From 8bcb9f9b2a53d3fb1434b3e6d3d0f037fdc606d7 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 18 Jun 2026 01:46:54 +0800 Subject: [PATCH] feat(wordlens): trim hints to first sense + suppress known derivations (#4635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(wordlens): rename ww-gloss CSS class to wl-gloss Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class — the 'ww' shorthand was missed. Renamed consistently across the apply site (GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in iframeEventHandlers, and the browser tests. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(wordlens): trim hints to first sense + suppress known derivations Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no regeneration): - cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep only the first sense, so hints stay short — "Ahem" shows 呃哼, not "interj. 呃哼"; multi-sense entries collapse to their first sense. - Derivational reduction (English source only): a would-be-glossed word inherits a known base form's lower rank when the base exists in the pack AND their glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank because their gloss doesn't overlap the base (hardly≠hard, lately≠late). Co-Authored-By: Claude Opus 4.8 (1M context) * docs(wordlens): note hint-quality layer + wl-gloss in agent memory Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../.claude/memory/wordlens-feature.md | 6 +- .../app/reader/wordlens-ruby.browser.test.ts | 4 +- .../document/wordlens.browser.test.ts | 4 +- .../__tests__/services/wordlens/gloss.test.ts | 53 +++++++++++++ .../services/wordlens/planner.test.ts | 33 ++++++++ .../app/reader/utils/iframeEventHandlers.ts | 2 +- .../src/app/reader/utils/wordlensRuby.ts | 4 +- .../src/services/wordlens/gloss.ts | 79 +++++++++++++++++++ .../src/services/wordlens/planner.ts | 25 +++++- apps/readest-app/src/utils/style.ts | 4 +- 10 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts create mode 100644 apps/readest-app/src/services/wordlens/gloss.ts diff --git a/apps/readest-app/.claude/memory/wordlens-feature.md b/apps/readest-app/.claude/memory/wordlens-feature.md index 1dff6df3..9d3b0e8c 100644 --- a/apps/readest-app/.claude/memory/wordlens-feature.md +++ b/apps/readest-app/.claude/memory/wordlens-feature.md @@ -11,13 +11,13 @@ Kindle-style **Word Lens**: a short native-language gloss above difficult words **Files:** `src/services/wordlens/{types,difficulty,glossIndex,planner}.ts` (pure pipeline), `src/app/reader/utils/{wordlensRuby,wordlensSection}.ts` (DOM inject/clear + orchestration), `src/components/settings/WordLensPanel.tsx`, `scripts/build-wordlens-data.mjs`, `public/wordlens/{en-zh,zh-en}.json` + `ATTRIBUTION.md`. -**CFI-safe DOM mutation (the key technique).** Glosses render as `wordgloss` injected into the live iframe doc. foliate `epubcfi.js` `rawChildNodes` HOISTS a `cfi-skip` wrapper's children and REMOVES a `cfi-inert` subtree, then `indexChildNodes` re-merges the now-adjacent text nodes into one chunk → CFI is byte-identical to plain text in BOTH `fromRange` and `toRange`. So highlights/bookmarks/progress are unaffected. `xcfi.ts` honors the same attrs → KOSync safe too. Gate test: `epubcfi-ruby-inline.test.ts` (the pre-existing `epubcfi-{inert,skip}.test.ts` only cover block wrappers, NOT mid-text inline ruby with text on both sides). NOTE: an early research subagent wrongly claimed `cfi-skip`/`cfi-inert` only affect tree-walk not offset summation — that's FALSE; they fully neutralize offsets. This retired the need for an overlay-rendering fallback. +**CFI-safe DOM mutation (the key technique).** Glosses render as `wordgloss` injected into the live iframe doc. foliate `epubcfi.js` `rawChildNodes` HOISTS a `cfi-skip` wrapper's children and REMOVES a `cfi-inert` subtree, then `indexChildNodes` re-merges the now-adjacent text nodes into one chunk → CFI is byte-identical to plain text in BOTH `fromRange` and `toRange`. So highlights/bookmarks/progress are unaffected. `xcfi.ts` honors the same attrs → KOSync safe too. Gate test: `epubcfi-ruby-inline.test.ts` (the pre-existing `epubcfi-{inert,skip}.test.ts` only cover block wrappers, NOT mid-text inline ruby with text on both sides). NOTE: an early research subagent wrongly claimed `cfi-skip`/`cfi-inert` only affect tree-walk not offset summation — that's FALSE; they fully neutralize offsets. This retired the need for an overlay-rendering fallback. **TTS/search/annotation isolation.** Gloss text must stay out of spoken text, find-in-book, and Edge-TTS word offsets. (1) foliate TTS reject filter already has `tags:['rt']` (`TTSController.ts`) → spoken text excludes gloss automatically. (2) `globalAnnotations` findTextRanges already rejects `rt`/`rp`. (3) `wordHighlight.ts` getTextSubRange walked raw `SHOW_TEXT` → added `isInertText` skip + exported `rangeTextExcludingInert` (used in `TTSController.prepareSpeakWords` instead of `range.toString()`), else Edge word offsets drift vs the gloss-free boundary words — this also fixes a latent furigana bug. (4) search `SearchBar.tsx` only rejected `rt` for ja → added `attributes:['cfi-inert']`. **Gloss data caveat (IMPORTANT).** The committed `public/wordlens/*.json` are small CURATED STARTER sets (52 EN→中文, 40 中文→EN) so the feature works out-of-the-box. The FULL frequency-trimmed assets are a maintainer step: `node scripts/build-wordlens-data.mjs en-zh [topN]` (ECDICT, MIT, ~160MB full; difficulty = COCA `frq` rank; EN inflections parsed from the `exchange` field) and `zh-en [topN]` (CC-CEDICT CC-BY-SA + HSK; zh tokenized in-app via jieba `cutZh`). Index is lazy-fetched once from `/wordlens/.json` into an in-memory `GlossIndex`. -**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap` generation guard (latest wins on a clean doc); a `pendingWordLensDictRef` could stick `true` past early-returns and hijack the next selection → read-and-reset pattern; skip pre-paginated/fixed-layout books (ruby overflows fixed boxes); adding a `SettingsPanelType` requires updating the exhaustive `panelIcons` map in `commandRegistry.ts` AND its `react-icons/pi` test mock. Tap-to-dict = `iframeEventHandlers` hit-tests `.ww-gloss` → `wordlens-dictionary` event → `Annotator` synthesizes a selection over the base word. **STALE-CLOSURE bug (fixed post-PR):** `handleWordLensDictionary` is registered in a `useEffect(…, [bookKey])`, so it closes over the render-time `view` const — which is still `null` when the effect first runs (view mounts after bookKey). At tap time the closure's `view` stays null → `view.renderer.getContents()` undefined → content-not-found early-return → no popup, silent. Fix = read `const view = getView(bookKey)` FRESH inside the handler (mirrors the other handlers at lines ~616/709/748 that already do this). Diagnosed live via `[wwtap]` console-trace instrumentation through the whole path (gloss-hit → dispatch → handler → effect); the `content? true index N` log confirmed the fix. General rule for this file: any foliate event handler bound once with `[bookKey]` deps must call `getView(bookKey)` rather than use the closure `view`. +**Gotchas fixed in review:** slider-drag fires repeated refreshes → `refreshSectionGlosses` uses a `WeakMap` generation guard (latest wins on a clean doc); a `pendingWordLensDictRef` could stick `true` past early-returns and hijack the next selection → read-and-reset pattern; skip pre-paginated/fixed-layout books (ruby overflows fixed boxes); adding a `SettingsPanelType` requires updating the exhaustive `panelIcons` map in `commandRegistry.ts` AND its `react-icons/pi` test mock. Tap-to-dict = `iframeEventHandlers` hit-tests `.wl-gloss` → `wordlens-dictionary` event → `Annotator` synthesizes a selection over the base word. **STALE-CLOSURE bug (fixed post-PR):** `handleWordLensDictionary` is registered in a `useEffect(…, [bookKey])`, so it closes over the render-time `view` const — which is still `null` when the effect first runs (view mounts after bookKey). At tap time the closure's `view` stays null → `view.renderer.getContents()` undefined → content-not-found early-return → no popup, silent. Fix = read `const view = getView(bookKey)` FRESH inside the handler (mirrors the other handlers at lines ~616/709/748 that already do this). Diagnosed live via `[wwtap]` console-trace instrumentation through the whole path (gloss-hit → dispatch → handler → effect); the `content? true index N` log confirmed the fix. General rule for this file: any foliate event handler bound once with `[bookKey]` deps must call `getView(bookKey)` rather than use the closure `view`. **R2 runtime delivery (no bundled packs).** Packs are NOT shipped in the installer: source of truth is `apps/readest-app/data/wordlens/` (committed, outside `public/`+`src/` so unbundled) holding `.json` + `manifest.json` (`{pair,source,target,file,bytes,sha256,entries}`); `scripts/sync-wordlens-r2.mjs` (`pnpm wordlens:sync`, env `WORDLENS_R2_BUCKET`) uploads to the `cdn.readest.com` R2 bucket (`WORDLENS_CDN_BASE`). Runtime: `src/services/wordlens/glossPacks.ts` = `fetchManifest`→`resolvePack(source,hint)`→`ensurePack`(sha256 verify, single-flight via in-flight Map, `allowDownload` flag, `?v=` cache-bust on pack URL only)→`loadGlossIndex` (memo does NOT cache null → retries after download), stored in appService `'Data'` (IndexedDB on web). `getPackStatus`/`deletePack`/`listAvailableTargets` drive the UI. @@ -27,4 +27,6 @@ Kindle-style **Word Lens**: a short native-language gloss above difficult words **UI in Settings → Language** (not a top-level tab): `WordLensSubPanel` opened via a `NavigationRow` in `LangPanel`, mirroring the `CustomDictionaries` sub-page swap (`useKeyDownActions` back/Esc + early-return render). Hint-language `SettingsSelect` = `getLangOptions(TRANSLATED_LANGS)` base-code-filtered against manifest targets (targets are base codes `zh`; TRANSLATED_LANGS has `zh-CN`/`pt-BR` → match on base); value `wordLensHintLang` ('' = auto = app UI lang via `getLocale()`). Global `ReadSettings.wordLensAutoDownload` (default true) gates reader auto-download; `isMetered()` (`src/utils/network.ts`, Network Information API, absent on iOS/Tauri → unmetered). Generalized: `WordLensSourceLang=string`; `difficulty.ts` FREQUENCY cutoffs for all non-zh + HSK-scale (`level×3000`) for zh; `canTokenizeSource` blocks ja/ko/th (tier 3, need a segmenter). Spec: `docs/superpowers/specs/2026-06-14-wordlens-r2-delivery.md`. +**Hint quality (runtime, best-effort over shipped packs — no regen).** `src/services/wordlens/gloss.ts`: `cleanGloss` strips a leading POS tag (`/^\s*(?:[a-zA-Z]{1,6}\.\s*)+/` — the `{1,6}` is so 6-letter `interj.` is caught, e.g. "Ahem" → 呃哼 not "interj. 呃哼") and keeps ONLY the first sense (split on `[,,、;;/]`), so en-zh hints stay short; applied in `planGlosses` when building each occurrence. **Derivational reduction (English source only):** a would-be-glossed word inherits a known base form's LOWER rank when the base exists in the pack AND their glosses overlap, so transparent derivations drop below the cutoff and aren't hinted (lazily⇐lazy, shyly⇐shy via shared 怯, sorrowful⇐sorrow, downwards⇐downward, inwards⇐inward). `baseFormCandidates` over-generates via reverse-suffix rules (-ly/-ily→y/-ful/-ward(s)/-ness/-less); `glossesShareMeaning` validates (shared Han char after stripping 的/地 particles; Latin = shared word≥3). Drifted forms keep their own rank because their gloss does NOT overlap the base (hardly≠hard, lately≠late, barely≠bare) — and those are sub-B1-common anyway so unaffected at higher levels. Done in the PLANNER (which knows `sourceLang`), GlossIndex stays pure. The matters-only-when-would-be-glossed gate (compute reduction only after the direct rank passes `isDifficult`) keeps it cheap. **NOT in the rename PR #4633** — landed as a follow-up; the `wl-gloss` class rename (was `ww-gloss`, the 'ww' shorthand #4633 missed) rode along. + Related: [[edge-tts-word-highlighting-4017]], [[footnote-aside-namespace-order-4438]], [[empty-start-cfi-sync]], [[android-nativefile-remotefile-io]]. diff --git a/apps/readest-app/src/__tests__/app/reader/wordlens-ruby.browser.test.ts b/apps/readest-app/src/__tests__/app/reader/wordlens-ruby.browser.test.ts index e365e5cf..e2660dff 100644 --- a/apps/readest-app/src/__tests__/app/reader/wordlens-ruby.browser.test.ts +++ b/apps/readest-app/src/__tests__/app/reader/wordlens-ruby.browser.test.ts @@ -34,14 +34,14 @@ describe('wordlensRuby', () => { const model = buildSectionTextModel(document); applyGlosses(document, model, [{ start: 4, end: 9, word: 'quick', gloss: '敏捷' }]); - const ruby = document.querySelector('ruby.ww-gloss')!; + const ruby = document.querySelector('ruby.wl-gloss')!; expect(ruby.getAttribute('cfi-skip')).toBe(''); expect(ruby.querySelector('rt')!.getAttribute('cfi-inert')).toBe(''); expect(ruby.querySelector('rt')!.textContent).toBe('敏捷'); expect(document.getElementById('p')!.textContent).toBe('The quick敏捷 fox'); clearGlosses(document); - expect(document.querySelector('ruby.ww-gloss')).toBeNull(); + expect(document.querySelector('ruby.wl-gloss')).toBeNull(); expect(document.getElementById('p')!.textContent).toBe('The quick fox'); }); diff --git a/apps/readest-app/src/__tests__/document/wordlens.browser.test.ts b/apps/readest-app/src/__tests__/document/wordlens.browser.test.ts index 8a51e566..7a6e8461 100644 --- a/apps/readest-app/src/__tests__/document/wordlens.browser.test.ts +++ b/apps/readest-app/src/__tests__/document/wordlens.browser.test.ts @@ -17,12 +17,12 @@ describe('Word Lens rendering (browser)', () => { { start, end: start + 'cryptic'.length, word: 'cryptic', gloss: '晦涩的' }, ]); - const rt = document.querySelector('ruby.ww-gloss > rt')!; + const rt = document.querySelector('ruby.wl-gloss > rt')!; expect(rt.textContent).toBe('晦涩的'); expect(root.getBoundingClientRect().height).toBeGreaterThan(before); clearGlosses(document); - expect(document.querySelector('ruby.ww-gloss')).toBeNull(); + expect(document.querySelector('ruby.wl-gloss')).toBeNull(); expect(root.textContent).toBe('A cryptic note appears.'); }); }); diff --git a/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts b/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts new file mode 100644 index 00000000..fdf7bc69 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/wordlens/gloss.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { cleanGloss, baseFormCandidates, glossesShareMeaning } from '@/services/wordlens/gloss'; + +describe('cleanGloss', () => { + it('strips a leading interj. POS tag (6 letters)', () => { + expect(cleanGloss('interj. 呃哼')).toBe('呃哼'); + }); + + it('strips short leading POS tags', () => { + expect(cleanGloss('a. 神秘的')).toBe('神秘的'); + expect(cleanGloss('vt. 做;vi. 看')).toBe('做'); + }); + + it('keeps only the first sense (comma- or semicolon-separated)', () => { + expect(cleanGloss('内心的, 向内的, 本来的;向内的')).toBe('内心的'); + expect(cleanGloss('to run, to operate, to manage')).toBe('to run'); + }); + + it('leaves a single short sense unchanged, including a trailing 的', () => { + expect(cleanGloss('向下')).toBe('向下'); + expect(cleanGloss('晦涩的')).toBe('晦涩的'); + }); + + it('returns empty string for empty input', () => { + expect(cleanGloss('')).toBe(''); + }); +}); + +describe('baseFormCandidates', () => { + it('over-generates base forms for transparent derivations', () => { + expect(baseFormCandidates('lazily')).toContain('lazy'); + expect(baseFormCandidates('shyly')).toContain('shy'); + expect(baseFormCandidates('sorrowful')).toContain('sorrow'); + expect(baseFormCandidates('downwards')).toContain('downward'); + expect(baseFormCandidates('inwards')).toContain('inward'); + }); + + it('returns no candidates for a non-derived word', () => { + expect(baseFormCandidates('cryptic')).toEqual([]); + }); +}); + +describe('glossesShareMeaning', () => { + it('is true when two CJK glosses share a content character', () => { + expect(glossesShareMeaning('懒洋洋地', '懒惰的, 怠惰的')).toBe(true); + expect(glossesShareMeaning('向下', '向下的')).toBe(true); + }); + + it('is false when a derived gloss has drifted from its base', () => { + expect(glossesShareMeaning('几乎不', '坚硬的, 硬的')).toBe(false); // hardly vs hard + expect(glossesShareMeaning('近来', '迟的, 晚的')).toBe(false); // lately vs late + }); +}); diff --git a/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts b/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts index 971c50eb..05e7c5f4 100644 --- a/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts +++ b/apps/readest-app/src/__tests__/services/wordlens/planner.test.ts @@ -52,6 +52,39 @@ describe('planGlosses (Chinese)', () => { }); }); +describe('planGlosses derivational reduction (English source)', () => { + // A derived word inherits its base's (lower) rank when the base exists and + // their glosses share meaning — so it drops below the cutoff and isn't hinted. + const derv: GlossSource = { + lookup(word) { + const table: Record = { + lazily: { rank: 16150, gloss: '懒洋洋地' }, + lazy: { rank: 6238, gloss: '懒惰的, 怠惰的' }, + // hardly is artificially rare here to prove the gloss-overlap gate (not rank) keeps it. + hardly: { rank: 16000, gloss: '几乎不' }, + hard: { rank: 438, gloss: '坚硬的, 硬的' }, + ahem: { rank: 24471, gloss: 'interj. 呃哼' }, + }; + return table[word.toLowerCase()] ?? null; + }, + }; + const cutoff = 14000; // ~C1 + + it('suppresses a transparent derivation whose base is known (lazily ⇐ lazy)', () => { + expect(planGlosses('lazily', derv, { sourceLang: 'en', rankCutoff: cutoff })).toEqual([]); + }); + + it('keeps a drifted derivation even when its base is common (hardly ≠ hard)', () => { + const occ = planGlosses('hardly', derv, { sourceLang: 'en', rankCutoff: cutoff }); + expect(occ.map((o) => o.word)).toEqual(['hardly']); + }); + + it('cleans the displayed gloss (strips interj., keeps first sense)', () => { + const occ = planGlosses('ahem', derv, { sourceLang: 'en', rankCutoff: cutoff }); + expect(occ[0]?.gloss).toBe('呃哼'); + }); +}); + describe('planGlosses against a zh-en fixture', () => { // Decoupled from the shipping data/wordlens/zh-en.json: the committed pack is // (re)generated from real corpora, so the test owns its ranks via a fixture. diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts index 1a69d27e..c1008693 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -252,7 +252,7 @@ export const handleClick = ( // after the drag/long-hold guards so only a clean single tap triggers it. const glossWord = findGlossWord(element); if (glossWord) { - const ruby = element?.closest('ruby.ww-gloss') ?? null; + const ruby = element?.closest('ruby.wl-gloss') ?? null; eventDispatcher.dispatch('wordlens-dictionary', { bookKey, element: ruby, word: glossWord }); return; } diff --git a/apps/readest-app/src/app/reader/utils/wordlensRuby.ts b/apps/readest-app/src/app/reader/utils/wordlensRuby.ts index c7f557fa..f5036c59 100644 --- a/apps/readest-app/src/app/reader/utils/wordlensRuby.ts +++ b/apps/readest-app/src/app/reader/utils/wordlensRuby.ts @@ -1,6 +1,6 @@ import type { GlossOccurrence } from '@/services/wordlens/types'; -const GLOSS_CLASS = 'ww-gloss'; +const GLOSS_CLASS = 'wl-gloss'; interface Segment { node: Text; @@ -77,7 +77,7 @@ const occurrenceRange = ( return range; }; -/** Wrap each occurrence as wordgloss. */ +/** Wrap each occurrence as wordgloss. */ export const applyGlosses = ( doc: Document, model: SectionTextModel, diff --git a/apps/readest-app/src/services/wordlens/gloss.ts b/apps/readest-app/src/services/wordlens/gloss.ts new file mode 100644 index 00000000..52612278 --- /dev/null +++ b/apps/readest-app/src/services/wordlens/gloss.ts @@ -0,0 +1,79 @@ +// Runtime gloss normalization + English derivational reduction for Word Lens. +// +// These run against the shipped (already-trimmed) gloss packs — no regeneration. +// `cleanGloss` shortens what a reader sees; `baseFormCandidates` + +// `glossesShareMeaning` let a transparent derivation (lazily ⇐ lazy) inherit its +// base's difficulty so it isn't hinted when the base is already known. + +// Leading dictionary POS tags: "a." "vt." "interj." (≤6 letters so interj. is covered). +const LEADING_POS = /^\s*(?:[a-zA-Z]{1,6}\.\s*)+/; +// Sense separators in both the zh packs (",;、") and the en-target packs (",;/"). +const SENSE_SEPARATORS = /[,,、;;/]/; +const MAX_GLOSS_LEN = 24; + +/** First sense only, POS/bracket/classifier noise stripped, length-capped. */ +export function cleanGloss(gloss: string): string { + if (!gloss) return ''; + const firstSense = gloss.split(SENSE_SEPARATORS)[0] ?? ''; + return firstSense + .replace(LEADING_POS, '') + .replace(/\[[^\]]*\]/g, '') // [ge4] pinyin / [医] domain tags + .replace(/\bCL:.*$/, '') // CC-CEDICT classifier clause + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_GLOSS_LEN); +} + +// Reverse-derivation rules. Over-generate candidates ("all possibilities"); the +// caller validates each against the index + gloss overlap, so wrong guesses are +// harmless. English-only morphology — gated to English-source packs by the caller. +export function baseFormCandidates(word: string): string[] { + const w = word.toLowerCase(); + const out = new Set(); + const add = (x: string) => { + if (x.length >= 2) out.add(x); + }; + if (w.endsWith('ily') && w.length >= 5) add(w.slice(0, -3) + 'y'); // lazily → lazy + if (w.endsWith('ly') && w.length >= 4) { + add(w.slice(0, -2)); // shyly → shy + add(w.slice(0, -2) + 'e'); // nicely → nice + } + if (w.endsWith('ful') && w.length >= 5) { + add(w.slice(0, -3)); // sorrowful → sorrow + add(w.slice(0, -4) + 'y'); // beautiful → beauty + } + if (w.endsWith('wards') && w.length >= 6) { + add(w.slice(0, -1)); // downwards → downward + add(w.slice(0, -5)); // downwards → down + } else if (w.endsWith('ward') && w.length >= 5) { + add(w.slice(0, -4)); // inward → in + } + if (w.endsWith('ness') && w.length >= 5) { + add(w.slice(0, -4)); // kindness → kind + add(w.slice(0, -5) + 'y'); // happiness → happy + } + if (w.endsWith('less') && w.length >= 5) add(w.slice(0, -4)); // harmless → harm + out.delete(w); + return [...out]; +} + +// Particles that carry no lexical content; dropped before the overlap test so a +// shared 的/地 can't fake a match. +const PARTICLES = /[的地得了着之吗呢啊,,;;、。.\s/]/g; +const HAN = /\p{Script=Han}/u; + +/** + * Do two glosses share core meaning? Used to confirm a derivation is transparent + * (knowing the base really does mean knowing the derived form). CJK glosses match + * on a shared Han character; Latin-script glosses match on a shared word (≥3 letters). + */ +export function glossesShareMeaning(a: string, b: string): boolean { + const an = (a || '').replace(PARTICLES, ''); + const bn = (b || '').replace(PARTICLES, ''); + if (!an || !bn) return false; + const hanChars = [...an].filter((ch) => HAN.test(ch)); + if (hanChars.length) return hanChars.some((ch) => bn.includes(ch)); + const wordsA = a.toLowerCase().match(/[a-z]{3,}/g) ?? []; + const wordsB = new Set(b.toLowerCase().match(/[a-z]{3,}/g) ?? []); + return wordsA.some((w) => wordsB.has(w)); +} diff --git a/apps/readest-app/src/services/wordlens/planner.ts b/apps/readest-app/src/services/wordlens/planner.ts index 9a1d50e3..3c3015ee 100644 --- a/apps/readest-app/src/services/wordlens/planner.ts +++ b/apps/readest-app/src/services/wordlens/planner.ts @@ -1,5 +1,6 @@ -import type { GlossOccurrence, GlossSource, WordLensSourceLang } from './types'; +import type { GlossEntry, GlossOccurrence, GlossSource, WordLensSourceLang } from './types'; import { isDifficult } from './difficulty'; +import { baseFormCandidates, cleanGloss, glossesShareMeaning } from './gloss'; export interface PlanOptions { sourceLang: WordLensSourceLang; @@ -67,7 +68,15 @@ export const planGlosses = ( for (const t of tokens) { const entry = source.lookup(t.word); if (!entry || !isDifficult(entry.rank, opts.rankCutoff)) continue; - occurrences.push({ start: t.start, end: t.end, word: t.word, gloss: entry.gloss }); + // English derivations inherit a known base's lower rank (lazily ⇐ lazy), so + // they drop below the cutoff and aren't hinted. Gated to English source. + if ( + opts.sourceLang === 'en' && + !isDifficult(effectiveRank(t.word, entry, source), opts.rankCutoff) + ) { + continue; + } + occurrences.push({ start: t.start, end: t.end, word: t.word, gloss: cleanGloss(entry.gloss) }); if (occurrences.length >= cap) { console.warn(`[wordlens] occurrence cap (${cap}) hit; some hints omitted`); break; @@ -75,3 +84,15 @@ export const planGlosses = ( } return occurrences; }; + +// Lowest rank among the word itself and any base form that exists in the index +// AND shares meaning with it (transparent derivation). A drifted form like +// `hardly` finds `hard` but their glosses don't overlap, so it keeps its own rank. +const effectiveRank = (word: string, entry: GlossEntry, source: GlossSource): number => { + let rank = entry.rank; + for (const base of baseFormCandidates(word)) { + const b = source.lookup(base); + if (b && glossesShareMeaning(entry.gloss, b.gloss)) rank = Math.min(rank, b.rank); + } + return rank; +}; diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 9ee4637c..7d3ba2a0 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -760,10 +760,10 @@ const getRubyStyles = () => ` rp { display: none !important; } - ruby.ww-gloss { + ruby.wl-gloss { cursor: help; } - ruby.ww-gloss > rt { + ruby.wl-gloss > rt { font-size: 0.5em; line-height: 1.1; opacity: 0.7;