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;  } |