Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 973 lines 38 kB view raw
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('<', '&lt;') // 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}