Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2021-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {Handlebars} from '../../lib/handlebars.js';
20import {NodeFilter} from '../../lib/linkedom.js';
21import {createAnkiNoteData} from '../data/anki-note-data-creator.js';
22import {getPronunciationsOfType, isNonNounVerbOrAdjective} from '../dictionary/dictionary-data-util.js';
23import {PronunciationGenerator} from '../display/pronunciation-generator.js';
24import {StructuredContentGenerator} from '../display/structured-content-generator.js';
25import {CssStyleApplier} from '../dom/css-style-applier.js';
26import {convertHiraganaToKatakana, convertKatakanaToHiragana, distributeFurigana, getKanaMorae, getPitchCategory, isMoraPitchHigh} from '../language/ja/japanese.js';
27import {AnkiTemplateRendererContentManager} from './anki-template-renderer-content-manager.js';
28import {TemplateRendererMediaProvider} from './template-renderer-media-provider.js';
29import {TemplateRenderer} from './template-renderer.js';
30
31/**
32 * This class contains all Anki-specific template rendering functionality. It is built on
33 * the generic TemplateRenderer class and various other Anki-related classes.
34 */
35export class AnkiTemplateRenderer {
36 /**
37 * Creates a new instance of the class.
38 * @param {Document} document
39 * @param {Window} window
40 */
41 constructor(document, window) {
42 /** @type {CssStyleApplier} */
43 this._structuredContentStyleApplier = new CssStyleApplier('/data/structured-content-style.json');
44 /** @type {CssStyleApplier} */
45 this._pronunciationStyleApplier = new CssStyleApplier('/data/pronunciation-style.json');
46 /** @type {RegExp} */
47 this._structuredContentDatasetKeyIgnorePattern = /^sc([^a-z]|$)/;
48 /** @type {TemplateRenderer} */
49 this._templateRenderer = new TemplateRenderer();
50 /** @type {TemplateRendererMediaProvider} */
51 this._mediaProvider = new TemplateRendererMediaProvider();
52 /** @type {?(Map<string, unknown>[])} */
53 this._stateStack = null;
54 /** @type {?import('anki-note-builder').Requirement[]} */
55 this._requirements = null;
56 /** @type {(() => void)[]} */
57 this._cleanupCallbacks = [];
58 /** @type {?HTMLElement} */
59 this._temporaryElement = null;
60 /** @type {Document} */
61 this._document = document;
62 /** @type {Window} */
63 this._window = window;
64 /** @type {PronunciationGenerator} */
65 this._pronunciationGenerator = new PronunciationGenerator(this._document);
66 }
67
68 /**
69 * Gets the generic TemplateRenderer instance.
70 * @type {TemplateRenderer}
71 */
72 get templateRenderer() {
73 return this._templateRenderer;
74 }
75
76 /**
77 * Prepares the data that is necessary before the template renderer can be safely used.
78 */
79 async prepare() {
80 /* eslint-disable @stylistic/no-multi-spaces */
81 this._templateRenderer.registerHelpers([
82 ['dumpObject', this._dumpObject.bind(this)],
83 ['furigana', this._furigana.bind(this)],
84 ['furiganaPlain', this._furiganaPlain.bind(this)],
85 ['multiLine', this._multiLine.bind(this)],
86 ['regexReplace', this._regexReplace.bind(this)],
87 ['regexMatch', this._regexMatch.bind(this)],
88 ['mergeTags', this._mergeTags.bind(this)],
89 ['eachUpTo', this._eachUpTo.bind(this)],
90 ['spread', this._spread.bind(this)],
91 ['op', this._op.bind(this)],
92 ['get', this._get.bind(this)],
93 ['set', this._set.bind(this)],
94 ['scope', this._scope.bind(this)],
95 ['property', this._property.bind(this)],
96 ['noop', this._noop.bind(this)],
97 ['isMoraPitchHigh', this._isMoraPitchHigh.bind(this)],
98 ['getKanaMorae', this._getKanaMorae.bind(this)],
99 ['typeof', this._getTypeof.bind(this)],
100 ['join', this._join.bind(this)],
101 ['concat', this._concat.bind(this)],
102 ['pitchCategories', this._pitchCategories.bind(this)],
103 ['formatGlossary', this._formatGlossary.bind(this)],
104 ['formatGlossaryPlain', this._formatGlossaryPlain.bind(this)],
105 ['hasMedia', this._hasMedia.bind(this)],
106 ['getMedia', this._getMedia.bind(this)],
107 ['pronunciation', this._pronunciation.bind(this)],
108 ['hiragana', this._hiragana.bind(this)],
109 ['katakana', this._katakana.bind(this)],
110 ]);
111 /* eslint-enable @stylistic/no-multi-spaces */
112 this._templateRenderer.registerDataType('ankiNote', {
113 modifier: ({marker, commonData}) => createAnkiNoteData(marker, commonData),
114 composeData: ({marker}, commonData) => ({marker, commonData}),
115 });
116 this._templateRenderer.setRenderCallbacks(
117 this._onRenderSetup.bind(this),
118 this._onRenderCleanup.bind(this),
119 );
120 await Promise.all([
121 this._structuredContentStyleApplier.prepare(),
122 this._pronunciationStyleApplier.prepare(),
123 ]);
124 }
125
126 // Private
127
128 /**
129 * @returns {{requirements: import('anki-note-builder').Requirement[]}}
130 */
131 _onRenderSetup() {
132 /** @type {import('anki-note-builder').Requirement[]} */
133 const requirements = [];
134 this._stateStack = [new Map()];
135 this._requirements = requirements;
136 this._mediaProvider.requirements = requirements;
137 return {requirements};
138 }
139
140 /**
141 * @returns {void}
142 */
143 _onRenderCleanup() {
144 for (const callback of this._cleanupCallbacks) { callback(); }
145 this._stateStack = null;
146 this._requirements = null;
147 this._mediaProvider.requirements = null;
148 this._cleanupCallbacks.length = 0;
149 }
150
151 /**
152 * @param {string} text
153 * @returns {string}
154 */
155 _safeString(text) {
156 return new Handlebars.SafeString(text);
157 }
158
159 // Template helpers
160
161 /** @type {import('template-renderer').HelperFunction<string>} */
162 _dumpObject(object) {
163 return JSON.stringify(object, null, 4);
164 }
165
166 /** @type {import('template-renderer').HelperFunction<string>} */
167 _furigana(args, context, options) {
168 const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options);
169 const segments = distributeFurigana(expression, reading);
170
171 let result = '';
172 for (const {text, reading: reading2} of segments) {
173 result += (
174 reading2.length > 0 ?
175 `<ruby>${text}<rt>${reading2}</rt></ruby>` :
176 text
177 );
178 }
179
180 return this._safeString(result);
181 }
182
183 /** @type {import('template-renderer').HelperFunction<string>} */
184 _furiganaPlain(args, context, options) {
185 const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options);
186 const segments = distributeFurigana(expression, reading);
187
188 let result = '';
189 for (const {text, reading: reading2} of segments) {
190 if (reading2.length > 0) {
191 if (result.length > 0) { result += ' '; }
192 result += `${text}[${reading2}]`;
193 } else {
194 result += text;
195 }
196 }
197
198 return result;
199 }
200
201 /**
202 * @type {import('template-renderer').HelperFunction<{expression: string, reading: string}>}
203 */
204 _getFuriganaExpressionAndReading(args) {
205 let expression;
206 let reading;
207 if (args.length >= 2) {
208 [expression, reading] = /** @type {[expression?: string, reading?: string]} */ (args);
209 } else {
210 ({expression, reading} = /** @type {import('core').SerializableObject} */ (args[0]));
211 }
212 return {
213 expression: typeof expression === 'string' ? expression : '',
214 reading: typeof reading === 'string' ? reading : '',
215 };
216 }
217
218 /**
219 * @param {string} string
220 * @returns {string}
221 */
222 _stringToMultiLineHtml(string) {
223 return string.split('\n').join('<br>');
224 }
225
226 /** @type {import('template-renderer').HelperFunction<string>} */
227 _multiLine(_args, context, options) {
228 return this._stringToMultiLineHtml(this._computeValueString(options, context));
229 }
230
231 /**
232 * Usage:
233 * ```{{#regexReplace regex string [flags] [content]...}}content{{/regexReplace}}```
234 * - regex: regular expression string
235 * - string: string to replace
236 * - flags: optional flags for regular expression.
237 * e.g. "i" for case-insensitive, "g" for replace all
238 * @type {import('template-renderer').HelperFunction<string>}
239 */
240 _regexReplace(args, context, options) {
241 const argCount = args.length;
242 let value = this._computeValueString(options, context);
243 if (argCount > 3) {
244 value = `${args.slice(3).join('')}${value}`;
245 }
246 if (argCount > 1) {
247 try {
248 const [pattern, replacement, flags] = args;
249 if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); }
250 if (typeof replacement !== 'string') { throw new Error('Invalid replacement'); }
251 const regex = new RegExp(pattern, typeof flags === 'string' ? flags : 'g');
252 value = value.replace(regex, replacement);
253 } catch (e) {
254 return `${e}`;
255 }
256 }
257 return value;
258 }
259
260 /**
261 * Usage:
262 * {{#regexMatch regex [flags] [content]...}}content{{/regexMatch}}
263 * - regex: regular expression string
264 * - flags: optional flags for regular expression
265 * e.g. "i" for case-insensitive, "g" for match all
266 * @type {import('template-renderer').HelperFunction<string>}
267 */
268 _regexMatch(args, context, options) {
269 const argCount = args.length;
270 let value = this._computeValueString(options, context);
271 if (argCount > 2) {
272 value = `${args.slice(2).join('')}${value}`;
273 }
274 if (argCount > 0) {
275 try {
276 const [pattern, flags] = args;
277 if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); }
278 const regex = new RegExp(pattern, typeof flags === 'string' ? flags : '');
279 /** @type {string[]} */
280 const parts = [];
281 value.replace(regex, (g0) => {
282 parts.push(g0);
283 return g0;
284 });
285 value = parts.join('');
286 } catch (e) {
287 return `${e}`;
288 }
289 }
290 return value;
291 }
292
293 /**
294 * @type {import('template-renderer').HelperFunction<string>}
295 */
296 _mergeTags(args) {
297 const [object, isGroupMode, isMergeMode] = /** @type {[object: import('anki-templates').TermDictionaryEntry, isGroupMode: boolean, isMergeMode: boolean]} */ (args);
298 /** @type {import('anki-templates').Tag[][]} */
299 const tagSources = [];
300 if (Array.isArray(object.termTags)) {
301 tagSources.push(object.termTags);
302 }
303 if (isGroupMode || isMergeMode) {
304 const {definitions} = object;
305 if (Array.isArray(definitions)) {
306 for (const definition of definitions) {
307 tagSources.push(definition.definitionTags);
308 }
309 }
310 } else {
311 if (Array.isArray(object.definitionTags)) {
312 tagSources.push(object.definitionTags);
313 }
314 }
315
316 const tags = new Set();
317 for (const tagSource of tagSources) {
318 for (const tag of tagSource) {
319 tags.add(tag.name);
320 }
321 }
322
323 return [...tags].join(', ');
324 }
325
326 /** @type {import('template-renderer').HelperFunction<string>} */
327 _eachUpTo(args, context, options) {
328 const [iterable, maxCount] = /** @type {[iterable: Iterable<unknown>, maxCount: number]} */ (args);
329 if (iterable) {
330 const results = [];
331 let any = false;
332 for (const entry of iterable) {
333 any = true;
334 if (results.length >= maxCount) { break; }
335 const processedEntry = this._computeValue(options, entry);
336 results.push(processedEntry);
337 }
338 if (any) {
339 return results.join('');
340 }
341 }
342 return this._computeInverseString(options, context);
343 }
344
345 /** @type {import('template-renderer').HelperFunction<unknown[]>} */
346 _spread(args) {
347 const result = [];
348 for (const array of /** @type {Iterable<unknown>[]} */ (args)) {
349 try {
350 result.push(...array);
351 } catch (e) {
352 // NOP
353 }
354 }
355 return result;
356 }
357
358 /** @type {import('template-renderer').HelperFunction<unknown>} */
359 _op(args) {
360 const [operator] = /** @type {[operator: string, operand1: import('core').SafeAny, operand2?: import('core').SafeAny, operand3?: import('core').SafeAny]} */ (args);
361 switch (args.length) {
362 case 2: return this._evaluateUnaryExpression(operator, args[1]);
363 case 3: return this._evaluateBinaryExpression(operator, args[1], args[2]);
364 case 4: return this._evaluateTernaryExpression(operator, args[1], args[2], args[3]);
365 default: return void 0;
366 }
367 }
368
369 /**
370 * @param {string} operator
371 * @param {import('core').SafeAny} operand1
372 * @returns {unknown}
373 */
374 _evaluateUnaryExpression(operator, operand1) {
375 switch (operator) {
376 case '+': return +operand1;
377 case '-': return -operand1;
378 case '~': return ~operand1;
379 case '!': return !operand1;
380 default: return void 0;
381 }
382 }
383
384 /**
385 * @param {string} operator
386 * @param {import('core').SafeAny} operand1
387 * @param {import('core').SafeAny} operand2
388 * @returns {unknown}
389 */
390 _evaluateBinaryExpression(operator, operand1, operand2) {
391 switch (operator) {
392 case '+': return operand1 + operand2;
393 case '-': return operand1 - operand2;
394 case '/': return operand1 / operand2;
395 case '*': return operand1 * operand2;
396 case '%': return operand1 % operand2;
397 case '**': return operand1 ** operand2;
398 case '==': return operand1 == operand2; // eslint-disable-line eqeqeq
399 case '!=': return operand1 != operand2; // eslint-disable-line eqeqeq
400 case '===': return operand1 === operand2;
401 case '!==': return operand1 !== operand2;
402 case '<': return operand1 < operand2;
403 case '<=': return operand1 <= operand2;
404 case '>': return operand1 > operand2;
405 case '>=': return operand1 >= operand2;
406 case '<<': return operand1 << operand2;
407 case '>>': return operand1 >> operand2;
408 case '>>>': return operand1 >>> operand2;
409 case '&': return operand1 & operand2;
410 case '|': return operand1 | operand2;
411 case '^': return operand1 ^ operand2;
412 case '&&': return operand1 && operand2;
413 case '||': return operand1 || operand2;
414 default: return void 0;
415 }
416 }
417
418 /**
419 * @param {string} operator
420 * @param {import('core').SafeAny} operand1
421 * @param {import('core').SafeAny} operand2
422 * @param {import('core').SafeAny} operand3
423 * @returns {unknown}
424 */
425 _evaluateTernaryExpression(operator, operand1, operand2, operand3) {
426 switch (operator) {
427 case '?:': return operand1 ? operand2 : operand3;
428 default: return void 0;
429 }
430 }
431
432 /** @type {import('template-renderer').HelperFunction<unknown>} */
433 _get(args) {
434 const [key] = /** @type {[key: string]} */ (args);
435 const stateStack = this._stateStack;
436 if (stateStack === null) { throw new Error('Invalid state'); }
437 for (let i = stateStack.length; --i >= 0;) {
438 const map = stateStack[i];
439 if (map.has(key)) {
440 return map.get(key);
441 }
442 }
443 return void 0;
444 }
445
446 /** @type {import('template-renderer').HelperFunction<string>} */
447 _set(args, context, options) {
448 const stateStack = this._stateStack;
449 if (stateStack === null) { throw new Error('Invalid state'); }
450 switch (args.length) {
451 case 1:
452 {
453 const [key] = /** @type {[key: string]} */ (args);
454 const value = this._computeValue(options, context);
455 stateStack[stateStack.length - 1].set(key, value);
456 }
457 break;
458 case 2:
459 {
460 const [key, value] = /** @type {[key: string, value: unknown]} */ (args);
461 stateStack[stateStack.length - 1].set(key, value);
462 }
463 break;
464 }
465 return '';
466 }
467
468 /** @type {import('template-renderer').HelperFunction<unknown>} */
469 _scope(_args, context, options) {
470 const stateStack = this._stateStack;
471 if (stateStack === null) { throw new Error('Invalid state'); }
472 try {
473 stateStack.push(new Map());
474 return this._computeValue(options, context);
475 } finally {
476 if (stateStack.length > 1) {
477 stateStack.pop();
478 }
479 }
480 }
481
482 /** @type {import('template-renderer').HelperFunction<unknown>} */
483 _property(args) {
484 const ii = args.length;
485 if (ii <= 0) { return void 0; }
486
487 try {
488 let value = args[0];
489 for (let i = 1; i < ii; ++i) {
490 if (typeof value !== 'object' || value === null) { throw new Error('Invalid object'); }
491 const key = args[i];
492 switch (typeof key) {
493 case 'number':
494 case 'string':
495 case 'symbol':
496 break;
497 default:
498 throw new Error('Invalid key');
499 }
500 value = /** @type {import('core').UnknownObject} */ (value)[key];
501 }
502 return value;
503 } catch (e) {
504 return void 0;
505 }
506 }
507
508 /** @type {import('template-renderer').HelperFunction<unknown>} */
509 _noop(_args, context, options) {
510 return this._computeValue(options, context);
511 }
512
513 /** @type {import('template-renderer').HelperFunction<boolean>} */
514 _isMoraPitchHigh(args) {
515 const [index, position] = /** @type {[index: number, position: number]} */ (args);
516 return isMoraPitchHigh(index, position);
517 }
518
519 /** @type {import('template-renderer').HelperFunction<string[]>} */
520 _getKanaMorae(args) {
521 const [text] = /** @type {[text: string]} */ (args);
522 return getKanaMorae(`${text}`);
523 }
524
525 /** @type {import('template-renderer').HelperFunction<import('core').TypeofResult>} */
526 _getTypeof(args, context, options) {
527 const ii = args.length;
528 const value = (ii > 0 ? args[0] : this._computeValue(options, context));
529 return typeof value;
530 }
531
532 /** @type {import('template-renderer').HelperFunction<string>} */
533 _join(args) {
534 return args.length > 0 ? args.slice(1, args.length).flat().join(/** @type {string} */ (args[0])) : '';
535 }
536
537 /** @type {import('template-renderer').HelperFunction<string>} */
538 _concat(args) {
539 let result = '';
540 for (let i = 0, ii = args.length; i < ii; ++i) {
541 // eslint-disable-next-line @typescript-eslint/restrict-plus-operands
542 result += args[i];
543 }
544 return result;
545 }
546
547 /** @type {import('template-renderer').HelperFunction<string[]>} */
548 _pitchCategories(args) {
549 const [data] = /** @type {[data: import('anki-templates').NoteData]} */ (args);
550 const {dictionaryEntry} = data;
551 if (dictionaryEntry.type !== 'term') { return []; }
552 const {pronunciations: termPronunciations, headwords} = dictionaryEntry;
553 /** @type {Set<string>} */
554 const categories = new Set();
555 for (const {headwordIndex, pronunciations} of termPronunciations) {
556 const {reading, wordClasses} = headwords[headwordIndex];
557 const isVerbOrAdjective = isNonNounVerbOrAdjective(wordClasses);
558 const pitches = getPronunciationsOfType(pronunciations, 'pitch-accent');
559 for (const {positions} of pitches) {
560 const category = getPitchCategory(reading, positions, isVerbOrAdjective);
561 if (category !== null) {
562 categories.add(category);
563 }
564 }
565 }
566 return [...categories];
567 }
568
569 /**
570 * @returns {HTMLElement}
571 */
572 _getTemporaryElement() {
573 let element = this._temporaryElement;
574 if (element === null) {
575 element = this._document.createElement('div');
576 this._temporaryElement = element;
577 }
578 return element;
579 }
580
581 /**
582 * @param {Element} node
583 * @returns {string}
584 */
585 _getStructuredContentHtml(node) {
586 return this._getHtml(node, this._structuredContentStyleApplier, this._structuredContentDatasetKeyIgnorePattern);
587 }
588
589 /**
590 * @param {Element} node
591 * @returns {string}
592 */
593 _getStructuredContentText(node) {
594 return this._getText(node, this._structuredContentStyleApplier, this._structuredContentDatasetKeyIgnorePattern);
595 }
596
597 /**
598 * @param {Element} node
599 * @returns {string}
600 */
601 _getPronunciationHtml(node) {
602 return this._getHtml(node, this._pronunciationStyleApplier, null);
603 }
604
605 /**
606 * @param {Element} node
607 * @param {CssStyleApplier} styleApplier
608 * @param {?RegExp} datasetKeyIgnorePattern
609 * @returns {string}
610 */
611 _getHtml(node, styleApplier, datasetKeyIgnorePattern) {
612 const container = this._getTemporaryElement();
613 container.appendChild(node);
614 this._normalizeHtml(container, styleApplier, datasetKeyIgnorePattern);
615 const result = container.innerHTML;
616 container.textContent = '';
617 return this._safeString(result);
618 }
619
620 /**
621 * @param {Element} node
622 * @param {CssStyleApplier} styleApplier
623 * @param {?RegExp} datasetKeyIgnorePattern
624 * @returns {string}
625 */
626 _getText(node, styleApplier, datasetKeyIgnorePattern) {
627 const container = this._getTemporaryElement();
628 container.appendChild(node);
629 this._normalizeHtml(container, styleApplier, datasetKeyIgnorePattern);
630 const result = container.innerHTML
631 .replaceAll(/<(div|li|ol|ul|br|details|summary|hr)(\s.*?>|>)/g, '\n') // tags that usually cause line breaks
632 .replaceAll(/<(span|a|ruby)(\s.*?>|>)/g, ' ') // tags that usually signify some change in content
633 .replaceAll(/<rt(\s.*?>|>)/g, '[') // ruby start
634 .replaceAll('</rt>', ']') // ruby end
635 .replaceAll(/<.*?>/gs, '') // remove all remaining tags
636 .replaceAll('<', '<') // escape remaining <
637 .replaceAll('>', '&rt;') // and >
638 .replaceAll(/\n+/g, '<br>') // convert newlines into linebreaks and condense newlines
639 .replaceAll(/^(\s*<br>\s*|\s)*/g, '') // remove leading linebreaks and whitespace
640 .replaceAll('<br>', '<br>\n');
641 container.textContent = '';
642 return this._safeString(result);
643 }
644
645 /**
646 * @param {Element} root
647 * @param {CssStyleApplier} styleApplier
648 * @param {?RegExp} datasetKeyIgnorePattern
649 */
650 _normalizeHtml(root, styleApplier, datasetKeyIgnorePattern) {
651 const TEXT_NODE = this._document.TEXT_NODE;
652 const ELEMENT_NODE = this._document.ELEMENT_NODE;
653 const treeWalker = this._document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
654 /** @type {HTMLElement[]} */
655 const elements = [];
656 /** @type {Text[]} */
657 const textNodes = [];
658 while (true) {
659 const node = treeWalker.nextNode();
660 if (node === null) { break; }
661 switch (node.nodeType) {
662 case ELEMENT_NODE:
663 elements.push(/** @type {HTMLElement} */ (node));
664 break;
665 case TEXT_NODE:
666 textNodes.push(/** @type {Text} */ (node));
667 break;
668 }
669 }
670 styleApplier.applyClassStyles(elements);
671 for (const element of elements) {
672 const {dataset} = element;
673 for (const key of Object.keys(dataset)) {
674 if (datasetKeyIgnorePattern !== null && datasetKeyIgnorePattern.test(key)) { continue; }
675 delete dataset[key];
676 }
677 }
678 for (const textNode of textNodes) {
679 this._replaceNewlines(textNode);
680 }
681 }
682
683 /**
684 * @param {Text} textNode
685 */
686 _replaceNewlines(textNode) {
687 const parts = /** @type {string} */ (textNode.nodeValue).split('\n');
688 if (parts.length <= 1) { return; }
689 const {parentNode} = textNode;
690 if (parentNode === null) { return; }
691 const fragment = this._document.createDocumentFragment();
692 for (let i = 0, ii = parts.length; i < ii; ++i) {
693 if (i > 0) { fragment.appendChild(this._document.createElement('br')); }
694 fragment.appendChild(this._document.createTextNode(parts[i]));
695 }
696 parentNode.replaceChild(fragment, textNode);
697 }
698
699 /**
700 * @param {import('anki-templates').NoteData} data
701 * @returns {StructuredContentGenerator}
702 */
703 _createStructuredContentGenerator(data) {
704 const contentManager = new AnkiTemplateRendererContentManager(this._mediaProvider, data);
705 const instance = new StructuredContentGenerator(contentManager, this._document, this._window);
706 this._cleanupCallbacks.push(() => contentManager.unloadAll());
707 return instance;
708 }
709
710 /**
711 * @param {import('template-renderer').HelperOptions} options
712 * @returns {import('anki-templates').NoteData}
713 */
714 _getNoteDataFromOptions(options) {
715 return options.data.root;
716 }
717
718 /**
719 * @type {import('template-renderer').HelperFunction<string>}
720 */
721 _formatGlossary(args, _context, options) {
722 const [dictionary, content] = /** @type {[dictionary: string, content: import('dictionary-data').TermGlossaryContent]} */ (args);
723 const data = this._getNoteDataFromOptions(options);
724 if (typeof content === 'string') { return this._safeString(this._stringToMultiLineHtml(content)); }
725 if (!(typeof content === 'object' && content !== null)) { return ''; }
726 switch (content.type) {
727 case 'image': return this._formatGlossaryImage(content, dictionary, data);
728 case 'structured-content': return this._formatStructuredContent(content, dictionary, data);
729 case 'text': return this._safeString(this._stringToMultiLineHtml(content.text));
730 }
731 return '';
732 }
733
734 /**
735 * @param {import('dictionary-data').TermGlossaryImage} content
736 * @param {string} dictionary
737 * @param {import('anki-templates').NoteData} data
738 * @returns {string}
739 */
740 _formatGlossaryImage(content, dictionary, data) {
741 const structuredContentGenerator = this._createStructuredContentGenerator(data);
742 const node = structuredContentGenerator.createDefinitionImage(content, dictionary);
743 return this._getStructuredContentHtml(node);
744 }
745
746 /**
747 * @param {import('dictionary-data').TermGlossaryStructuredContent} content
748 * @param {string} dictionary
749 * @param {import('anki-templates').NoteData} data
750 * @returns {string}
751 */
752 _formatStructuredContent(content, dictionary, data) {
753 const structuredContentGenerator = this._createStructuredContentGenerator(data);
754 const node = structuredContentGenerator.createStructuredContent(content.content, dictionary);
755 return node !== null ? this._getStructuredContentHtml(node) : '';
756 }
757
758 /**
759 * @type {import('template-renderer').HelperFunction<string>}
760 */
761 _formatGlossaryPlain(args, _context, options) {
762 const [dictionary, content] = /** @type {[dictionary: string, content: import('dictionary-data').TermGlossaryContent]} */ (args);
763 const data = this._getNoteDataFromOptions(options);
764 if (typeof content === 'string') { return this._safeString(content); }
765 if (!(typeof content === 'object' && content !== null)) { return ''; }
766 const structuredContentGenerator = this._createStructuredContentGenerator(data);
767 switch (content.type) {
768 case 'image': return '';
769 case 'structured-content': {
770 const glossaryStrings = this._extractGlossaryData(content, structuredContentGenerator);
771 if (glossaryStrings.length > 0) {
772 return glossaryStrings.join('<br>\n');
773 } else {
774 const node = structuredContentGenerator.createStructuredContent(content.content, dictionary);
775 return node !== null ? this._getStructuredContentText(node) : '';
776 }
777 }
778 case 'text': return this._safeString(content.text);
779 }
780 return '';
781 }
782
783 /**
784 * @param {import('structured-content.js').Content[]} content
785 * @returns {import('structured-content.js').Content[]}
786 */
787 _extractGlossaryStructuredContentRecursive(content) {
788 /** @type {import('structured-content.js').Content[]} */
789 const extractedContent = [];
790 for (let i = 0; i < content.length; i++) {
791 const structuredContent = content[i];
792 if (Array.isArray(structuredContent)) {
793 extractedContent.push(...this._extractGlossaryStructuredContentRecursive(structuredContent));
794 } else if (typeof structuredContent === 'object' && structuredContent) {
795 // @ts-expect-error - Checking if `data` exists
796 if (structuredContent.data?.content === 'glossary') {
797 extractedContent.push(structuredContent);
798 continue;
799 }
800 if (structuredContent.content) {
801 extractedContent.push(...this._extractGlossaryStructuredContentRecursive([structuredContent.content]));
802 }
803 }
804 }
805
806 return extractedContent;
807 }
808
809 /**
810 * @param {import('structured-content.js').Content[]} content
811 * @param {StructuredContentGenerator} structuredContentGenerator
812 * @returns {string[]}
813 */
814 _convertGlossaryStructuredContentRecursive(content, structuredContentGenerator) {
815 /** @type {string[]} */
816 const rawGlossaryContent = [];
817 for (let i = 0; i < content.length; i++) {
818 const structuredGloss = content[i];
819 if (typeof structuredGloss === 'string') {
820 rawGlossaryContent.push(structuredGloss);
821 } else if (Array.isArray(structuredGloss)) {
822 rawGlossaryContent.push(...this._convertGlossaryStructuredContentRecursive(structuredGloss, structuredContentGenerator));
823 } else if (typeof structuredGloss === 'object' && structuredGloss.content) {
824 if (structuredGloss.tag === 'ruby') {
825 const node = structuredContentGenerator.createStructuredContent(structuredGloss.content, '');
826 rawGlossaryContent.push(node !== null ? this._getStructuredContentText(node) : '');
827 continue;
828 }
829 rawGlossaryContent.push(...this._convertGlossaryStructuredContentRecursive([structuredGloss.content], structuredContentGenerator));
830 }
831 }
832 return rawGlossaryContent;
833 }
834
835 /**
836 * @param {import('dictionary-data').TermGlossaryStructuredContent} content
837 * @param {StructuredContentGenerator} structuredContentGenerator
838 * @returns {string[]}
839 */
840 _extractGlossaryData(content, structuredContentGenerator) {
841 /** @type {import('structured-content.js').Content[]} */
842 const glossaryContentQueue = this._extractGlossaryStructuredContentRecursive([content.content]);
843
844 /** @type {string[]} */
845 return this._convertGlossaryStructuredContentRecursive(glossaryContentQueue, structuredContentGenerator);
846 }
847
848 /**
849 * @type {import('template-renderer').HelperFunction<boolean>}
850 */
851 _hasMedia(args, _context, options) {
852 const data = this._getNoteDataFromOptions(options);
853 return this._mediaProvider.hasMedia(data, args, options.hash);
854 }
855
856 /**
857 * @type {import('template-renderer').HelperFunction<?string>}
858 */
859 _getMedia(args, _context, options) {
860 const data = this._getNoteDataFromOptions(options);
861 return this._mediaProvider.getMedia(data, args, options.hash);
862 }
863
864 /**
865 * @type {import('template-renderer').HelperFunction<string>}
866 */
867 _pronunciation(_args, _context, options) {
868 const {format, reading, pitchPositions} = options.hash;
869
870 if (
871 typeof reading !== 'string' ||
872 reading.length === 0 ||
873 (typeof pitchPositions !== 'number' && typeof pitchPositions !== 'string')
874 ) {
875 return '';
876 }
877 const morae = getKanaMorae(reading);
878
879 switch (format) {
880 case 'text':
881 {
882 const nasalPositions = this._getValidNumberArray(options.hash.nasalPositions);
883 const devoicePositions = this._getValidNumberArray(options.hash.devoicePositions);
884 return this._getPronunciationHtml(this._pronunciationGenerator.createPronunciationText(morae, pitchPositions, nasalPositions, devoicePositions));
885 }
886 case 'graph':
887 return this._getPronunciationHtml(this._pronunciationGenerator.createPronunciationGraph(morae, pitchPositions));
888 case 'graph-jj':
889 return this._getPronunciationHtml(this._pronunciationGenerator.createPronunciationGraphJJ(morae, pitchPositions));
890 case 'position':
891 return this._getPronunciationHtml(this._pronunciationGenerator.createPronunciationDownstepPosition(pitchPositions));
892 default:
893 return '';
894 }
895 }
896
897 /**
898 * @param {unknown} value
899 * @returns {number[]}
900 */
901 _getValidNumberArray(value) {
902 const result = [];
903 if (Array.isArray(value)) {
904 for (const item of value) {
905 if (typeof item === 'number') { result.push(item); }
906 }
907 }
908 return result;
909 }
910
911 /**
912 * @type {import('template-renderer').HelperFunction<string>}
913 */
914 _hiragana(args, context, options) {
915 const ii = args.length;
916 const {keepProlongedSoundMarks} = options.hash;
917 const value = (ii > 0 ? args[0] : this._computeValue(options, context));
918 return typeof value === 'string' ? convertKatakanaToHiragana(value, keepProlongedSoundMarks === true) : '';
919 }
920
921 /**
922 * @type {import('template-renderer').HelperFunction<string>}
923 */
924 _katakana(args, context, options) {
925 const ii = args.length;
926 const value = (ii > 0 ? args[0] : this._computeValue(options, context));
927 return typeof value === 'string' ? convertHiraganaToKatakana(value) : '';
928 }
929
930 /**
931 * @param {unknown} value
932 * @returns {string}
933 */
934 _asString(value) {
935 return typeof value === 'string' ? value : `${value}`;
936 }
937
938 /**
939 * @param {import('template-renderer').HelperOptions} options
940 * @param {unknown} context
941 * @returns {unknown}
942 */
943 _computeValue(options, context) {
944 return typeof options.fn === 'function' ? options.fn(context) : '';
945 }
946
947 /**
948 * @param {import('template-renderer').HelperOptions} options
949 * @param {unknown} context
950 * @returns {string}
951 */
952 _computeValueString(options, context) {
953 return this._asString(this._computeValue(options, context));
954 }
955
956 /**
957 * @param {import('template-renderer').HelperOptions} options
958 * @param {unknown} context
959 * @returns {unknown}
960 */
961 _computeInverse(options, context) {
962 return typeof options.inverse === 'function' ? options.inverse(context) : '';
963 }
964
965 /**
966 * @param {import('template-renderer').HelperOptions} options
967 * @param {unknown} context
968 * @returns {string}
969 */
970 _computeInverseString(options, context) {
971 return this._asString(this._computeInverse(options, context));
972 }
973}