// ==UserScript== // @name 注音 anywhere (Traditional Mandarin → zhuyin + select-to-translate) // @namespace local.zhuyin-anywhere // @version 0.9.4 // @description Annotates Traditional Chinese on any page with textbook-style zhuyin (word-segmented, Taiwan readings from McBopomofo via yt-zhuyin's dictionary). Hover a word for its meaning (CC-CEDICT, or Google in other languages); select text to translate it into English, Lithuanian or another language. Speaker buttons read words and selections aloud in Taiwan Mandarin. ⚙ on the popups opens the settings. // @match *://*/* // @match file:///* // @noframes // @homepageURL https://github.com/ashirviskas/zhuyin-anywhere // @downloadURL https://raw.githubusercontent.com/ashirviskas/zhuyin-anywhere/main/zhuyin-anywhere.user.js // @updateURL https://raw.githubusercontent.com/ashirviskas/zhuyin-anywhere/main/zhuyin-anywhere.user.js // @resource zydict https://cdn.jsdelivr.net/gh/ashirviskas/yt-zhuyin@main/zhuyin-dict.js // @resource cedict https://www.mdbg.net/chinese/export/cedict/cedict_1_0_ts_utf-8_mdbg.txt.gz // @resource comps https://cdn.jsdelivr.net/gh/ashirviskas/zhuyin-anywhere@main/data/components.tsv.gz // @grant GM_getResourceText // @grant GM_getResourceURL // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @connect translate.googleapis.com // @connect translate.google.com // @connect 127.0.0.1 // @connect localhost // @connect www.mdbg.net // @connect cdn.jsdelivr.net // @connect raw.githubusercontent.com // @run-at document-idle // ==/UserScript== /* * Pipeline * 1. Decide whether this page is worth it: per-site mode (auto / on / off). "auto" samples the page * text and only runs if it has enough Han characters and they look Traditional, not Simplified. * 2. Lazily load the dictionary (@resource, cached by Violentmonkey, parsed only when needed — the * 6.5 MB @require that yt-zhuyin uses would be parsed on every page load of every site). * 3. TreeWalker over text nodes, time-sliced in idle callbacks. Each Han run is segmented with the * same unigram max-likelihood segmenter as yt-zhuyin, so polyphones get word-level readings * (銀行 ㄏㄤˊ, not ㄒㄧㄥˊ). MutationObserver feeds new content into the same queue. With "Zhuyin is * added: near the screen" (lazy), two IntersectionObservers annotate text only within a few screens of * the viewport and put text far away back, so a book-length page stays a few thousand elements. * 4. Rendering: yt-zhuyin's textbook layout — vertical column right of the character, ˙ drawn as a * dot above, ˊˇˋ in their own slot. Optional 'top' layout. Zhuyin is user-select:none and is * also stripped from the selection text, so copy/translate see only the Chinese. "Zhuyin on page" can * hide it everywhere ('off') or show it only over the hovered word ('hover', an overlay that moves nothing); * the text stays annotated either way, so the card still works. In the popups the zhuyin goes after the * word, or beside each character ("Zhuyin in popups"), drawn with the same zy-col/zy-zs/zy-tn parts. * 5. Hover gloss: CC-CEDICT (CC BY-SA 4.0, MDBG) as a gzipped @resource — fetched once by Violentmonkey, * stored base64 in extension storage, read lazily via GM_getResourceURL and gunzipped with * DecompressionStream. Not GM_setValue: Violentmonkey ships every stored value into every page on * injection, so 10 MB there would cost every site load. Entries are sorted so the one matching the * zhuyin on the page comes first (incl. CEDICT's "Taiwan pr. [...]" notes). A segmenter word that * CEDICT doesn't have (IME phrases like 最好的) is split into CEDICT words for the card and word list. * 6. Selection → translation popup (shadow DOM, isolated from page CSS) in the target language. place() opens every * popup on the side its anchor's screen position decides, with the edge facing the anchor pinned, so popups * don't jump as they fill or as a card opens from a card; "Popup size" scales every px of their CSS. Google * gtx endpoint by default, or yt-zhuyin's local asr_server /translate (opus-mt, English only). With * a target other than English the hover card and the word list are Google too (one batched request), * as CC-CEDICT is English-only. "Hide meaning" blurs every translation until the reveal key is held. * 7. Read aloud: speaker button on both popups. Google's zh-TW voice (Taiwan Mandarin) as MP3, played * through Web Audio, which page CSP media-src can't block. Offline or refused → the browser's own * voice (speechSynthesis). Every row of the selection word list has its own speaker. * 8. Google translations and voice clips are cached in the page origin's IndexedDB (cacheMB per site, LRU). * 9. Touch: a tap opens a word's card; a long-press selection translates once it settles. * 10. Settings: one table (SETTINGS) drives both the Violentmonkey menu and the ⚙ panel in the popups. The panel * has tabs (Page, Popups, Translate, Learn); Learn-only rows (`ui`) aren't in the menu. * 11. Character parts: data/components.tsv.gz (Make Me a Hanzi + CJKVI-IDS + Wiktionary, built by tools/components), * another lazy @resource. A one-character card shows the parts and origin; a click on a part, or on a * character row of a word's card, redraws the card for it (renderCard), with a crumb back. * 12. Tints: the N most common characters (counted from the zhuyin dictionary) and My characters get a colour * hashed from the character, set per zy-c as --zyt; an html class picks the style. Drawn behind the glyph, so * nothing moves. Hovering a look in the settings previews it without storing it. * 13. Notes: "what's new" once after an update; "update available" when main's @version is newer (checked daily). * * Hotkeys: Alt+Z cycles zhuyin on page (on → hover → off) · Alt (held) + hover shows gloss in 'alt' mode · Alt+T translate selection (when autoTranslate is off) · * R (configurable, Alt optional) read the open card or popup aloud · Shift (configurable, held) shows hidden meanings · Esc closes popups and stops reading aloud */ (() => { 'use strict'; // ---------------------------------------------------------------- config // A newcomer's language: the browser's, with English second; English alone for English and Chinese browsers. const BROWSER_LANG = (() => { const b = (navigator.language || 'en').toLowerCase().split('-')[0]; return b === 'zh' ? 'en' : ({ nb: 'no', nn: 'no', fil: 'tl' })[b] ?? b; })(); const CFG = { zhuyin: GM_getValue('zhuyin', 'on'), // on the page: 'on' | 'hover' (over the hovered word only) | 'off'; the card always shows it popupZhuyin: GM_getValue('popupZhuyin', 'beside'),// card, selected text, word list: 'beside' (by each character) | 'after' (after the word, one line) hoverZhuyin: GM_getValue('hoverZhuyin', 'label'),// 'hover' mode: 'label' (one label above the word, moves nothing) | 'inline' (drawn in the line) layout: GM_getValue('layout', 'side'), // 'side' (textbook column) | 'top' (horizontal, above) scale: 0.5, // zhuyin size relative to the annotated text wordGap: 0.12, // em of space between dictionary words (0 = none) showPolyphones: GM_getValue('poly', false), // tint single chars whose reading is a guess among several translator: GM_getValue('translator', 'google'), // 'google' | 'local' localServer: 'http://127.0.0.1:8765', // yt-zhuyin asr_server.py targetLang: GM_getValue('targetLang', BROWSER_LANG),// Google language code; CC-CEDICT senses only for 'en' targetLang2: GM_getValue('targetLang2', BROWSER_LANG === 'en' ? '' : 'en'),// optional second language, shown under the first; '' = none autoTranslate: GM_getValue('autoTranslate', true),// popup on every CJK selection; false = Alt+T only pinyin: GM_getValue('pinyin', false), // selection popup: pinyin line, and pinyin instead of zhuyin in the word list compactChrome: GM_getValue('compactChrome', true),// buttons + fixed/sticky boxes (popups, headers): zhuyin hidden, gloss on hover overflowGuard: true, // a box that starts overflowing after annotation goes compact lazy: GM_getValue('lazy', true), // annotate only text near the screen, put far-off text back (long pages stay fast) nearScreens: 2, // lazy: annotated once this many viewport heights away, or closer farScreens: 6, // lazy: back to plain text once this far theme: GM_getValue('theme', 'auto'), // popups: 'auto' (follows the system) | 'dark' | 'light' cacheMB: GM_getValue('cacheMB', 100), // Google translations + voice kept in IndexedDB, per site; 0 = off gloss: GM_getValue('gloss', 'hover'), // 'hover' | 'alt' (hold Alt while hovering) | 'off' (plain title tooltip) popupScale: GM_getValue('popupScale', 100), // % size of the popups: text, spacing and boxes hideMeaning: GM_getValue('hideMeaning', false), // popups open, but translations stay blurred until revealKey is held revealKey: GM_getValue('revealKey', 'Shift'), // KeyboardEvent.key: 'Shift' | 'Alt' | 'Control' speakKey: GM_getValue('speakKey', 'R'), // 'R' or 'Alt+R': reads the open card or popup aloud (only while one is open) parts: GM_getValue('parts', true), // word card: what a character is made of, its origin; click a part to open it partsLang: GM_getValue('partsLang', true), // part meanings, hint and origin also in the target language(s) when not English tintTop: GM_getValue('tintTop', 0), // tint the N most common characters; 0 = off, up to 1000 tintMine: GM_getValue('tintMine', true), // tint (and underline) My characters myChars: GM_getValue('myChars', ''), // My characters, one string; added from the card's highlighter or the settings tintStyle: GM_getValue('tintStyle', 'box'), // 'box' | 'marker' (highlighter) | 'ink' (coloured text) | 'under' (underline) tintCovers: GM_getValue('tintCovers', 'char'), // 'char' (the character only) | 'both' (character + its zhuyin) tintPalette: GM_getValue('tintPalette', 'pastel'),// key of PALETTES tintStrength: GM_getValue('tintStrength', 35), // % opacity of box, highlighter and underline glossDelay: 220, // ms of hover before the card shows glossHideDelay: 400, // ms off the word and card before the card hides glossKeep: 1500, // ms, at least, after a card is opened from a card glossSlack: 40, // px around the word and card that still count as on them glossMaxEntries: 4, // CEDICT entries per word glossMaxDefs: 6, // definitions per entry selWordList: 24, // selection popup: list word glosses if ≤ this many Han chars minHanAuto: 20, // auto mode: need at least this many Han chars on the page sliceMs: 8, // per idle slice of DOM work debug: false, }; const log = (...a) => CFG.debug && console.log('[zhuyin]', ...a); // Local files have no hostname: they share one "local files" setting instead of colliding on ''. const host = location.protocol === 'file:' ? 'local files' : location.hostname; const siteKey = 'site:' + host; const siteMode = () => GM_getValue(siteKey, 'auto'); // 'auto' | 'on' | 'off' // ---------------------------------------------------------------- text classes const HAN = /[㐀-䶿一-鿿豈-﫿〇]|[\ud840-\ud87f][\udc00-\udfff]/; const HAN_RUN = /(?:[㐀-䶿一-鿿豈-﫿〇]|[\ud840-\ud87f][\udc00-\udfff])+/g; const ZY_JUNK = /[㄀-ㄯㆠ-ㆿˊˇˋ˙]/g; // stripped from selections, belt and braces // Characters that exist in only one script. Enough to tell a Simplified page from a Traditional one. const SIMP_ONLY = new Set('这们来时个说为国会对发经过还没开问见长东车门马么书给让认样学现边头点进动实种关应机'); const TRAD_ONLY = new Set('這們來時個說為國會對發經過還沒開問見長東車門馬麼書給讓認樣學現邊頭點進動實種關應機'); const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION', 'CODE', 'KBD', 'SAMP', 'VAR', 'TITLE', 'SVG', 'MATH', 'RUBY', 'RT', 'RP', 'IFRAME', 'CANVAS', 'TEMPLATE']); const SKIP_IDS = new Set(['zyh-host', 'ytz-panel']); // ours + yt-zhuyin's transcript panel (already has zhuyin) function shouldSkipElement(el) { for (let e = el; e && e !== document.body; e = e.parentElement) { if (SKIP_TAGS.has(e.tagName)) return true; if (e.isContentEditable) return true; if (e.tagName === 'ZY-RUN' || SKIP_IDS.has(e.id)) return true; if (e.getAttribute && e.getAttribute('translate') === 'no') return true; } return false; } // ---------------------------------------------------------------- dictionary let DICT = null; function loadDict() { if (DICT) return DICT; const t0 = performance.now(); const raw = GM_getResourceText('zydict'); if (!raw) throw new Error('zhuyin dictionary resource missing'); DICT = JSON.parse(raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1)); DICT.LT = Math.log(DICT.total || 2e7); log('dict parsed in', Math.round(performance.now() - t0), 'ms'); return DICT; } // ---- segmentation (from yt-zhuyin: unigram max-likelihood over the zhuyin-native dictionary) ---- // Works on code points, so Extension-B characters don't get split into surrogate halves. function segment(chars, D, maxLen = 6) { const n = chars.length, best = new Array(n + 1).fill(-Infinity), back = new Array(n + 1).fill(null); const UNK = -14; best[0] = 0; for (let i = 0; i < n; i++) { if (best[i] === -Infinity) continue; const push = (cand) => { const s = best[i] + cand.score, j = i + cand.len; if (s > best[j]) { best[j] = s; back[j] = { i, cand }; } }; const c = D.c[chars[i]]; push(c ? { len: 1, zy: [c[0][0]], score: Math.log((c[1] || 0) + 1) - D.LT - 1, alts: c[0] } : { len: 1, zy: [null], score: UNK, alts: null }); let key = chars[i]; for (let L = 2; L <= maxLen && i + L <= n; L++) { key += chars[i + L - 1]; const w = D.w[key]; if (w) push({ len: L, zy: w[0].split(' '), score: Math.log((w[1] || 0) + 1) - D.LT, alts: null }); } } const out = []; for (let j = n; j > 0; j = back[j].i) { const { cand, i } = back[j]; out.unshift({ chars: chars.slice(i, j), zy: cand.zy, alts: cand.alts }); } return out; } // ---- zhuyin helpers (from yt-zhuyin) ---- const Z_INIT = { 'ㄅ':'b','ㄆ':'p','ㄇ':'m','ㄈ':'f','ㄉ':'d','ㄊ':'t','ㄋ':'n','ㄌ':'l','ㄍ':'g','ㄎ':'k','ㄏ':'h','ㄐ':'j','ㄑ':'q','ㄒ':'x','ㄓ':'zh','ㄔ':'ch','ㄕ':'sh','ㄖ':'r','ㄗ':'z','ㄘ':'c','ㄙ':'s' }; const Z_FIN = { 'ㄚ':'a','ㄛ':'o','ㄜ':'e','ㄝ':'ê','ㄞ':'ai','ㄟ':'ei','ㄠ':'ao','ㄡ':'ou','ㄢ':'an','ㄣ':'en','ㄤ':'ang','ㄥ':'eng','ㄦ':'er', 'ㄧ':'i','ㄧㄚ':'ia','ㄧㄛ':'io','ㄧㄝ':'ie','ㄧㄞ':'iai','ㄧㄠ':'iao','ㄧㄡ':'iu','ㄧㄢ':'ian','ㄧㄣ':'in','ㄧㄤ':'iang','ㄧㄥ':'ing', 'ㄨ':'u','ㄨㄚ':'ua','ㄨㄛ':'uo','ㄨㄞ':'uai','ㄨㄟ':'ui','ㄨㄢ':'uan','ㄨㄣ':'un','ㄨㄤ':'uang','ㄨㄥ':'ong', 'ㄩ':'ü','ㄩㄝ':'üe','ㄩㄢ':'üan','ㄩㄣ':'ün','ㄩㄥ':'iong' }; const Z_STANDALONE = { 'ㄧ':'yi','ㄧㄚ':'ya','ㄧㄛ':'yo','ㄧㄝ':'ye','ㄧㄞ':'yai','ㄧㄠ':'yao','ㄧㄡ':'you','ㄧㄢ':'yan','ㄧㄣ':'yin','ㄧㄤ':'yang','ㄧㄥ':'ying', 'ㄨ':'wu','ㄨㄚ':'wa','ㄨㄛ':'wo','ㄨㄞ':'wai','ㄨㄟ':'wei','ㄨㄢ':'wan','ㄨㄣ':'wen','ㄨㄤ':'wang','ㄨㄥ':'weng', 'ㄩ':'yu','ㄩㄝ':'yue','ㄩㄢ':'yuan','ㄩㄣ':'yun','ㄩㄥ':'yong' }; const normZhuyin = (s) => s.endsWith('˙') ? '˙' + s.slice(0, -1) : s; // textbook: neutral dot in front // One zhuyin syllable → { py: toneless pinyin, tone: 1-5 }. function zySyl(s) { let tone = 1; if (s.startsWith('˙')) { tone = 5; s = s.slice(1); } else if (s.endsWith('˙')) { tone = 5; s = s.slice(0, -1); } else { const k = 'ˊˇˋ'.indexOf(s.at(-1)); if (k >= 0) { tone = k + 2; s = s.slice(0, -1); } } const init = Z_INIT[s[0]] ? s[0] : '', fin = s.slice(init.length); let py; if (!init) py = Z_STANDALONE[fin] ?? Z_FIN[fin]; else { py = Z_INIT[init] + (fin ? (Z_FIN[fin] ?? '?') : 'i'); if ('jqx'.includes(Z_INIT[init])) py = py.replace('ü', 'u'); } return { py: py || '?', tone }; } const MARKS = ['', '\u0304', '\u0301', '\u030c', '\u0300', '']; function markPinyin(py, tone) { const m = MARKS[tone]; if (!m) return py; const lower = py.toLowerCase(), vowels = 'aoeêiuü'; let idx = -1; for (const v of ['a', 'e', 'ê']) { idx = lower.indexOf(v); if (idx >= 0) break; } if (idx < 0 && lower.includes('ou')) idx = lower.indexOf('o'); if (idx < 0) for (let k = lower.length - 1; k >= 0; k--) if (vowels.includes(lower[k])) { idx = k; break; } if (idx < 0) return py; return (py.slice(0, idx + 1) + m + py.slice(idx + 1)).normalize('NFC'); } function zhuyinToPinyin(s) { const { py, tone } = zySyl(s); return markPinyin(py, tone); } // Comparable reading key: "yin2 hang2" style, ü as v. const zyKey = (zys) => zys.map(z => { const { py, tone } = zySyl(z); return py.replace(/ü/g, 'v') + tone; }).join(' '); // CEDICT numbered pinyin "zhang3 de5" / "lu:e4" / "Tai2 wan1" → marked "zhǎng de" / "lüè" / "Táiwān" function numToMarked(num) { return num.split(/\s+/).map(sy => { const m = /^([A-Za-zü:]+?)([1-5])?$/.exec(sy); if (!m) return sy; return markPinyin(m[1].replace(/u:/g, 'ü').replace(/U:/g, 'Ü'), m[2] ? +m[2] : 5); }).join(' '); } const numKey = (num) => num.toLowerCase().replace(/u:/g, 'v').replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim(); const toneless = (k) => k.replace(/[0-9]/g, ''); // ---------------------------------------------------------------- styles // Markup uses custom tags (, , …), not : news sites style bare `span` // ("span+span::before{content:'、'}", "p span{font-size:20px!important}"), which wrecked the layout. // Nothing on a page targets zy-* tags; for the `*` / `.text *` rules that remain, every selector is // boosted with :is(#zyh#zyh#zyh, tag) — an impossible ID compound that never matches but lifts the // specificity to (3,0,1) — and `all:unset` wipes whatever non-inherited props got through. // Inherited text props (letter-spacing, text-indent, line-height…) are pinned explicitly on zhuyin. const B = (sel) => sel.replace(/\bzy-[a-z]+\b(?![^(]*\))/g, (t) => `${t}:is(#zyh#zyh#zyh,${t})`); function injectCss() { if (document.getElementById('zyh-css')) return; const s = document.createElement('style'); s.id = 'zyh-css'; const rules = [ ['zy-run, zy-w, zy-c, zy-h, zy-z, zy-col, zy-zs, zy-dot, zy-tn', 'all:unset'], ['zy-h', 'display:inline;position:relative;isolation:isolate'], // the tint's ::before sits behind the glyph ['zy-run', `display:inline;word-break:normal;line-break:auto;--zyh-s:${CFG.scale}em`], // a keep-all/strict parent would forbid breaks between words ['zy-w', `display:inline;white-space:nowrap;margin-right:${CFG.wordGap}em`], ['zy-c', 'display:inline-flex;align-items:center;vertical-align:baseline;text-indent:0'], // each char is its own flex box: an inherited paragraph indent would hit every character ['zy-z', `display:inline-flex;align-items:flex-end;font-size:var(--zyh-s);line-height:1;margin:0 .1em 0 .15em;padding:0; color:currentColor;opacity:.72;white-space:nowrap;font-style:normal;font-weight:400;font-variant:normal; letter-spacing:0;word-spacing:0;text-indent:0;text-transform:none;text-align:left;text-decoration:none;text-shadow:none; writing-mode:horizontal-tb;vertical-align:baseline;float:none;position:static; font-family:"Noto Sans TC","Noto Sans CJK TC","PingFang TC","Microsoft JhengHei","Source Han Sans TW",sans-serif; -webkit-user-select:none;user-select:none;pointer-events:none`], ['zy-col', 'display:inline-flex;flex-direction:column;align-items:center'], ['zy-zs', 'display:inline;writing-mode:vertical-lr;text-orientation:upright'], ['zy-dot', 'display:block;width:.32em;height:.32em;border-radius:50%;background:currentColor;margin-bottom:.2em'], ['zy-tn', 'display:inline;font-size:1.5em;line-height:.6;width:.5em;margin-left:-.02em'], ['html.zyh-poly zy-c[data-p] zy-z', 'color:#d4a020;opacity:.9'], ['html.zyh-top zy-c', 'flex-direction:column-reverse;vertical-align:bottom'], ['html.zyh-top zy-z', 'align-items:center;margin:0 0 .05em 0;font-size:calc(var(--zyh-s) * .9)'], ['html.zyh-top zy-zs', 'writing-mode:horizontal-tb;text-orientation:mixed'], ['html.zyh-top zy-col', 'flex-direction:row'], ['html.zyh-top zy-dot', 'margin:0 .15em 0 0'], ['html.zyh-top zy-tn', 'font-size:1.3em;line-height:.7;width:auto;margin:0 0 0 .05em'], ['html.zyh-off zy-z, html.zyh-hover zy-z', 'display:none'], ['html.zyh-off zy-w, html.zyh-hover zy-w', 'margin-right:0'], ['zy-run[data-c] zy-z', 'display:none'], ['zy-run[data-c] zy-w', 'margin-right:0'], // Hover: one label with the word's zhuyin floats above it, out of the flow so no text moves (shown in the // line, the word would widen, slip from under the pointer and flicker). One label, not one per character: // a syllable is wider than its character, so per-character labels overlap. Not for compact text. ['html.zyh-hover zy-w', 'position:relative'], ['html.zyh-hover:not(.zyh-inline) zy-run:not([data-c]) zy-w:hover::before', `content:attr(data-zl);position:absolute;bottom:100%;left:50%; transform:translateX(-50%);z-index:1;margin:0 0 .15em 0;padding:.15em .4em;border-radius:.3em; font-size:max(.6em,11px);line-height:1.2;font-weight:400;font-style:normal;letter-spacing:.05em;white-space:nowrap; background:Canvas;color:CanvasText;box-shadow:0 1px 3px rgba(0,0,0,.3);pointer-events:none;-webkit-user-select:none;user-select:none; font-family:"Noto Sans TC","Noto Sans CJK TC","PingFang TC","Microsoft JhengHei","Source Han Sans TW",sans-serif`], // Hover in the line: drawn as in 'on'. The word widens, so at a line end it can wrap away from the pointer. ['html.zyh-hover.zyh-inline zy-run:not([data-c]) zy-w:hover zy-z', 'display:inline-flex'], ['html.zyh-tc zy-c[data-t] zy-h::before', `content:"";position:absolute;left:-.06em;right:-.06em;top:50%;height:1.2em; transform:translateY(-50%);z-index:-1;border-radius:.18em;pointer-events:none`], ...Object.entries(TINT_CSS).flatMap(([st, [glyph, cell]]) => [[`html.zyh-t-${st}.zyh-tc zy-c[data-t] zy-h${st === 'ink' ? '' : '::before'}`, glyph], [`html.zyh-t-${st}:not(.zyh-tc) zy-c[data-t]`, cell]]), ['zy-c[data-t="m"] zy-h', 'text-decoration:underline .1em var(--zyu);text-underline-offset:.18em'], ['zy-c[data-on]', 'background:rgba(66,133,244,.16);box-shadow:0 .1em 0 rgba(66,133,244,.16),0 -.1em 0 rgba(66,133,244,.16)'], // word of the open card; per character, as the card may show part of a zy-w ]; const imp = (decls) => decls.split(';').map(d => d.trim()).filter(Boolean) .map(d => (d.startsWith('--') ? d : d + '!important')).join(';'); s.textContent = rules.map(([sel, d]) => `${sel.split(',').map(x => B(x.trim())).join(',')}{${imp(d)}}`).join('\n'); (document.head || document.documentElement).appendChild(s); const root = document.documentElement.classList; tintClass(); root.toggle('zyh-top', CFG.layout === 'top'); root.toggle('zyh-poly', CFG.showPolyphones); zhuyinClass(CFG.zhuyin); } const zhuyinClass = (v) => { const root = document.documentElement.classList; root.toggle('zyh-off', v === 'off'); root.toggle('zyh-hover', v === 'hover'); root.toggle('zyh-inline', CFG.hoverZhuyin === 'inline'); }; // ---------------------------------------------------------------- tints // The N most common characters and My characters get a light colour of their own. A character's colour is // a function of the character and the palette only (FNV-1a hash → hue), so it's the same on every page and // for everyone. Each tinted zy-c carries data-t ('t', or 'm' for My characters, which are also underlined) // and its colour in --zyt; the html class picks how it's drawn: box, highlighter, coloured text, underline. // [the glyph alone ("covers: character": drawn on zy-h::before, a box 1.2em tall behind it, so the layout doesn't // change and boxes on neighbouring lines don't touch), the whole cell with its zhuyin (trimmed top and bottom)]. const TINT_CSS = { box: ['background:var(--zyt)', 'background:linear-gradient(transparent 10%,var(--zyt) 10%,var(--zyt) 90%,transparent 90%);border-radius:.18em'], marker: ['background:linear-gradient(transparent 50%,var(--zyt) 50%,var(--zyt) 92%,transparent 92%);border-radius:0', 'background:linear-gradient(transparent 55%,var(--zyt) 55%,var(--zyt) 85%,transparent 85%)'], ink: ['color:var(--zyt)', 'color:var(--zyt)'], under: ['background:linear-gradient(transparent 86%,var(--zyt) 86%);border-radius:0', 'box-shadow:inset 0 -.14em 0 var(--zyt)'], }; // [name, (hash) → [OKLCH lightness, chroma, hue] on a light page]. One hash bit varies the lightness, so // neighbouring hues still differ. Colour-blind safe: the 8 Okabe–Ito colours, so they repeat. const OKABE_ITO = [[.74, .15, 70], [.76, .1, 230], [.62, .12, 165], [.92, .15, 105], [.5, .13, 250], [.62, .18, 40], [.7, .12, 340], [.7, 0, 0]]; const PALETTES = { pastel: ['Pastel', (h) => [.87 + ((h >>> 9) & 1) * .04, .085, h % 360]], vivid: ['Vivid', (h) => [.76 + ((h >>> 9) & 1) * .05, .16, h % 360]], earth: ['Earth', (h) => [.8 + ((h >>> 9) & 1) * .05, .065, 30 + h % 110]], ocean: ['Ocean', (h) => [.82 + ((h >>> 9) & 3) * .025, .09, 175 + h % 95]], forest: ['Forest', (h) => [.82 + ((h >>> 9) & 3) * .025, .09, 95 + h % 75]], sunset: ['Sunset', (h) => [.84 + ((h >>> 9) & 1) * .04, .11, (330 + h % 100) % 360]], berry: ['Berry', (h) => [.83 + ((h >>> 9) & 1) * .05, .11, 290 + h % 80]], mono: ['Mono', (h) => [.72 + (h % 8) * .03, .09, 255]], cb: ['Colour-blind safe', (h) => OKABE_ITO[h % 8]], }; const fnv = (s) => { let h = 0x811c9dc5; for (const b of new TextEncoder().encode(s)) { h ^= b; h = Math.imul(h, 0x01000193) >>> 0; } return h; }; // A dark page (light text) gets darker, stronger tints; coloured text gets a lightness that stays readable. // Cached per character until a tint setting changes (refreshTints clears it): a page has thousands of cells. const colours = new Map(); function tintColour(ch, dark, under = false) { const key = ch + (dark ? 'd' : '') + (under ? 'u' : ''); let col = colours.get(key); if (!col) colours.set(key, (col = tintRgb(ch, dark, under))); return col; } function tintRgb(ch, dark, under) { let [L, C, H] = (PALETTES[CFG.tintPalette] ?? PALETTES.pastel)[1](fnv(ch)), a = CFG.tintStrength / 100; if (under || CFG.tintStyle === 'ink') { L = dark ? .78 : .52; C = Math.max(C, .12); a = 1; } else if (dark) { L = Math.max(.32, L - .42); C *= .9; a = Math.min(1, a * 1.6); } return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H} / ${a.toFixed(2)})`; } // The N most common characters, by their counts summed over the zhuyin dictionary's words. let TOP = null; function topChars() { if (TOP) return TOP; const D = loadDict(), n = new Map(), add = (c, k) => n.set(c, (n.get(c) || 0) + k); for (const w in D.w) for (const c of w) add(c, D.w[w][1] || 0); for (const c in D.c) add(c, D.c[c][1] || 0); return (TOP = [...n].filter(([c]) => HAN.test(c)).sort((a, b) => b[1] - a[1]).slice(0, 1000).map(x => x[0])); } let tints = null, pageDark = false; // character → 't' | 'm' function tintSet() { if (tints) return tints; tints = new Map(); if (CFG.tintTop > 0) for (const c of topChars().slice(0, CFG.tintTop)) tints.set(c, 't'); if (CFG.tintMine) for (const c of CFG.myChars) tints.set(c, 'm'); return tints; } // Light text on the body = a dark page. Its background is less reliable: often transparent, set on a wrapper. function isDarkPage() { const m = /(\d+)[, ]+(\d+)[, ]+(\d+)/.exec(getComputedStyle(document.body || document.documentElement).color); return !!m && (0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3]) / 255 > 0.6; } // Mark one element as tinted ('t', or 'm' for My characters), or unmark it (t falsy). dark: colours for a dark background. // Writes only what changed: each write restyles the cell, and a refresh visits every cell on the page. function paint(e, ch, t, dark) { if (!t) { if (e.hasAttribute('data-t')) { e.removeAttribute('data-t'); e.style.removeProperty('--zyt'); e.style.removeProperty('--zyu'); } return; } const set = (k, v) => { if (e.style.getPropertyValue(k) !== v) e.style.setProperty(k, v); }; if (e.getAttribute('data-t') !== t) e.setAttribute('data-t', t); set('--zyt', tintColour(ch, dark)); if (t === 'm') set('--zyu', tintColour(ch, dark, true)); else if (e.style.getPropertyValue('--zyu')) e.style.removeProperty('--zyu'); } // A page cell, or a character in the popups (dark: the popup's theme). const tintCell = (e, ch, dark = pageDark) => paint(e, ch, tintSet().get(ch), dark); const popDark = () => CFG.theme === 'dark' || (CFG.theme === 'auto' && !matchMedia('(prefers-color-scheme: light)').matches); const tintClass = () => { const root = document.documentElement.classList; for (const s of ['box', 'marker', 'ink', 'under']) root.toggle('zyh-t-' + s, CFG.tintStyle === s); root.toggle('zyh-tc', CFG.tintCovers === 'char'); }; // After a tint setting changes (or while one is previewed): every cell on the page, and the settings sample. let refreshQ = 0; function refreshTints() { cancelAnimationFrame(refreshQ); refreshQ = 0; tints = null; colours.clear(); pageDark = isDarkPage(); tintClass(); if (active) for (const c of document.querySelectorAll('zy-c')) tintCell(c, c.firstChild?.textContent ?? ''); if (pop) { pop.hostEl.dataset.ts = CFG.tintStyle; const pv = pop.set.querySelector('.pv'); if (pv) paintSample(pv); } } // Once per frame at most: sweeping the pointer across the settings leaves one button and enters the next in one frame. const refreshSoon = () => { refreshQ ||= requestAnimationFrame(refreshTints); }; // ---------------------------------------------------------------- rendering function zhuyinParts(z) { const dot = z.startsWith('˙'); if (dot) z = z.slice(1); const tone = 'ˊˇˋ'.includes(z.at(-1)) ? z.at(-1) : ''; const col = document.createElement('zy-col'); if (dot) { const d = document.createElement('zy-dot'); col.appendChild(d); } const syms = document.createElement('zy-zs'); syms.textContent = tone ? z.slice(0, -1) : z; col.appendChild(syms); if (!tone) return [col]; const t = document.createElement('zy-tn'); t.textContent = tone; return [col, t]; } // One Han run → fragment of word spans. Unknown characters are left as plain text. function renderRun(text, D) { const frag = document.createDocumentFragment(); for (const w of segment([...text], D)) { if (w.zy[0] === null) { frag.appendChild(document.createTextNode(w.chars.join(''))); continue; } const word = document.createElement('zy-w'); word.dataset.zy = w.zy.map(z => z || '?').join(' '); word.dataset.zl = w.zy.map(z => (z ? normZhuyin(z) : '?')).join(' '); // the hover label if (w.alts && w.alts.length > 1) word.dataset.alts = w.alts.slice(1).join(' '); w.chars.forEach((ch, i) => { const z = w.zy[i]; const cc = document.createElement('zy-c'), h = document.createElement('zy-h'); // zy-h: the glyph alone, for "tint covers: character" h.textContent = ch; cc.appendChild(h); tintCell(cc, ch); if (z) { const zy = document.createElement('zy-z'); zy.setAttribute('aria-hidden', 'true'); zy.append(...zhuyinParts(normZhuyin(z))); cc.appendChild(zy); if (w.alts && w.alts.length > 1) { cc.setAttribute('data-p', ''); if (CFG.gloss === 'off') cc.title = 'also: ' + w.alts.slice(1).map(normZhuyin).join(' / '); } } word.appendChild(cc); }); frag.appendChild(word); // Explicit break opportunity. Firefox won't wrap between two adjacent inline-flex boxes on some // sites (LTN: lines only broke at punctuation and overflowed the column); works everywhere // and adds nothing to copied text. frag.appendChild(document.createElement('wbr')); } return frag; } // Replace one text node in place. The wrapper keeps the original string so 'off' can restore it. function annotateTextNode(node, D, compact) { const text = node.nodeValue; HAN_RUN.lastIndex = 0; if (!HAN_RUN.test(text)) return null; HAN_RUN.lastIndex = 0; const run = document.createElement('zy-run'); run.dataset.src = text; if (compact) run.setAttribute('data-c', ''); let last = 0, m; while ((m = HAN_RUN.exec(text))) { if (m.index > last) run.appendChild(document.createTextNode(text.slice(last, m.index))); run.appendChild(renderRun(m[0], D)); last = m.index + m[0].length; } if (last < text.length) run.appendChild(document.createTextNode(text.slice(last))); node.parentNode.replaceChild(run, node); return run; } // ---- compact mode: annotated (hover gloss works) but zhuyin hidden ---- // UI chrome is sized for the bare text; 40% wider text overlaps buttons and clips in popups. const CHROME_SEL = 'button,[role=button],select,label,[role=tab],[role=menuitem]'; const fixedMemo = new WeakMap(); function inFixed(el) { if (!el || el === document.body || el === document.documentElement) return false; let v = fixedMemo.get(el); if (v === undefined) { const pos = getComputedStyle(el).position; v = pos === 'fixed' || pos === 'sticky' || inFixed(el.parentElement); fixedMemo.set(el, v); } return v; } const isChrome = (el) => CFG.compactChrome && (!!el.closest(CHROME_SEL) || inFixed(el)); // How far content sticks out of a box, in px (0 for inline boxes, which report no client size). // The zhuyin column hangs a few px below an ordinary line, so the guard compares amounts against a // threshold instead of treating any overflow as breakage. const overflowOf = (a) => a.clientHeight ? Math.max(a.scrollHeight - a.clientHeight, a.scrollWidth - a.clientWidth, 0) : 0; function brokeBox(a, before) { const grew = overflowOf(a) - before; if (grew <= 6) return false; const cs = getComputedStyle(a); if (grew <= parseFloat(cs.fontSize) * 0.5) return false; return !(/auto|scroll/.test(cs.overflowY) || /auto|scroll/.test(cs.overflowX)); // scrollers are fine } const GUARD_DEPTH = 4; function guardAncestors(el, out) { for (let a = el, i = 0; a && i < GUARD_DEPTH && a !== document.body; a = a.parentElement, i++) out.add(a); } function restoreAll() { document.querySelectorAll('zy-run').forEach(r => r.replaceWith(document.createTextNode(r.dataset.src))); } // ---------------------------------------------------------------- work queue const queue = []; // roots (elements or text nodes) waiting to be walked let scheduled = false, observer = null, active = false; const idle = window.requestIdleCallback || ((cb) => setTimeout(() => cb({ timeRemaining: () => CFG.sliceMs }), 16)); function enqueue(n) { queue.push(n); if (!scheduled) { scheduled = true; idle(work, { timeout: 500 }); } } function collectTextNodes(root, out) { if (root.nodeType === Node.TEXT_NODE) { if (HAN.test(root.nodeValue) && root.parentElement && !shouldSkipElement(root.parentElement)) out.push(root); return; } if (root.nodeType !== Node.ELEMENT_NODE || shouldSkipElement(root)) return; const tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { acceptNode(n) { if (n.nodeType === Node.ELEMENT_NODE) { if (SKIP_TAGS.has(n.tagName) || n.isContentEditable || n.tagName === 'ZY-RUN' || SKIP_IDS.has(n.id) || n.getAttribute('translate') === 'no') return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_SKIP; } return HAN.test(n.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; }, }); for (let n; (n = tw.nextNode());) out.push(n); } let pending = []; function work(deadline) { const D = loadDict(); const t0 = performance.now(); // Called because the timeout ran out (the page is never idle), timeRemaining() is 0: take a slice anyway, // or nothing would ever get done. const budget = () => Math.min(deadline.didTimeout ? CFG.sliceMs : deadline.timeRemaining(), CFG.sliceMs - (performance.now() - t0)) > 1; observer?.disconnect(); try { // Batched so layout is read twice per slice, not once per node: gather → measure → write → measure. const batch = []; while ((pending.length || queue.length) && budget() && batch.length < 300) { if (!pending.length) { collectTextNodes(queue.shift(), pending); continue; } const n = pending.shift(), pe = n.parentElement; if (!n.isConnected || !pe) continue; if (CFG.lazy && !nearEls.has(pe)) watch(pe); else batch.push(n); } if (batch.length) { const boxes = new Set(), compact = batch.map(n => isChrome(n.parentElement)); if (CFG.overflowGuard) batch.forEach((n, i) => { if (!compact[i]) guardAncestors(n.parentElement, boxes); }); const was = new Map([...boxes].map(a => [a, overflowOf(a)])); // read batch.forEach((n, i) => { if (n.isConnected) annotateTextNode(n, D, compact[i]); }); // write for (const [a, before] of was) { // read // already clipping before we touched it = a "Read more" clamp or ellipsis line, built to cut text off if (before > 2 || !a.isConnected || !brokeBox(a, before)) continue; a.querySelectorAll('zy-run:not([data-c])').forEach(r => r.setAttribute('data-c', '')); log('overflow guard → compact', a); } } } finally { observe(); } scheduled = false; if (pending.length || queue.length) { scheduled = true; idle(work, { timeout: 500 }); } } // ---- long pages: only text near the screen is annotated ---- // A book annotated whole is 150k+ elements, and every scroll, tint change and settings preview pays for all of them. // So a text node waits until its parent element comes within nearScreens of the viewport, and goes back to plain // text once it's farScreens away. The distance is also measured from the element's box, as the observers report an // element clipped by a scrolling box as off screen however close it is. A hidden element (a zero box) counts as near: // annotated up front as before, so a menu doesn't reflow as it opens. let nearEls = new WeakSet(), nearIO = null, farIO = null; const screensAway = (r) => Math.max(0, r.top - innerHeight, -r.bottom) / innerHeight; function watch(el) { nearIO ??= new IntersectionObserver((es) => { for (const e of es) { if (!e.target.isConnected) { nearIO.unobserve(e.target); farIO.unobserve(e.target); } else if (e.isIntersecting || screensAway(e.boundingClientRect) < CFG.nearScreens) { if (!nearEls.has(e.target)) { nearEls.add(e.target); enqueue(e.target); } } else nearEls.delete(e.target); } }, { rootMargin: `${CFG.nearScreens * 100}% 0px` }); farIO ??= new IntersectionObserver((es) => { const far = es.filter(e => !e.isIntersecting && e.target.isConnected && screensAway(e.boundingClientRect) >= CFG.farScreens); if (!far.length) return; observer?.disconnect(); const sel = getSelection(), keep = sel.isCollapsed ? () => false : (r) => sel.containsNode(r, true); // a selection stays whole for (const { target } of far) { nearEls.delete(target); for (const r of target.querySelectorAll('zy-run')) if (!keep(r)) r.replaceWith(r.dataset.src); } observe(); }, { rootMargin: `${CFG.farScreens * 100}% 0px` }); nearIO.observe(el); farIO.observe(el); } function observe() { if (!active) return; observer ??= new MutationObserver((muts) => { for (const m of muts) { if (m.type === 'characterData') { // A site rewrote a text node we haven't touched (we only ever observe untouched ones). if (m.target.parentElement && !m.target.parentElement.closest('zy-run')) enqueue(m.target); continue; } for (const n of m.addedNodes) { if (n.nodeType === Node.ELEMENT_NODE && (n.tagName === 'ZY-RUN' || SKIP_IDS.has(n.id))) continue; if (n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE) enqueue(n); } } }); observer.observe(document.body, { childList: true, subtree: true, characterData: true }); } // ---------------------------------------------------------------- page detection function sniff() { const lang = (document.documentElement.lang || '').toLowerCase(); const sample = (document.body?.innerText || '').slice(0, 200000); let han = 0, s = 0, t = 0; for (const ch of sample) { if (!HAN.test(ch)) continue; han++; if (SIMP_ONLY.has(ch)) s++; else if (TRAD_ONLY.has(ch)) t++; } const tradLang = /^zh-(tw|hk|mo|hant)/.test(lang); const ok = han >= CFG.minHanAuto && (tradLang || t >= s); log('sniff', { lang, han, simp: s, trad: t, ok }); return ok; } function start() { if (active) return; active = true; pageDark = isDarkPage(); injectCss(); enqueue(document.body); whatsNew(); checkUpdate(); } function stop() { active = false; observer?.disconnect(); observer = null; nearIO?.disconnect(); farIO?.disconnect(); nearIO = farIO = null; nearEls = new WeakSet(); queue.length = 0; pending = []; restoreAll(); } // ---------------------------------------------------------------- CC-CEDICT const CEDICT_URL = 'https://www.mdbg.net/chinese/export/cedict/cedict_1_0_ts_utf-8_mdbg.txt.gz'; let CEDICT = null, cedictP = null; async function gunzipMaybe(buf) { const u8 = new Uint8Array(buf); if (u8[0] === 0x1f && u8[1] === 0x8b) { // still gzipped (a server may also have sent Content-Encoding and we got it plain) return new Response(new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip'))).text(); } return new TextDecoder().decode(u8); } async function fetchCedictBytes() { try { const url = GM_getResourceURL('cedict'); if (url) { const r = await fetch(url); if (r.ok) return await r.arrayBuffer(); } } catch (e) { log('cedict @resource unavailable, downloading', e); } return new Promise((res, rej) => GM_xmlhttpRequest({ method: 'GET', url: CEDICT_URL, responseType: 'arraybuffer', timeout: 60000, onload: (r) => (r.status === 200 ? res(r.response) : rej(new Error('HTTP ' + r.status))), onerror: () => rej(new Error('network error')), ontimeout: () => rej(new Error('timeout')) })); } // Index: traditional headword → raw lines. Lines are parsed only when looked up. function loadCedict() { return cedictP ??= (async () => { const t0 = performance.now(); const text = await gunzipMaybe(await fetchCedictBytes()); const map = new Map(); for (let i = 0; i < text.length;) { let j = text.indexOf('\n', i); if (j < 0) j = text.length; if (text.charCodeAt(i) !== 35) { // '#' comment lines const sp = text.indexOf(' ', i); if (sp > i && sp < j) { const k = text.slice(i, sp), line = text.slice(i, text.charCodeAt(j - 1) === 13 ? j - 1 : j); const a = map.get(k); a ? a.push(line) : map.set(k, [line]); } } i = j + 1; } if (map.size < 50000) throw new Error('CC-CEDICT download looks truncated (' + map.size + ' entries)'); // don't cache a broken file as "no entry" CEDICT = map; log('cedict', map.size, 'headwords in', Math.round(performance.now() - t0), 'ms'); return map; })().catch((e) => { cedictP = null; throw e; }); } const CE_LINE = /^(\S+) (\S+) \[([^\]]*)\] \/(.*)\/$/; const HANC = '㐀-䶿一-鿿豈-﫿〇·'; const RE_TS = new RegExp(`([${HANC}]+)\\|[${HANC}]+\\[([^\\]]+)\\]`, 'g'); // 個|个[ge4] → 個 gè const RE_T = new RegExp(`([${HANC}]+)\\[([^\\]]+)\\]`, 'g'); // 得[de2] → 得 dé const RE_PY = /\[([A-Za-z:1-5 ,·]+)\]/g; // [le4 se4] → [lèsè] const fmtDef = (d, py = true) => py ? d.replace(RE_TS, (_, t, p) => `${t} ${numToMarked(p)}`).replace(RE_T, (_, t, p) => `${t} ${numToMarked(p)}`).replace(RE_PY, (_, p) => `[${numToMarked(p)}]`) : d.replace(RE_TS, '$1').replace(RE_T, '$1').replace(/^CL:/, 'measure word: ').replace(/,(?=\S)/g, ', ').replace(/\s*\(?Taiwan pr\. \[[^\]]*\]\)?/g, '').replace(RE_PY, '').trim(); const PRON_ONLY = /^(Taiwan pr\.|also pr\.|pr\. )/i; // pronunciation notes, not meanings // Entries for a word, best first: the reading shown on the page (incl. "Taiwan pr." notes) beats a // toneless match beats the rest; capitalised (proper-noun/surname) readings sink. const lookup = (word, zys) => lookupKey(word, zys && zys.length && !zys.includes('?') ? zyKey(zys) : null); function lookupKey(word, key) { const lines = CEDICT?.get(word); if (!lines) return []; return lines.map((line, order) => { const m = CE_LINE.exec(line); if (!m) return null; const e = { trad: m[1], simp: m[2], num: m[3], defs: m[4].split('/').filter(Boolean), score: 0, order }; if (key) { const k = numKey(e.num); const tw = /Taiwan pr\. \[([^\]]+)\]/.exec(m[4]); if (k === key) e.score = 3; else if (tw && numKey(tw[1]) === key) { e.score = 3; e.tw = tw[1]; } else if (toneless(k) === toneless(key)) e.score = 1; } if (/^[A-Z]/.test(e.num)) e.score -= 0.5; return e; }).filter(Boolean).sort((a, b) => b.score - a.score || a.order - b.order); } const shortDef = (e, py = true) => fmtDef(e.defs.find(d => !/^(surname|variant of|old variant of|used in|see |Taiwan pr)/i.test(d)) ?? e.defs[0] ?? '', py); // ---------------------------------------------------------------- character parts // components.tsv.gz, built by tools/components from Make Me a Hanzi, CJKVI-IDS and Wiktionary (GPL-3.0-or-later, // see data/). One line per character: char, parts ("女s 馬p": s meaning, p sound, f shape), type, gloss, hint, // origin, src, older parts (Wiktionary's, when today's glyph doesn't show them: 年 "禾s 人p"). Loaded like // CC-CEDICT: an @resource, read the first time a card wants it. const COMPS_URL = 'https://cdn.jsdelivr.net/gh/ashirviskas/zhuyin-anywhere@main/data/components.tsv.gz'; let COMPS = null, compsP = null; const SOUND_IN = new Map(); // part → characters it gives the sound to function loadComps() { return compsP ??= (async () => { let buf = null; try { const url = GM_getResourceURL('comps'); if (url) { const r = await fetch(url); if (r.ok) buf = await r.arrayBuffer(); } } catch (e) { log('components @resource unavailable, downloading', e); } buf ??= await gmFetch({ method: 'GET', url: COMPS_URL, responseType: 'arraybuffer', timeout: 60000 }); const map = new Map(); for (const line of (await gunzipMaybe(buf)).split('\n')) { if (!line || line.startsWith('#')) continue; const [ch, parts, type, gloss, hint, origin, , from] = line.split('\t'); const split = (f) => (f ? f.split(' ').map(x => [x.slice(0, -1), x.at(-1)]) : []), ps = split(parts); map.set(ch, { parts: ps, type, gloss, hint, origin, from: split(from) }); for (const [pc, role] of ps) if (role === 'p') { const a = SOUND_IN.get(pc); a ? a.push(ch) : SOUND_IN.set(pc, [ch]); } } if (map.size < 1000) throw new Error('character parts download looks truncated'); return (COMPS = map); })().catch((e) => { compsP = null; throw e; }); } const UNENCODED = /^[??①-⑳]$/; const FORMS = { psc: ['形聲', 'meaning + sound'], ic: ['會意', 'idea from parts'], pic: ['象形', 'picture'], ind: ['指事', 'pointer'], loan: ['假借', 'borrowed for its sound'] }; // ---------------------------------------------------------------- disk cache // Google translations and voice clips, kept in IndexedDB so a reload doesn't ask Google again. IndexedDB // belongs to the page's origin, so each site has its own cache of up to cacheMB. (GM_setValue is shared // across sites, but Violentmonkey injects all of it into every page.) Least recently used goes first. // 'v': key → value. 'm': key → { k, n: bytes, t: last use }, small, so it's cheap to sum and sweep. let dbP = null, dbBytes = 0; const idbReq = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); function cacheDb() { return dbP ??= (async () => { const open = indexedDB.open('zhuyin-anywhere', 1); open.onupgradeneeded = () => { const db = open.result; db.createObjectStore('v'); db.createObjectStore('m', { keyPath: 'k' }).createIndex('t', 't'); }; const db = await idbReq(open); dbBytes = (await idbReq(db.transaction('m').objectStore('m').getAll())).reduce((s, m) => s + m.n, 0); return db; })().catch((e) => { log('cache unavailable', e); return null; }); // sandboxed frames, some private modes } async function cacheGet(k) { const db = CFG.cacheMB > 0 && await cacheDb(); if (!db) return undefined; try { const tx = db.transaction(['v', 'm'], 'readwrite'), m = tx.objectStore('m'); const [v, meta] = await Promise.all([idbReq(tx.objectStore('v').get(k)), idbReq(m.get(k))]); if (v !== undefined && meta) m.put({ ...meta, t: Date.now() }); return v; } catch (e) { log('cache read failed', e); return undefined; } } async function cachePut(k, v, n) { const db = CFG.cacheMB > 0 && await cacheDb(); if (!db) return; try { const tx = db.transaction(['v', 'm'], 'readwrite'), vs = tx.objectStore('v'), m = tx.objectStore('m'); vs.put(v, k); m.put({ k, n, t: Date.now() }); dbBytes += n; const cap = CFG.cacheMB * 2 ** 20; if (dbBytes > cap) m.index('t').openCursor().onsuccess = (e) => { // oldest first, down to 90% const c = e.target.result; if (!c || dbBytes <= cap * 0.9) return; dbBytes -= c.value.n; vs.delete(c.value.k); c.delete(); c.continue(); }; } catch (e) { log('cache write failed', e); } } // ---------------------------------------------------------------- translation const tCache = new Map(); function gmFetch(opts) { return new Promise((resolve, reject) => GM_xmlhttpRequest({ timeout: 15000, ...opts, onload: (r) => (r.status >= 200 && r.status < 300 ? resolve(opts.responseType ? r.response : r.responseText) : reject(new Error('HTTP ' + r.status))), onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timeout')) })); } // Every target language Google Translate offers (codes as its gtx endpoint takes them), English and // Lithuanian first. The menu prompt also takes any other code. const LANGS = [['en', 'English'], ['lt', 'Lithuanian'], ...`af Afrikaans|sq Albanian|am Amharic|ar Arabic|hy Armenian|as Assamese|ay Aymara| az Azerbaijani|bm Bambara|eu Basque|be Belarusian|bn Bengali|bho Bhojpuri|bs Bosnian|bg Bulgarian|ca Catalan|ceb Cebuano| ny Chichewa|zh-CN Chinese (Simplified)|co Corsican|hr Croatian|cs Czech|da Danish|dv Dhivehi|doi Dogri|nl Dutch|eo Esperanto| et Estonian|ee Ewe|tl Filipino|fi Finnish|fr French|fy Frisian|gl Galician|ka Georgian|de German|el Greek|gn Guarani| gu Gujarati|ht Haitian Creole|ha Hausa|haw Hawaiian|iw Hebrew|hi Hindi|hmn Hmong|hu Hungarian|is Icelandic|ig Igbo| ilo Ilocano|id Indonesian|ga Irish|it Italian|ja Japanese|jw Javanese|kn Kannada|kk Kazakh|km Khmer|rw Kinyarwanda| gom Konkani|ko Korean|kri Krio|ku Kurdish (Kurmanji)|ckb Kurdish (Sorani)|ky Kyrgyz|lo Lao|la Latin|lv Latvian|ln Lingala| lg Luganda|lb Luxembourgish|mk Macedonian|mai Maithili|mg Malagasy|ms Malay|ml Malayalam|mt Maltese|mi Maori|mr Marathi| mni-Mtei Meiteilon (Manipuri)|lus Mizo|mn Mongolian|my Myanmar (Burmese)|ne Nepali|no Norwegian|or Odia|om Oromo|ps Pashto| fa Persian|pl Polish|pt Portuguese|pa Punjabi|qu Quechua|ro Romanian|ru Russian|sm Samoan|sa Sanskrit|gd Scots Gaelic| nso Sepedi|sr Serbian|st Sesotho|sn Shona|sd Sindhi|si Sinhala|sk Slovak|sl Slovenian|so Somali|es Spanish|su Sundanese| sw Swahili|sv Swedish|tg Tajik|ta Tamil|tt Tatar|te Telugu|th Thai|ti Tigrinya|ts Tsonga|tr Turkish|tk Turkmen|ak Twi| uk Ukrainian|ur Urdu|ug Uyghur|uz Uzbek|vi Vietnamese|cy Welsh|xh Xhosa|yi Yiddish|yo Yoruba|zu Zulu` .split('|').map(x => { x = x.trim(); const k = x.indexOf(' '); return [x.slice(0, k), x.slice(k + 1)]; })]; const langName = (c) => LANGS.find(l => l[0] === c)?.[1] ?? c; const langs = () => [...new Set([CFG.targetLang, CFG.targetLang2].filter(Boolean))]; const translator = (lang) => (CFG.translator === 'local' && lang === 'en' ? 'local' : 'google'); // opus-mt only knows English const viaText = () => { const ls = langs(), by = [...new Set(ls.map(l => (translator(l) === 'local' ? 'local opus-mt' : 'Google')))].join(' + '); return by + (ls.join() === 'en' ? '' : ' · ' + ls.map(langName).join(' + ')); }; // With two languages every translation line carries a small language tag. const langTag = (l) => (langs().length > 1 ? el('span', 'lg', l.toUpperCase()) : ''); // The language is passed in, not read from CFG: a switch mid-request mustn't file the answer under the new one. const google = async (text, lang, sl = 'zh-TW') => { const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=' + sl + '&tl=' + encodeURIComponent(lang) + '&dt=t&q=' + encodeURIComponent(text); const body = await gmFetch({ method: 'GET', url }); let j; try { j = JSON.parse(body); } catch { throw new Error('Google returned a non-JSON page (rate-limited / captcha?)'); } return (j[0] || []).map(seg => seg[0]).join(''); }; const putTr = (k, v) => cachePut(k, v, 2 * (k.length + v.length)); async function translate(text, lang) { const tr = translator(lang), key = tr + '|' + lang + '|' + text; if (tCache.has(key)) return tCache.get(key); let out; if (tr === 'local') { const r = JSON.parse(await gmFetch({ method: 'POST', url: CFG.localServer + '/translate', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ lines: [text] }) })); out = r.lines?.[0] ?? ''; } else { const k = 'tr|' + lang + '|' + text; out = await cacheGet(k); if (out === undefined) { out = await google(text, lang); putTr(k, out); } } tCache.set(key, out); return out; } // Words for the hover card and the word list, Google only: the ones not cached go in one request, one // per line; Google keeps the lines apart. If it ever merges lines, each word is asked on its own. // sl 'en': English part meanings (飠 "food"), hints and origins for "Translate explanations". async function translateWords(words, lang, sl = 'zh-TW') { const from = sl === 'zh-TW' ? '' : sl + '>'; const mk = (w) => 'google|' + from + lang + '|' + w, dk = (w) => 'tr|' + from + lang + '|' + w; const out = await Promise.all(words.map(async (w) => tCache.get(mk(w)) ?? cacheGet(dk(w)))); const miss = words.filter((w, i) => out[i] === undefined); if (miss.length) { const lines = (await google(miss.join('\n'), lang, sl)).split('\n').map(l => l.trim()); const got = lines.length === miss.length ? lines : await Promise.all(miss.map(w => google(w, lang, sl))); miss.forEach((w, i) => putTr(dk(w), got[i])); let m = 0; out.forEach((v, i) => { if (v === undefined) out[i] = got[m++]; }); } words.forEach((w, i) => tCache.set(mk(w), out[i])); return out; } // ---------------------------------------------------------------- read aloud // Google's zh-TW voice, decoded and played through Web Audio: an