/* * Copyright (C) 2016-2020 Alex Yatskov <alex@foosoft.net> * Author: Alex Yatskov <alex@foosoft.net> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ /*global wanakana*/ const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([ ['ヲ', 'ヲヺ-'], ['ァ', 'ァ--'], ['ィ', 'ィ--'], ['ゥ', 'ゥ--'], ['ェ', 'ェ--'], ['ォ', 'ォ--'], ['ャ', 'ャ--'], ['ュ', 'ュ--'], ['ョ', 'ョ--'], ['ッ', 'ッ--'], ['ー', 'ー--'], ['ア', 'ア--'], ['イ', 'イ--'], ['ウ', 'ウヴ-'], ['エ', 'エ--'], ['オ', 'オ--'], ['カ', 'カガ-'], ['キ', 'キギ-'], ['ク', 'クグ-'], ['ケ', 'ケゲ-'], ['コ', 'コゴ-'], ['サ', 'サザ-'], ['シ', 'シジ-'], ['ス', 'スズ-'], ['セ', 'セゼ-'], ['ソ', 'ソゾ-'], ['タ', 'タダ-'], ['チ', 'チヂ-'], ['ツ', 'ツヅ-'], ['テ', 'テデ-'], ['ト', 'トド-'], ['ナ', 'ナ--'], ['ニ', 'ニ--'], ['ヌ', 'ヌ--'], ['ネ', 'ネ--'], ['ノ', 'ノ--'], ['ハ', 'ハバパ'], ['ヒ', 'ヒビピ'], ['フ', 'フブプ'], ['ヘ', 'ヘベペ'], ['ホ', 'ホボポ'], ['マ', 'マ--'], ['ミ', 'ミ--'], ['ム', 'ム--'], ['メ', 'メ--'], ['モ', 'モ--'], ['ヤ', 'ヤ--'], ['ユ', 'ユ--'], ['ヨ', 'ヨ--'], ['ラ', 'ラ--'], ['リ', 'リ--'], ['ル', 'ル--'], ['レ', 'レ--'], ['ロ', 'ロ--'], ['ワ', 'ワ--'], ['ン', 'ン--'] ]); const JP_HIRAGANA_RANGE = [0x3040, 0x309f]; const JP_KATAKANA_RANGE = [0x30a0, 0x30ff]; const JP_KANA_RANGES = [JP_HIRAGANA_RANGE, JP_KATAKANA_RANGE]; const JP_CJK_COMMON_RANGE = [0x4e00, 0x9fff]; const JP_CJK_RARE_RANGE = [0x3400, 0x4dbf]; const JP_CJK_RANGES = [JP_CJK_COMMON_RANGE, JP_CJK_RARE_RANGE]; const JP_ITERATION_MARK_CHAR_CODE = 0x3005; // Japanese character ranges, roughly ordered in order of expected frequency const JP_JAPANESE_RANGES = [ JP_HIRAGANA_RANGE, JP_KATAKANA_RANGE, JP_CJK_COMMON_RANGE, JP_CJK_RARE_RANGE, [0xff66, 0xff9f], // Halfwidth katakana [0x30fb, 0x30fc], // Katakana punctuation [0xff61, 0xff65], // Kana punctuation [0x3000, 0x303f], // CJK punctuation [0xff10, 0xff19], // Fullwidth numbers [0xff21, 0xff3a], // Fullwidth upper case Latin letters [0xff41, 0xff5a], // Fullwidth lower case Latin letters [0xff01, 0xff0f], // Fullwidth punctuation 1 [0xff1a, 0xff1f], // Fullwidth punctuation 2 [0xff3b, 0xff3f], // Fullwidth punctuation 3 [0xff5b, 0xff60], // Fullwidth punctuation 4 [0xffe0, 0xffee] // Currency markers ]; // Helper functions function _jpIsCharCodeInRanges(charCode, ranges) { for (const [min, max] of ranges) { if (charCode >= min && charCode <= max) { return true; } } return false; } // Character code testing functions function jpIsCharCodeKanji(charCode) { return _jpIsCharCodeInRanges(charCode, JP_CJK_RANGES); } function jpIsCharCodeKana(charCode) { return _jpIsCharCodeInRanges(charCode, JP_KANA_RANGES); } function jpIsCharCodeJapanese(charCode) { return _jpIsCharCodeInRanges(charCode, JP_JAPANESE_RANGES); } // String testing functions function jpIsStringEntirelyKana(str) { if (str.length === 0) { return false; } for (let i = 0, ii = str.length; i < ii; ++i) { if (!jpIsCharCodeKana(str.charCodeAt(i))) { return false; } } return true; } function jpIsStringPartiallyJapanese(str) { if (str.length === 0) { return false; } for (let i = 0, ii = str.length; i < ii; ++i) { if (jpIsCharCodeJapanese(str.charCodeAt(i))) { return true; } } return false; } // Conversion functions function jpKatakanaToHiragana(text) { let result = ''; for (const c of text) { if (wanakana.isKatakana(c)) { result += wanakana.toHiragana(c); } else { result += c; } } return result; } function jpHiraganaToKatakana(text) { let result = ''; for (const c of text) { if (wanakana.isHiragana(c)) { result += wanakana.toKatakana(c); } else { result += c; } } return result; } function jpToRomaji(text) { return wanakana.toRomaji(text); } function jpConvertReading(expressionFragment, readingFragment, readingMode) { switch (readingMode) { case 'hiragana': return jpKatakanaToHiragana(readingFragment || ''); case 'katakana': return jpHiraganaToKatakana(readingFragment || ''); case 'romaji': if (readingFragment) { return jpToRomaji(readingFragment); } else { if (jpIsStringEntirelyKana(expressionFragment)) { return jpToRomaji(expressionFragment); } } return readingFragment; case 'none': return null; default: return readingFragment; } } function jpDistributeFurigana(expression, reading) { const fallback = [{furigana: reading, text: expression}]; if (!reading) { return fallback; } let isAmbiguous = false; const segmentize = (reading2, groups) => { if (groups.length === 0 || isAmbiguous) { return []; } const group = groups[0]; if (group.mode === 'kana') { if (jpKatakanaToHiragana(reading2).startsWith(jpKatakanaToHiragana(group.text))) { const readingLeft = reading2.substring(group.text.length); const segs = segmentize(readingLeft, groups.splice(1)); if (segs) { return [{text: group.text}].concat(segs); } } } else { let foundSegments = null; for (let i = reading2.length; i >= group.text.length; --i) { const readingUsed = reading2.substring(0, i); const readingLeft = reading2.substring(i); const segs = segmentize(readingLeft, groups.slice(1)); if (segs) { if (foundSegments !== null) { // more than one way to segmentize the tail, mark as ambiguous isAmbiguous = true; return null; } foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs); } // there is only one way to segmentize the last non-kana group if (groups.length === 1) { break; } } return foundSegments; } }; const groups = []; let modePrev = null; for (const c of expression) { const charCode = c.charCodeAt(0); const modeCurr = jpIsCharCodeKanji(charCode) || charCode === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana'; if (modeCurr === modePrev) { groups[groups.length - 1].text += c; } else { groups.push({mode: modeCurr, text: c}); modePrev = modeCurr; } } const segments = segmentize(reading, groups); if (segments && !isAmbiguous) { return segments; } return fallback; } function jpDistributeFuriganaInflected(expression, reading, source) { const output = []; let stemLength = 0; const shortest = Math.min(source.length, expression.length); const sourceHiragana = jpKatakanaToHiragana(source); const expressionHiragana = jpKatakanaToHiragana(expression); while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) { ++stemLength; } const offset = source.length - stemLength; const stemExpression = source.substring(0, source.length - offset); const stemReading = reading.substring( 0, offset === 0 ? reading.length : reading.length - expression.length + stemLength ); for (const segment of jpDistributeFurigana(stemExpression, stemReading)) { output.push(segment); } if (stemLength !== source.length) { output.push({text: source.substring(stemLength)}); } return output; } function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) { let result = ''; const ii = text.length; const hasSourceMapping = Array.isArray(sourceMapping); for (let i = 0; i < ii; ++i) { const c = text[i]; const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c); if (typeof mapping !== 'string') { result += c; continue; } let index = 0; switch (text.charCodeAt(i + 1)) { case 0xff9e: // dakuten index = 1; break; case 0xff9f: // handakuten index = 2; break; } let c2 = mapping[index]; if (index > 0) { if (c2 === '-') { // invalid index = 0; c2 = mapping[0]; } else { ++i; } } if (hasSourceMapping && index > 0) { index = result.length; const v = sourceMapping.splice(index + 1, 1)[0]; sourceMapping[index] += v; } result += c2; } return result; } function jpConvertNumericTofullWidth(text) { let result = ''; for (let i = 0, ii = text.length; i < ii; ++i) { let c = text.charCodeAt(i); if (c >= 0x30 && c <= 0x39) { // ['0', '9'] c += 0xff10 - 0x30; // 0xff10 = '0' full width result += String.fromCharCode(c); } else { result += text[i]; } } return result; } function jpConvertAlphabeticToKana(text, sourceMapping) { let part = ''; let result = ''; const ii = text.length; if (sourceMapping.length === ii) { sourceMapping.length = ii; sourceMapping.fill(1); } for (let i = 0; i < ii; ++i) { // Note: 0x61 is the character code for 'a' let c = text.charCodeAt(i); if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] c += (0x61 - 0x41); } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] // NOP; c += (0x61 - 0x61); } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth c += (0x61 - 0xff21); } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth c += (0x61 - 0xff41); } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash c = 0x2d; // '-' } else { if (part.length > 0) { result += jpToHiragana(part, sourceMapping, result.length); part = ''; } result += text[i]; continue; } part += String.fromCharCode(c); } if (part.length > 0) { result += jpToHiragana(part, sourceMapping, result.length); } return result; } function jpToHiragana(text, sourceMapping, sourceMappingStart) { const result = wanakana.toHiragana(text); // Generate source mapping if (Array.isArray(sourceMapping)) { if (typeof sourceMappingStart !== 'number') { sourceMappingStart = 0; } let i = 0; let resultPos = 0; const ii = text.length; while (i < ii) { // Find smallest matching substring let iNext = i + 1; let resultPosNext = result.length; while (iNext < ii) { const t = wanakana.toHiragana(text.substring(0, iNext)); if (t === result.substring(0, t.length)) { resultPosNext = t.length; break; } ++iNext; } // Merge characters const removals = iNext - i - 1; if (removals > 0) { let sum = 0; const vs = sourceMapping.splice(sourceMappingStart + 1, removals); for (const v of vs) { sum += v; } sourceMapping[sourceMappingStart] += sum; } ++sourceMappingStart; // Empty elements const additions = resultPosNext - resultPos - 1; for (let j = 0; j < additions; ++j) { sourceMapping.splice(sourceMappingStart, 0, 0); ++sourceMappingStart; } i = iNext; resultPos = resultPosNext; } } return result; }