aboutsummaryrefslogtreecommitdiff
path: root/ext/mixed/js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/mixed/js')
-rw-r--r--ext/mixed/js/audio.js24
-rw-r--r--ext/mixed/js/core.js (renamed from ext/mixed/js/extension.js)98
-rw-r--r--ext/mixed/js/display-context.js55
-rw-r--r--ext/mixed/js/display.js270
-rw-r--r--ext/mixed/js/dom.js66
-rw-r--r--ext/mixed/js/japanese.js100
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;
}