diff options
Diffstat (limited to 'ext/mixed/js')
-rw-r--r-- | ext/mixed/js/audio.js | 24 | ||||
-rw-r--r-- | ext/mixed/js/core.js (renamed from ext/mixed/js/extension.js) | 98 | ||||
-rw-r--r-- | ext/mixed/js/display-context.js | 55 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 270 | ||||
-rw-r--r-- | ext/mixed/js/dom.js | 66 | ||||
-rw-r--r-- | ext/mixed/js/japanese.js | 100 |
6 files changed, 465 insertions, 148 deletions
diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js index cf8b8d24..35f283a4 100644 --- a/ext/mixed/js/audio.js +++ b/ext/mixed/js/audio.js @@ -68,14 +68,14 @@ class TextToSpeechAudio { } static createFromUri(ttsUri) { - const m = /^tts:[^#\?]*\?([^#]*)/.exec(ttsUri); + const m = /^tts:[^#?]*\?([^#]*)/.exec(ttsUri); if (m === null) { return null; } const searchParameters = {}; for (const group of m[1].split('&')) { const sep = group.indexOf('='); if (sep < 0) { continue; } - searchParameters[decodeURIComponent(group.substr(0, sep))] = decodeURIComponent(group.substr(sep + 1)); + searchParameters[decodeURIComponent(group.substring(0, sep))] = decodeURIComponent(group.substring(sep + 1)); } if (!searchParameters.text) { return null; } @@ -88,19 +88,15 @@ class TextToSpeechAudio { } -function audioGetFromUrl(url, download) { +function audioGetFromUrl(url, willDownload) { const tts = TextToSpeechAudio.createFromUri(url); if (tts !== null) { - if (download) { - throw new Error('Download not supported for text-to-speech'); + if (willDownload) { + throw new Error('AnkiConnect does not support downloading text-to-speech audio.'); } return Promise.resolve(tts); } - if (download) { - return Promise.resolve(null); - } - return new Promise((resolve, reject) => { const audio = new Audio(url); audio.addEventListener('loadeddata', () => { @@ -115,9 +111,9 @@ function audioGetFromUrl(url, download) { }); } -async function audioGetFromSources(expression, sources, optionsContext, download, cache=null) { +async function audioGetFromSources(expression, sources, optionsContext, willDownload, cache=null) { const key = `${expression.expression}:${expression.reading}`; - if (cache !== null && cache.hasOwnProperty(expression)) { + if (cache !== null && hasOwn(cache, expression)) { return cache[key]; } @@ -129,7 +125,11 @@ async function audioGetFromSources(expression, sources, optionsContext, download } try { - const audio = await audioGetFromUrl(url, download); + let audio = await audioGetFromUrl(url, willDownload); + if (willDownload) { + // AnkiConnect handles downloading URLs into cards + audio = null; + } const result = {audio, url, source}; if (cache !== null) { cache[key] = result; diff --git a/ext/mixed/js/extension.js b/ext/mixed/js/core.js index 54862e19..b5911535 100644 --- a/ext/mixed/js/extension.js +++ b/ext/mixed/js/core.js @@ -17,27 +17,11 @@ */ -// toIterable is required on Edge for cross-window origin objects. -function toIterable(value) { - if (typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] !== 'undefined') { - return value; - } - - if (value !== null && typeof value === 'object') { - const length = value.length; - if (typeof length === 'number' && Number.isFinite(length)) { - const array = []; - for (let i = 0; i < length; ++i) { - array.push(value[i]); - } - return array; - } - } - - throw new Error('Could not convert to iterable'); -} +/* + * Extension information + */ -function extensionHasChrome() { +function _extensionHasChrome() { try { return typeof chrome === 'object' && chrome !== null; } catch (e) { @@ -45,7 +29,7 @@ function extensionHasChrome() { } } -function extensionHasBrowser() { +function _extensionHasBrowser() { try { return typeof browser === 'object' && browser !== null; } catch (e) { @@ -53,6 +37,21 @@ function extensionHasBrowser() { } } +const EXTENSION_IS_BROWSER_EDGE = ( + _extensionHasBrowser() && + (!_extensionHasChrome() || (typeof chrome.runtime === 'undefined' && typeof browser.runtime !== 'undefined')) +); + +if (EXTENSION_IS_BROWSER_EDGE) { + // Edge does not have chrome defined. + chrome = browser; +} + + +/* + * Error handling + */ + function errorToJson(error) { return { name: error.name, @@ -86,16 +85,44 @@ function logError(error, alert) { } } -const EXTENSION_IS_BROWSER_EDGE = ( - extensionHasBrowser() && - (!extensionHasChrome() || (typeof chrome.runtime === 'undefined' && typeof browser.runtime !== 'undefined')) -); -if (EXTENSION_IS_BROWSER_EDGE) { - // Edge does not have chrome defined. - chrome = browser; +/* + * Common helpers + */ + +function isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); } +function hasOwn(object, property) { + return Object.prototype.hasOwnProperty.call(object, property); +} + +// toIterable is required on Edge for cross-window origin objects. +function toIterable(value) { + if (typeof Symbol !== 'undefined' && typeof value[Symbol.iterator] !== 'undefined') { + return value; + } + + if (value !== null && typeof value === 'object') { + const length = value.length; + if (typeof length === 'number' && Number.isFinite(length)) { + const array = []; + for (let i = 0; i < length; ++i) { + array.push(value[i]); + } + return array; + } + } + + throw new Error('Could not convert to iterable'); +} + + +/* + * Async utilities + */ + function promiseTimeout(delay, resolveValue) { if (delay <= 0) { return Promise.resolve(resolveValue); @@ -133,3 +160,18 @@ function promiseTimeout(delay, resolveValue) { return promise; } + +function stringReplaceAsync(str, regex, replacer) { + let match; + let index = 0; + const parts = []; + while ((match = regex.exec(str)) !== null) { + parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); + index = regex.lastIndex; + } + if (parts.length === 0) { + return Promise.resolve(str); + } + parts.push(str.substring(index)); + return Promise.all(parts).then((v) => v.join('')); +} diff --git a/ext/mixed/js/display-context.js b/ext/mixed/js/display-context.js new file mode 100644 index 00000000..4b399881 --- /dev/null +++ b/ext/mixed/js/display-context.js @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>. + */ + + +class DisplayContext { + constructor(type, definitions, context) { + this.type = type; + this.definitions = definitions; + this.context = context; + } + + get(key) { + return this.context[key]; + } + + set(key, value) { + this.context[key] = value; + } + + update(data) { + Object.assign(this.context, data); + } + + get previous() { + return this.context.previous; + } + + get next() { + return this.context.next; + } + + static push(self, type, definitions, context) { + const newContext = new DisplayContext(type, definitions, context); + if (self !== null) { + newContext.update({previous: self}); + self.update({next: newContext}); + } + return newContext; + } +} diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 6d992897..c32852ad 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -42,7 +42,7 @@ class Display { this.setInteractive(true); } - onError(error) { + onError(_error) { throw new Error('Override me'); } @@ -55,93 +55,117 @@ class Display { this.sourceTermView(); } + onNextTermView(e) { + e.preventDefault(); + this.nextTermView(); + } + async onKanjiLookup(e) { try { e.preventDefault(); + if (!this.context) { return; } const link = e.target; - this.windowScroll.toY(0); + this.context.update({ + index: this.entryIndexFind(link), + scroll: this.windowScroll.y + }); const context = { - source: { - definitions: this.definitions, - index: this.entryIndexFind(link), - scroll: this.windowScroll.y - } + sentence: this.context.get('sentence'), + url: this.context.get('url') }; - if (this.context) { - context.sentence = this.context.sentence; - context.url = this.context.url; - context.source.source = this.context.source; - } - const definitions = await apiKanjiFind(link.textContent, this.getOptionsContext()); this.setContentKanji(definitions, context); - } catch (e) { - this.onError(e); + } catch (error) { + this.onError(error); } } onGlossaryMouseDown(e) { - if (Frontend.isMouseButton('primary', e)) { + if (DOM.isMouseButtonPressed(e, 'primary')) { this.clickScanPrevent = false; } } - onGlossaryMouseMove(e) { + onGlossaryMouseMove() { this.clickScanPrevent = true; } onGlossaryMouseUp(e) { - if (!this.clickScanPrevent && Frontend.isMouseButton('primary', e)) { + if (!this.clickScanPrevent && DOM.isMouseButtonPressed(e, 'primary')) { this.onTermLookup(e); } } - async onTermLookup(e) { + async onTermLookup(e, {disableScroll, selectText, disableHistory}={}) { + try { + if (!this.context) { return; } + + const termLookupResults = await this.termLookup(e); + if (!termLookupResults) { return; } + const {textSource, definitions} = termLookupResults; + + const scannedElement = e.target; + const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); + + this.context.update({ + index: this.entryIndexFind(scannedElement), + scroll: this.windowScroll.y + }); + const context = { + disableScroll, + disableHistory, + sentence, + url: this.context.get('url') + }; + if (disableHistory) { + Object.assign(context, { + previous: this.context.previous, + next: this.context.next + }); + } else { + Object.assign(context, { + previous: this.context + }); + } + + this.setContentTerms(definitions, context); + + if (selectText) { + textSource.select(); + } + } catch (error) { + this.onError(error); + } + } + + async termLookup(e) { try { e.preventDefault(); - const clickedElement = e.target; const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options); if (textSource === null) { return false; } - let definitions, length, sentence; + let definitions, length; try { textSource.setEndOffset(this.options.scanning.length); - ({definitions, length} = await apiTermsFind(textSource.text(), this.getOptionsContext())); + ({definitions, length} = await apiTermsFind(textSource.text(), {}, this.getOptionsContext())); if (definitions.length === 0) { return false; } textSource.setEndOffset(length); - - sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); } finally { textSource.cleanup(); } - this.windowScroll.toY(0); - const context = { - source: { - definitions: this.definitions, - index: this.entryIndexFind(clickedElement), - scroll: this.windowScroll.y - } - }; - - if (this.context) { - context.sentence = sentence; - context.url = this.context.url; - context.source.source = this.context.source; - } - - this.setContentTerms(definitions, context); - } catch (e) { - this.onError(e); + return {textSource, definitions}; + } catch (error) { + this.onError(error); } } @@ -170,7 +194,7 @@ class Display { onKeyDown(e) { const key = Display.getKeyFromEvent(e); const handlers = Display.onKeyDownHandlers; - if (handlers.hasOwnProperty(key)) { + if (hasOwn(handlers, key)) { const handler = handlers[key]; if (handler(this, e)) { e.preventDefault(); @@ -182,9 +206,17 @@ class Display { onWheel(e) { if (e.altKey) { - const delta = e.deltaY; - if (delta !== 0) { - this.entryScrollIntoView(this.index + (delta > 0 ? 1 : -1), null, true); + if (e.deltaY !== 0) { + this.entryScrollIntoView(this.index + (e.deltaY > 0 ? 1 : -1), null, true); + e.preventDefault(); + } + } else if (e.shiftKey) { + const delta = -e.deltaX || e.deltaY; + if (delta > 0) { + this.sourceTermView(); + e.preventDefault(); + } else if (delta < 0) { + this.nextTermView(); e.preventDefault(); } } @@ -192,7 +224,7 @@ class Display { onRuntimeMessage({action, params}, sender, callback) { const handlers = Display.runtimeMessageHandlers; - if (handlers.hasOwnProperty(action)) { + if (hasOwn(handlers, action)) { const handler = handlers[action]; const result = handler(this, params); callback(result); @@ -268,6 +300,7 @@ class Display { this.addEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this)); + this.addEventListeners('.next-term', 'click', this.onNextTermView.bind(this)); if (this.options.scanning.enablePopupSearch) { this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this)); this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this)); @@ -279,9 +312,9 @@ class Display { } addEventListeners(selector, type, listener, options) { - this.container.querySelectorAll(selector).forEach((node) => { + for (const node of this.container.querySelectorAll(selector)) { Display.addEventListener(this.eventListeners, node, type, listener, options); - }); + } } setContent(type, details) { @@ -298,6 +331,7 @@ class Display { } async setContentTerms(definitions, context) { + if (!context) { throw new Error('Context expected'); } if (!this.isInitialized()) { return; } try { @@ -305,17 +339,23 @@ class Display { this.setEventListenersActive(false); - if (!context || context.focus !== false) { + if (context.focus !== false) { window.focus(); } this.definitions = definitions; - this.context = context; + if (context.disableHistory) { + delete context.disableHistory; + this.context = new DisplayContext('terms', definitions, context); + } else { + this.context = DisplayContext.push(this.context, 'terms', definitions, context); + } const sequence = ++this.sequence; const params = { definitions, - source: context && context.source, + source: this.context.previous, + next: this.context.next, addable: options.anki.enable, grouped: options.general.resultOutputMode === 'group', merged: options.general.resultOutputMode === 'merge', @@ -324,20 +364,20 @@ class Display { debug: options.general.debugInfo }; - if (context) { - for (const definition of definitions) { - if (context.sentence) { - definition.cloze = Display.clozeBuild(context.sentence, definition.source); - } - - definition.url = context.url; - } + for (const definition of definitions) { + definition.cloze = Display.clozeBuild(context.sentence, definition.source); + definition.url = context.url; } const content = await apiTemplateRender('terms.html', params); this.container.innerHTML = content; - const {index, scroll} = context || {}; - this.entryScrollIntoView(index || 0, scroll); + const {index, scroll, disableScroll} = context; + if (!disableScroll) { + this.entryScrollIntoView(index || 0, scroll); + } else { + delete context.disableScroll; + this.entrySetCurrent(index || 0); + } if (options.audio.enabled && options.audio.autoPlay) { this.autoPlayAudio(); @@ -352,6 +392,7 @@ class Display { } async setContentKanji(definitions, context) { + if (!context) { throw new Error('Context expected'); } if (!this.isInitialized()) { return; } try { @@ -359,34 +400,35 @@ class Display { this.setEventListenersActive(false); - if (!context || context.focus !== false) { + if (context.focus !== false) { window.focus(); } this.definitions = definitions; - this.context = context; + if (context.disableHistory) { + delete context.disableHistory; + this.context = new DisplayContext('kanji', definitions, context); + } else { + this.context = DisplayContext.push(this.context, 'kanji', definitions, context); + } const sequence = ++this.sequence; const params = { definitions, - source: context && context.source, + source: this.context.previous, + next: this.context.next, addable: options.anki.enable, debug: options.general.debugInfo }; - if (context) { - for (const definition of definitions) { - if (context.sentence) { - definition.cloze = Display.clozeBuild(context.sentence); - } - - definition.url = context.url; - } + for (const definition of definitions) { + definition.cloze = Display.clozeBuild(context.sentence, definition.character); + definition.url = context.url; } const content = await apiTemplateRender('kanji.html', params); this.container.innerHTML = content; - const {index, scroll} = context || {}; + const {index, scroll} = context; this.entryScrollIntoView(index || 0, scroll); this.setEventListenersActive(true); @@ -446,7 +488,7 @@ class Display { } } - entryScrollIntoView(index, scroll, smooth) { + entrySetCurrent(index) { index = Math.min(index, this.definitions.length - 1); index = Math.max(index, 0); @@ -460,13 +502,20 @@ class Display { entry.classList.add('entry-current'); } + this.index = index; + + return entry; + } + + entryScrollIntoView(index, scroll, smooth) { this.windowScroll.stop(); - let target; - if (scroll) { + const entry = this.entrySetCurrent(index); + let target; + if (scroll !== null) { target = scroll; } else { - target = index === 0 || entry === null ? 0 : Display.getElementTop(entry); + target = this.index === 0 || entry === null ? 0 : Display.getElementTop(entry); } if (smooth) { @@ -474,22 +523,36 @@ class Display { } else { this.windowScroll.toY(target); } - - this.index = index; } sourceTermView() { - if (this.context && this.context.source) { - const context = { - url: this.context.source.url, - sentence: this.context.source.sentence, - index: this.context.source.index, - scroll: this.context.source.scroll, - source: this.context.source.source - }; + if (!this.context || !this.context.previous) { return; } + this.context.update({ + index: this.index, + scroll: this.windowScroll.y + }); + const previousContext = this.context.previous; + previousContext.set('disableHistory', true); + const details = { + definitions: previousContext.definitions, + context: previousContext.context + }; + this.setContent(previousContext.type, details); + } - this.setContentTerms(this.context.source.definitions, context); - } + nextTermView() { + if (!this.context || !this.context.next) { return; } + this.context.update({ + index: this.index, + scroll: this.windowScroll.y + }); + const nextContext = this.context.next; + nextContext.set('disableHistory', true); + const details = { + definitions: nextContext.definitions, + context: nextContext.context + }; + this.setContent(nextContext.type, details); } noteTryAdd(mode) { @@ -564,7 +627,7 @@ class Display { if (button !== null) { let titleDefault = button.dataset.titleDefault; if (!titleDefault) { - titleDefault = button.title || ""; + titleDefault = button.title || ''; button.dataset.titleDefault = titleDefault; } button.title = `${titleDefault}\n${info}`; @@ -623,18 +686,13 @@ class Display { return index >= 0 && index < entries.length ? entries[index] : null; } - static clozeBuild(sentence, source) { - const result = { - sentence: sentence.text.trim() + static clozeBuild({text, offset}, source) { + return { + sentence: text.trim(), + prefix: text.substring(0, offset).trim(), + body: text.substring(offset, offset + source.length), + suffix: text.substring(offset + source.length).trim() }; - - if (source) { - result.prefix = sentence.text.substring(0, sentence.offset).trim(); - result.body = source.trim(); - result.suffix = sentence.text.substring(sentence.offset + source.length).trim(); - } - - return result; } entryIndexFind(element) { @@ -765,6 +823,14 @@ Display.onKeyDownHandlers = { return false; }, + 'F': (self, e) => { + if (e.altKey) { + self.nextTermView(); + return true; + } + return false; + }, + 'E': (self, e) => { if (e.altKey) { self.noteTryAdd('term-kanji'); diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js new file mode 100644 index 00000000..4e4d49e3 --- /dev/null +++ b/ext/mixed/js/dom.js @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2019 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 <http://www.gnu.org/licenses/>. + */ + + +class DOM { + static isPointInRect(x, y, rect) { + return ( + x >= rect.left && x < rect.right && + y >= rect.top && y < rect.bottom + ); + } + + static isPointInAnyRect(x, y, rects) { + for (const rect of rects) { + if (DOM.isPointInRect(x, y, rect)) { + return true; + } + } + return false; + } + + static isPointInSelection(x, y, selection) { + for (let i = 0; i < selection.rangeCount; ++i) { + const range = selection.getRangeAt(i); + if (DOM.isPointInAnyRect(x, y, range.getClientRects())) { + return true; + } + } + return false; + } + + static isMouseButtonPressed(mouseEvent, button) { + const mouseEventButton = mouseEvent.button; + switch (button) { + case 'primary': return mouseEventButton === 0; + case 'secondary': return mouseEventButton === 2; + case 'auxiliary': return mouseEventButton === 1; + default: return false; + } + } + + static isMouseButtonDown(mouseEvent, button) { + const mouseEventButtons = mouseEvent.buttons; + switch (button) { + case 'primary': return (mouseEventButtons & 0x1) !== 0x0; + case 'secondary': return (mouseEventButtons & 0x2) !== 0x0; + case 'auxiliary': return (mouseEventButtons & 0x4) !== 0x0; + default: return false; + } + } +} diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js index 9f401da7..8b841b2e 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/mixed/js/japanese.js @@ -26,6 +26,15 @@ function jpIsKana(c) { return wanakana.isKana(c); } +function jpIsJapaneseText(text) { + for (const c of text) { + if (jpIsKanji(c) || jpIsKana(c)) { + return true; + } + } + return false; +} + function jpKatakanaToHiragana(text) { let result = ''; for (const c of text) { @@ -39,36 +48,84 @@ function jpKatakanaToHiragana(text) { 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 (jpIsKana(expressionFragment)) { + return jpToRomaji(expressionFragment); + } + } + return readingFragment; + default: + return readingFragment; + } +} + function jpDistributeFurigana(expression, reading) { const fallback = [{furigana: reading, text: expression}]; if (!reading) { return fallback; } + let isAmbiguous = false; const segmentize = (reading, groups) => { - if (groups.length === 0) { + if (groups.length === 0 || isAmbiguous) { return []; } const group = groups[0]; if (group.mode === 'kana') { - if (reading.startsWith(group.text)) { - const readingUsed = reading.substring(0, group.text.length); + if (jpKatakanaToHiragana(reading).startsWith(jpKatakanaToHiragana(group.text))) { const readingLeft = reading.substring(group.text.length); const segs = segmentize(readingLeft, groups.splice(1)); if (segs) { - return [{text: readingUsed}].concat(segs); + return [{text: group.text}].concat(segs); } } } else { + let foundSegments = null; for (let i = reading.length; i >= group.text.length; --i) { const readingUsed = reading.substring(0, i); const readingLeft = reading.substring(i); const segs = segmentize(readingLeft, groups.slice(1)); if (segs) { - return [{text: group.text, furigana: readingUsed}].concat(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; } }; @@ -84,5 +141,36 @@ function jpDistributeFurigana(expression, reading) { } } - return segmentize(reading, groups) || fallback; + 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.slice(0, source.length - offset); + const stemReading = reading.slice( + 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.slice(stemLength)}); + } + + return output; } |