From 82bec02a362980a63a7e12d21447f726690a987f Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 6 Mar 2026 16:38:19 -0800 Subject: [PATCH] feat(subtitles): highlight character-name tokens --- ...rable-character-name-token-highlighting.md | 39 ++++ src/config/config.test.ts | 81 ++++++++ src/config/definitions/defaults-subtitle.ts | 2 + src/config/definitions/options-subtitle.ts | 14 ++ src/config/resolve/subtitle-domains.ts | 32 +++ src/config/resolve/subtitle-style.test.ts | 50 +++++ src/core/services/app-ready.test.ts | 5 + src/core/services/startup.ts | 2 + src/core/services/tokenizer.test.ts | 16 ++ src/core/services/tokenizer.ts | 11 +- .../tokenizer/yomitan-parser-runtime.test.ts | 193 ++++++++++++++++++ .../tokenizer/yomitan-parser-runtime.ts | 104 ++++++++-- src/main.ts | 2 + src/main/app-lifecycle.ts | 2 + src/main/runtime/app-ready-main-deps.test.ts | 3 + src/main/runtime/app-ready-main-deps.ts | 1 + .../composers/app-ready-composer.test.ts | 1 + .../runtime/config-hot-reload-handlers.ts | 1 + .../subtitle-tokenization-main-deps.test.ts | 2 + .../subtitle-tokenization-main-deps.ts | 6 + src/renderer/modals/session-help.ts | 7 + src/renderer/state.ts | 4 + src/renderer/style.css | 22 +- src/renderer/subtitle-render.test.ts | 78 ++++++- src/renderer/subtitle-render.ts | 65 +++--- src/types.ts | 3 + 26 files changed, 703 insertions(+), 43 deletions(-) create mode 100644 backlog/tasks/task-97 - Add-configurable-character-name-token-highlighting.md diff --git a/backlog/tasks/task-97 - Add-configurable-character-name-token-highlighting.md b/backlog/tasks/task-97 - Add-configurable-character-name-token-highlighting.md new file mode 100644 index 0000000..3c5095f --- /dev/null +++ b/backlog/tasks/task-97 - Add-configurable-character-name-token-highlighting.md @@ -0,0 +1,39 @@ +--- +id: TASK-97 +title: Add configurable character-name token highlighting +status: Done +assignee: [] +created_date: '2026-03-06 10:15' +updated_date: '2026-03-06 10:15' +labels: + - subtitle + - dictionary + - renderer +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.ts + - >- + /home/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer/yomitan-parser-runtime.ts + - /home/sudacode/projects/japanese/SubMiner/src/renderer/subtitle-render.ts +priority: medium +--- + +## Description + + +Color subtitle tokens that match entries from the SubMiner character dictionary, with a configurable default color and a config toggle that disables both rendering and name-match detection work. + + +## Acceptance Criteria + +- [x] #1 Tokens matched from the SubMiner character dictionary receive dedicated renderer styling. +- [x] #2 `subtitleStyle.nameMatchEnabled` disables name-match detection work when false. +- [x] #3 `subtitleStyle.nameMatchColor` overrides the default `#f5bde6`. +- [x] #4 Regression coverage verifies config parsing, tokenizer propagation, scanner gating, and renderer class/CSS behavior. + + +## Final Summary + + +Added configurable character-name token highlighting with default color `#f5bde6` and config gate `subtitleStyle.nameMatchEnabled`. When enabled, left-to-right Yomitan scanning tags tokens whose winning dictionary entry comes from the SubMiner character dictionary; when disabled, the tokenizer skips that metadata work and the renderer suppresses name-match styling. Added focused regression tests for config parsing, main-deps wiring, Yomitan scan gating, token propagation, renderer classes, and CSS behavior. + diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 5c314a4..7d6ca6f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -242,6 +242,49 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { ); }); +test('parses subtitleStyle.nameMatchColor and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "nameMatchColor": "#eed49f" + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal( + ((validService.getConfig().subtitleStyle as unknown as Record).nameMatchColor ?? + null) as string | null, + '#eed49f', + ); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "nameMatchColor": "pink" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + ((invalidService.getConfig().subtitleStyle as unknown as Record) + .nameMatchColor ?? null) as string | null, + '#f5bde6', + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.nameMatchColor'), + ); +}); + test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( @@ -280,6 +323,44 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values ); }); +test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "nameMatchEnabled": false + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.nameMatchEnabled, false); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "nameMatchEnabled": "no" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.nameMatchEnabled, + DEFAULT_CONFIG.subtitleStyle.nameMatchEnabled, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.nameMatchEnabled'), + ); +}); + test('parses anilist.enabled and warns for invalid value', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index 57446ba..d6792f8 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -8,6 +8,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick = { autoPauseVideoOnYomitanPopup: false, hoverTokenColor: '#f4dbd6', hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)', + nameMatchEnabled: true, + nameMatchColor: '#f5bde6', fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP', fontSize: 35, fontColor: '#cad3f5', diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index d822835..72c3ec4 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -47,6 +47,20 @@ export function buildSubtitleConfigOptionRegistry( defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor, description: 'CSS color used for hovered subtitle token background highlight in mpv.', }, + { + path: 'subtitleStyle.nameMatchEnabled', + kind: 'boolean', + defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled, + description: + 'Enable subtitle token coloring for matches from the SubMiner character dictionary.', + }, + { + path: 'subtitleStyle.nameMatchColor', + kind: 'string', + defaultValue: defaultConfig.subtitleStyle.nameMatchColor, + description: + 'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.', + }, { path: 'subtitleStyle.frequencyDictionary.enabled', kind: 'boolean', diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index baf3b72..e36e59d 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -105,6 +105,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenBackgroundColor = resolved.subtitleStyle.hoverTokenBackgroundColor; + const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled; + const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor; const fallbackFrequencyDictionary = { ...resolved.subtitleStyle.frequencyDictionary, }; @@ -228,6 +230,36 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ); } + const nameMatchColor = asColor( + (src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor, + ); + const nameMatchEnabled = asBoolean( + (src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled, + ); + if (nameMatchEnabled !== undefined) { + resolved.subtitleStyle.nameMatchEnabled = nameMatchEnabled; + } else if ((src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled !== undefined) { + resolved.subtitleStyle.nameMatchEnabled = fallbackSubtitleStyleNameMatchEnabled; + warn( + 'subtitleStyle.nameMatchEnabled', + (src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled, + resolved.subtitleStyle.nameMatchEnabled, + 'Expected boolean.', + ); + } + + if (nameMatchColor !== undefined) { + resolved.subtitleStyle.nameMatchColor = nameMatchColor; + } else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) { + resolved.subtitleStyle.nameMatchColor = fallbackSubtitleStyleNameMatchColor; + warn( + 'subtitleStyle.nameMatchColor', + (src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor, + resolved.subtitleStyle.nameMatchColor, + 'Expected hex color.', + ); + } + const frequencyDictionary = isObject( (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, ) diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts index 92575c2..da8ad28 100644 --- a/src/config/resolve/subtitle-style.test.ts +++ b/src/config/resolve/subtitle-style.test.ts @@ -66,6 +66,25 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', ( ); }); +test('subtitleStyle nameMatchEnabled falls back on invalid value', () => { + const { context, warnings } = createResolveContext({ + subtitleStyle: { + nameMatchEnabled: 'invalid' as unknown as boolean, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.nameMatchEnabled' && + warning.message === 'Expected boolean.', + ), + ); +}); + test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => { const { context } = createResolveContext({}); @@ -80,6 +99,37 @@ test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', ]); }); +test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', () => { + const valid = createResolveContext({ + subtitleStyle: { + nameMatchColor: '#f5bde6', + }, + }); + applySubtitleDomainConfig(valid.context); + assert.equal( + (valid.context.resolved.subtitleStyle as { nameMatchColor?: string }).nameMatchColor, + '#f5bde6', + ); + + const invalid = createResolveContext({ + subtitleStyle: { + nameMatchColor: 'pink', + }, + }); + applySubtitleDomainConfig(invalid.context); + assert.equal( + (invalid.context.resolved.subtitleStyle as { nameMatchColor?: string }).nameMatchColor, + '#f5bde6', + ); + assert.ok( + invalid.warnings.some( + (warning) => + warning.path === 'subtitleStyle.nameMatchColor' && + warning.message === 'Expected hex color.', + ), + ); +}); + test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => { const valid = createResolveContext({ subtitleStyle: { diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index b66894c..2be97e7 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -42,6 +42,7 @@ function makeDeps(overrides: Partial = {}) { }, texthookerOnlyMode: false, shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`), initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'), handleInitialArgs: () => calls.push('handleInitialArgs'), logDebug: (message) => calls.push(`debug:${message}`), @@ -57,7 +58,11 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy }); await runAppReadyRuntime(deps); assert.ok(calls.includes('startSubtitleWebsocket:9001')); + assert.ok(calls.includes('setVisibleOverlayVisible:true')); assert.ok(calls.includes('initializeOverlayRuntime')); + assert.ok( + calls.indexOf('setVisibleOverlayVisible:true') < calls.indexOf('initializeOverlayRuntime'), + ); assert.ok(calls.includes('startBackgroundWarmups')); assert.ok( calls.includes( diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 1ba85f0..a254a85 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -116,6 +116,7 @@ export interface AppReadyRuntimeDeps { startBackgroundWarmups: () => void; texthookerOnlyMode: boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; + setVisibleOverlayVisible: (visible: boolean) => void; initializeOverlayRuntime: () => void; handleInitialArgs: () => void; logDebug?: (message: string) => void; @@ -226,6 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); }); +test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async () => { + const result = await tokenizeSubtitle( + 'アクアです', + makeDepsFromYomitanTokens([ + { surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }, + { surface: 'です', reading: 'です', headword: 'です' }, + ]), + ); + + assert.equal(result.tokens?.length, 2); + assert.equal((result.tokens?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true); + assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false); +}); + test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => { let lookupCalls = 0; const result = await tokenizeSubtitle( diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 6af3def..c06c248 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -44,6 +44,7 @@ export interface TokenizerServiceDeps { getJlptLevel: (text: string) => JlptLevel | null; getNPlusOneEnabled?: () => boolean; getJlptEnabled?: () => boolean; + getNameMatchEnabled?: () => boolean; getFrequencyDictionaryEnabled?: () => boolean; getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode; getFrequencyRank?: FrequencyDictionaryLookup; @@ -73,6 +74,7 @@ export interface TokenizerDepsRuntimeOptions { getJlptLevel: (text: string) => JlptLevel | null; getNPlusOneEnabled?: () => boolean; getJlptEnabled?: () => boolean; + getNameMatchEnabled?: () => boolean; getFrequencyDictionaryEnabled?: () => boolean; getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode; getFrequencyRank?: FrequencyDictionaryLookup; @@ -85,6 +87,7 @@ export interface TokenizerDepsRuntimeOptions { interface TokenizerAnnotationOptions { nPlusOneEnabled: boolean; jlptEnabled: boolean; + nameMatchEnabled: boolean; frequencyEnabled: boolean; frequencyMatchMode: FrequencyDictionaryMatchMode; minSentenceWordsForNPlusOne: number | undefined; @@ -190,6 +193,7 @@ export function createTokenizerDepsRuntime( getJlptLevel: options.getJlptLevel, getNPlusOneEnabled: options.getNPlusOneEnabled, getJlptEnabled: options.getJlptEnabled, + getNameMatchEnabled: options.getNameMatchEnabled, getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled, getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'), getFrequencyRank: options.getFrequencyRank, @@ -301,6 +305,7 @@ function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] { isMerged: token.isMerged ?? true, isKnown: token.isKnown ?? false, isNPlusOneTarget: token.isNPlusOneTarget ?? false, + isNameMatch: token.isNameMatch ?? false, reading: normalizeYomitanMergedReading(token), })); } @@ -460,6 +465,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp return { nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false, jlptEnabled: deps.getJlptEnabled?.() !== false, + nameMatchEnabled: deps.getNameMatchEnabled?.() !== false, frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false, frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword', minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(), @@ -473,7 +479,9 @@ async function parseWithYomitanInternalParser( deps: TokenizerServiceDeps, options: TokenizerAnnotationOptions, ): Promise { - const selectedTokens = await requestYomitanScanTokens(text, deps, logger); + const selectedTokens = await requestYomitanScanTokens(text, deps, logger, { + includeNameMatchMetadata: options.nameMatchEnabled, + }); if (!selectedTokens || selectedTokens.length === 0) { return null; } @@ -489,6 +497,7 @@ async function parseWithYomitanInternalParser( isMerged: true, isKnown: false, isNPlusOneTarget: false, + isNameMatch: token.isNameMatch ?? false, }), ), ); diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts index 0318372..352ecc0 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import test from 'node:test'; +import * as vm from 'node:vm'; import { getYomitanDictionaryInfo, importYomitanDictionaryFromZip, @@ -39,6 +40,40 @@ function createDeps( }; } +async function runInjectedYomitanScript( + script: string, + handler: (action: string, params: unknown) => unknown, +): Promise { + return await vm.runInNewContext(script, { + chrome: { + runtime: { + lastError: null, + sendMessage: ( + payload: { action?: string; params?: unknown }, + callback: (response: { result?: unknown; error?: { message?: string } }) => void, + ) => { + try { + callback({ result: handler(payload.action ?? '', payload.params) }); + } catch (error) { + callback({ error: { message: (error as Error).message } }); + } + }, + }, + }, + Array, + Error, + JSON, + Map, + Math, + Number, + Object, + Promise, + RegExp, + Set, + String, + }); +} + test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => { let scriptValue = ''; const deps = createDeps(async (script) => { @@ -450,6 +485,164 @@ test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of assert.match(scannerScript ?? '', /deinflect:\s*true/); }); +test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => { + const deps = createDeps(async (script) => { + if (script.includes('optionsGetFull')) { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + }, + }, + ], + }; + } + + return [ + { + surface: 'アクア', + reading: 'あくあ', + headword: 'アクア', + startPos: 0, + endPos: 3, + isNameMatch: true, + }, + { + surface: 'です', + reading: 'です', + headword: 'です', + startPos: 3, + endPos: 5, + isNameMatch: false, + }, + ]; + }); + + const result = await requestYomitanScanTokens('アクアです', deps, { + error: () => undefined, + }); + + assert.equal(result?.length, 2); + assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, true); + assert.equal((result?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false); +}); + +test('requestYomitanScanTokens skips name-match work when disabled', async () => { + let scannerScript = ''; + const deps = createDeps(async (script) => { + if (script.includes('termsFind')) { + scannerScript = script; + } + if (script.includes('optionsGetFull')) { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + }, + }, + ], + }; + } + + return [ + { + surface: 'アクア', + reading: 'あくあ', + headword: 'アクア', + startPos: 0, + endPos: 3, + }, + ]; + }); + + const result = await requestYomitanScanTokens( + 'アクア', + deps, + { error: () => undefined }, + { includeNameMatchMetadata: false }, + ); + + assert.equal(result?.length, 1); + assert.equal((result?.[0] as { isNameMatch?: boolean } | undefined)?.isNameMatch, undefined); + assert.match(scannerScript, /const includeNameMatchMetadata = false;/); +}); + +test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary alias only exists on definitions', async () => { + let scannerScript = ''; + const deps = createDeps(async (script) => { + if (script.includes('termsFind')) { + scannerScript = script; + return []; + } + if (script.includes('optionsGetFull')) { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + }, + }, + ], + }; + } + return null; + }); + + await requestYomitanScanTokens( + 'カズマ', + deps, + { error: () => undefined }, + { includeNameMatchMetadata: true }, + ); + + assert.match(scannerScript, /getPreferredHeadword/); + + const result = await runInjectedYomitanScript(scannerScript, (action, params) => { + if (action === 'termsFind') { + const text = (params as { text?: string } | undefined)?.text; + if (text === 'カズマ') { + return { + originalTextLength: 3, + dictionaryEntries: [ + { + dictionaryAlias: '', + headwords: [ + { + term: 'カズマ', + reading: 'かずま', + sources: [{ originalText: 'カズマ', isPrimary: true, matchType: 'exact' }], + }, + ], + definitions: [ + { dictionary: 'JMdict', dictionaryAlias: 'JMdict' }, + { + dictionary: 'SubMiner Character Dictionary (AniList 130298)', + dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)', + }, + ], + }, + ], + }; + } + return { originalTextLength: 0, dictionaryEntries: [] }; + } + throw new Error(`unexpected action: ${action}`); + }); + + assert.equal(Array.isArray(result), true); + assert.equal((result as { length?: number } | null)?.length, 1); + assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'カズマ'); + assert.equal((result as Array<{ headword?: string }>)[0]?.headword, 'カズマ'); + assert.equal((result as Array<{ startPos?: number }>)[0]?.startPos, 0); + assert.equal((result as Array<{ endPos?: number }>)[0]?.endPos, 3); + assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); +}); + test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => { let scriptValue = ''; const deps = createDeps(async (script) => { diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index 6f4fce2..6bf280c 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -45,6 +45,7 @@ export interface YomitanScanToken { headword: string; startPos: number; endPos: number; + isNameMatch?: boolean; } interface YomitanProfileMetadata { @@ -75,7 +76,8 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] { typeof entry.reading === 'string' && typeof entry.headword === 'string' && typeof entry.startPos === 'number' && - typeof entry.endPos === 'number', + typeof entry.endPos === 'number' && + (entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean'), ) ); } @@ -772,24 +774,92 @@ const YOMITAN_SCANNING_HELPERS = String.raw` return segments; } function getPreferredHeadword(dictionaryEntries, token) { + function appendDictionaryNames(target, value) { + if (!value || typeof value !== 'object') { + return; + } + const candidates = [ + value.dictionary, + value.dictionaryName, + value.name, + value.title, + value.dictionaryTitle, + value.dictionaryAlias + ]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + target.push(candidate.trim()); + } + } + } + function getDictionaryEntryNames(entry) { + const names = []; + appendDictionaryNames(names, entry); + for (const definition of entry?.definitions || []) { + appendDictionaryNames(names, definition); + } + for (const frequency of entry?.frequencies || []) { + appendDictionaryNames(names, frequency); + } + for (const pronunciation of entry?.pronunciations || []) { + appendDictionaryNames(names, pronunciation); + } + return names; + } + function isNameDictionaryEntry(entry) { + if (!includeNameMatchMetadata || !entry || typeof entry !== 'object') { + return false; + } + return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary")); + } + function hasExactPrimarySource(headword, token) { + for (const src of headword.sources || []) { + if (src.originalText !== token) { continue; } + if (!src.isPrimary) { continue; } + if (src.matchType !== 'exact') { continue; } + return true; + } + return false; + } + let matchedNameDictionary = false; + if (includeNameMatchMetadata) { + for (const dictionaryEntry of dictionaryEntries || []) { + if (!isNameDictionaryEntry(dictionaryEntry)) { continue; } + for (const headword of dictionaryEntry.headwords || []) { + if (!hasExactPrimarySource(headword, token)) { continue; } + matchedNameDictionary = true; + break; + } + if (matchedNameDictionary) { break; } + } + } for (const dictionaryEntry of dictionaryEntries || []) { for (const headword of dictionaryEntry.headwords || []) { - const validSources = []; - for (const src of headword.sources || []) { - if (src.originalText !== token) { continue; } - if (!src.isPrimary) { continue; } - if (src.matchType !== 'exact') { continue; } - validSources.push(src); - } - if (validSources.length > 0) { return {term: headword.term, reading: headword.reading}; } + if (!hasExactPrimarySource(headword, token)) { continue; } + return { + term: headword.term, + reading: headword.reading, + isNameMatch: matchedNameDictionary || isNameDictionaryEntry(dictionaryEntry) + }; } } const fallback = dictionaryEntries?.[0]?.headwords?.[0]; - return fallback ? {term: fallback.term, reading: fallback.reading} : null; + return fallback + ? { + term: fallback.term, + reading: fallback.reading, + isNameMatch: matchedNameDictionary || isNameDictionaryEntry(dictionaryEntries?.[0]) + } + : null; } `; -function buildYomitanScanningScript(text: string, profileIndex: number, scanLength: number): string { +function buildYomitanScanningScript( + text: string, + profileIndex: number, + scanLength: number, + includeNameMatchMetadata: boolean, +): string { return ` (async () => { const invoke = (action, params) => @@ -811,6 +881,7 @@ function buildYomitanScanningScript(text: string, profileIndex: number, scanLeng }); }); ${YOMITAN_SCANNING_HELPERS} + const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'}; const text = ${JSON.stringify(text)}; const details = {matchType: "exact", deinflect: true}; const tokens = []; @@ -834,6 +905,7 @@ ${YOMITAN_SCANNING_HELPERS} headword: preferredHeadword.term, startPos: i, endPos: i + originalTextLength, + isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true, }); i += originalTextLength; continue; @@ -944,6 +1016,9 @@ export async function requestYomitanScanTokens( text: string, deps: YomitanParserRuntimeDeps, logger: LoggerLike, + options?: { + includeNameMatchMetadata?: boolean; + }, ): Promise { const yomitanExt = deps.getYomitanExt(); if (!text || !yomitanExt) { @@ -962,7 +1037,12 @@ export async function requestYomitanScanTokens( try { const rawResult = await parserWindow.webContents.executeJavaScript( - buildYomitanScanningScript(text, profileIndex, scanLength), + buildYomitanScanningScript( + text, + profileIndex, + scanLength, + options?.includeNameMatchMetadata === true, + ), true, ); if (isScanTokenArray(rawResult)) { diff --git a/src/main.ts b/src/main.ts index b72a259..33f870f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2184,6 +2184,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ appState.backgroundMode ? false : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), + setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), shouldSkipHeavyStartup: () => @@ -2435,6 +2436,7 @@ const { 'subtitle.annotation.jlpt', getResolvedConfig().subtitleStyle.enableJlpt, ), + getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( 'subtitle.annotation.frequency', diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 1742f48..6fa40c4 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -43,6 +43,7 @@ export interface AppReadyRuntimeDepsFactoryInput { startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode']; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; + setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible']; initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs']; onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; @@ -99,6 +100,7 @@ export function createAppReadyRuntimeDeps( texthookerOnlyMode: params.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: params.shouldAutoInitializeOverlayRuntimeFromConfig, + setVisibleOverlayVisible: params.setVisibleOverlayVisible, initializeOverlayRuntime: params.initializeOverlayRuntime, handleInitialArgs: params.handleInitialArgs, onCriticalConfigErrors: params.onCriticalConfigErrors, diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts index 94df508..b941d3e 100644 --- a/src/main/runtime/app-ready-main-deps.test.ts +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -37,6 +37,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async startBackgroundWarmups: () => calls.push('start-warmups'), texthookerOnlyMode: false, shouldAutoInitializeOverlayRuntimeFromConfig: () => true, + setVisibleOverlayVisible: () => calls.push('set-visible-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'), handleInitialArgs: () => calls.push('handle-initial-args'), onCriticalConfigErrors: () => { @@ -58,6 +59,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async await onReady.loadYomitanExtension(); await onReady.prewarmSubtitleDictionaries?.(); onReady.startBackgroundWarmups(); + onReady.setVisibleOverlayVisible(true); assert.deepEqual(calls, [ 'load-subtitle-position', @@ -67,5 +69,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async 'load-yomitan', 'prewarm-dicts', 'start-warmups', + 'set-visible-overlay', ]); }); diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index d0eec25..d658f09 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -26,6 +26,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD startBackgroundWarmups: deps.startBackgroundWarmups, texthookerOnlyMode: deps.texthookerOnlyMode, shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig, + setVisibleOverlayVisible: deps.setVisibleOverlayVisible, initializeOverlayRuntime: deps.initializeOverlayRuntime, handleInitialArgs: deps.handleInitialArgs, onCriticalConfigErrors: deps.onCriticalConfigErrors, diff --git a/src/main/runtime/composers/app-ready-composer.test.ts b/src/main/runtime/composers/app-ready-composer.test.ts index 471ab27..fd1f582 100644 --- a/src/main/runtime/composers/app-ready-composer.test.ts +++ b/src/main/runtime/composers/app-ready-composer.test.ts @@ -48,6 +48,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => startBackgroundWarmups: () => {}, texthookerOnlyMode: false, shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => {}, initializeOverlayRuntime: () => {}, handleInitialArgs: () => {}, logDebug: () => {}, diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 4ff3f97..4ffba19 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -24,6 +24,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { ...config.subtitleStyle, nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, knownWordColor: config.ankiConnect.nPlusOne.knownWord, + nameMatchColor: config.subtitleStyle.nameMatchColor, enableJlpt: config.subtitleStyle.enableJlpt, frequencyDictionary: config.subtitleStyle.frequencyDictionary, }; diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts index dd92e25..f17fc84 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.test.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -34,6 +34,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () => getMinSentenceWordsForNPlusOne: () => 3, getJlptLevel: () => 'N2', getJlptEnabled: () => true, + getNameMatchEnabled: () => false, getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryMatchMode: () => 'surface', getFrequencyRank: () => 5, @@ -48,6 +49,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () => deps.setYomitanParserInitPromise(null); assert.equal(deps.getNPlusOneEnabled?.(), true); assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3); + assert.equal(deps.getNameMatchEnabled?.(), false); assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface'); assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']); }); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index 1bc6566..3ee8c8d 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -2,6 +2,7 @@ import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer' type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { getJlptEnabled: NonNullable; + getNameMatchEnabled?: NonNullable; getFrequencyDictionaryEnabled: NonNullable< TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled'] >; @@ -43,6 +44,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(), getJlptLevel: (text: string) => deps.getJlptLevel(text), getJlptEnabled: () => deps.getJlptEnabled(), + ...(deps.getNameMatchEnabled + ? { + getNameMatchEnabled: () => deps.getNameMatchEnabled!(), + } + : {}), getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(), getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(), getFrequencyRank: (text: string) => deps.getFrequencyRank(text), diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index 1619a1c..f5c8177 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -27,6 +27,7 @@ const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA const FALLBACK_COLORS = { knownWordColor: '#a6da95', nPlusOneColor: '#c6a0f6', + nameMatchColor: '#f5bde6', jlptN1Color: '#ed8796', jlptN2Color: '#f5a97f', jlptN3Color: '#f9e2af', @@ -207,6 +208,7 @@ function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] { function buildColorSection(style: { knownWordColor?: unknown; nPlusOneColor?: unknown; + nameMatchColor?: unknown; jlptColors?: { N1?: unknown; N2?: unknown; @@ -228,6 +230,11 @@ function buildColorSection(style: { action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), }, + { + shortcut: 'Character names', + action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor), + color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor), + }, { shortcut: 'JLPT N1', action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 6c7d8ff..d4bbd60 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -58,6 +58,8 @@ export type RendererState = { knownWordColor: string; nPlusOneColor: string; + nameMatchEnabled: boolean; + nameMatchColor: string; jlptN1Color: string; jlptN2Color: string; jlptN3Color: string; @@ -125,6 +127,8 @@ export function createRendererState(): RendererState { knownWordColor: '#a6da95', nPlusOneColor: '#c6a0f6', + nameMatchEnabled: true, + nameMatchColor: '#f5bde6', jlptN1Color: '#ed8796', jlptN2Color: '#f5a97f', jlptN3Color: '#f9e2af', diff --git a/src/renderer/style.css b/src/renderer/style.css index 57a8737..eaa6aab 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -285,6 +285,7 @@ body { color: #cad3f5; --subtitle-known-word-color: #a6da95; --subtitle-n-plus-one-color: #c6a0f6; + --subtitle-name-match-color: #f5bde6; --subtitle-jlpt-n1-color: #ed8796; --subtitle-jlpt-n2-color: #f5a97f; --subtitle-jlpt-n3-color: #f9e2af; @@ -416,6 +417,11 @@ body.settings-modal-open #subtitleContainer { text-shadow: 0 0 6px rgba(198, 160, 246, 0.35); } +#subtitleRoot .word.word-name-match { + color: var(--subtitle-name-match-color, #f5bde6); + text-shadow: 0 0 6px rgba(245, 189, 230, 0.35); +} + #subtitleRoot .word.word-jlpt-n1 { text-decoration-line: underline; text-decoration-thickness: 2px; @@ -510,7 +516,7 @@ body.settings-modal-open #subtitleContainer { } #subtitleRoot - .word:not(.word-known):not(.word-n-plus-one):not(.word-frequency-single):not( + .word:not(.word-known):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not( .word-frequency-band-1 ):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not( .word-frequency-band-5 @@ -523,6 +529,7 @@ body.settings-modal-open #subtitleContainer { #subtitleRoot .word.word-known:hover, #subtitleRoot .word.word-n-plus-one:hover, +#subtitleRoot .word.word-name-match:hover, #subtitleRoot .word.word-frequency-single:hover, #subtitleRoot .word.word-frequency-band-1:hover, #subtitleRoot .word.word-frequency-band-2:hover, @@ -536,6 +543,7 @@ body.settings-modal-open #subtitleContainer { #subtitleRoot .word.word-known .c:hover, #subtitleRoot .word.word-n-plus-one .c:hover, +#subtitleRoot .word.word-name-match .c:hover, #subtitleRoot .word.word-frequency-single .c:hover, #subtitleRoot .word.word-frequency-band-1 .c:hover, #subtitleRoot .word.word-frequency-band-2 .c:hover, @@ -550,7 +558,7 @@ body.settings-modal-open #subtitleContainer { #subtitleRoot .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not( .word-known - ):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not( + ):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not( .word-frequency-band-2 ):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover { color: var(--subtitle-hover-token-color, #f4dbd6) !important; @@ -583,6 +591,12 @@ body.settings-modal-open #subtitleContainer { -webkit-text-fill-color: var(--subtitle-n-plus-one-color, #c6a0f6) !important; } +#subtitleRoot .word.word-name-match::selection, +#subtitleRoot .word.word-name-match .c::selection { + color: var(--subtitle-name-match-color, #f5bde6) !important; + -webkit-text-fill-color: var(--subtitle-name-match-color, #f5bde6) !important; +} + #subtitleRoot .word.word-frequency-single::selection, #subtitleRoot .word.word-frequency-single .c::selection { color: var(--subtitle-frequency-single-color, #f5a97f) !important; @@ -622,13 +636,13 @@ body.settings-modal-open #subtitleContainer { #subtitleRoot .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not( .word-known - ):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not( + ):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not( .word-frequency-band-2 ):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection, #subtitleRoot .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not( .word-known - ):not(.word-n-plus-one):not(.word-frequency-single):not(.word-frequency-band-1):not( + ):not(.word-n-plus-one):not(.word-name-match):not(.word-frequency-single):not(.word-frequency-band-1):not( .word-frequency-band-2 ):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5) .c::selection { diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 5756e4e..e549229 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -181,6 +181,45 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2'); }); +test('computeWordClass applies name-match class ahead of known and frequency classes', () => { + const token = createToken({ + isKnown: true, + frequencyRank: 10, + surface: 'アクア', + }) as MergedToken & { isNameMatch?: boolean }; + token.isNameMatch = true; + + assert.equal( + computeWordClass(token, { + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }), + 'word word-name-match', + ); +}); + +test('computeWordClass skips name-match class when disabled', () => { + const token = createToken({ + surface: 'アクア', + }) as MergedToken & { isNameMatch?: boolean }; + token.isNameMatch = true; + + assert.equal( + computeWordClass(token, { + nameMatchEnabled: false, + enabled: true, + topX: 100, + mode: 'single', + singleColor: '#000000', + bandedColors: ['#000000', '#000000', '#000000', '#000000', '#000000'] as const, + }), + 'word', + ); +}); + test('computeWordClass keeps known and N+1 color classes exclusive over frequency classes', () => { const known = createToken({ isKnown: true, @@ -229,6 +268,39 @@ test('computeWordClass keeps known and N+1 color classes exclusive over frequenc ); }); +test('applySubtitleStyle sets subtitle name-match color variable', () => { + const restoreDocument = installFakeDocument(); + try { + const subtitleRoot = new FakeElement('div'); + const subtitleContainer = new FakeElement('div'); + const secondarySubRoot = new FakeElement('div'); + const secondarySubContainer = new FakeElement('div'); + const ctx = { + state: createRendererState(), + dom: { + subtitleRoot, + subtitleContainer, + secondarySubRoot, + secondarySubContainer, + }, + } as never; + + const renderer = createSubtitleRenderer(ctx); + renderer.applySubtitleStyle({ + nameMatchColor: '#f5bde6', + } as never); + + assert.equal( + (subtitleRoot.style as unknown as { values?: Map }).values?.get( + '--subtitle-name-match-color', + ), + '#f5bde6', + ); + } finally { + restoreDocument(); + } +}); + test('computeWordClass adds frequency class for single mode when rank is within topX', () => { const token = createToken({ surface: '猫', @@ -598,7 +670,7 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { assert.match( cssText, - /#subtitleRoot\s+\.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + /#subtitleRoot\s+\.word:not\(\.word-known\):not\(\.word-n-plus-one\):not\(\.word-name-match\):not\(\.word-frequency-single\):not\(\s*\.word-frequency-band-1\s*\):not\(\.word-frequency-band-2\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\s*\.word-frequency-band-5\s*\):hover\s*\{[\s\S]*?background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, ); const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover'); @@ -636,11 +708,11 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { assert.match( cssText, - /\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\):hover\s*\{[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + /\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-name-match\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\):hover\s*\{[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, ); assert.match( cssText, - /\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\)::selection[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, + /\.word:is\(\.word-jlpt-n1,\s*\.word-jlpt-n2,\s*\.word-jlpt-n3,\s*\.word-jlpt-n4,\s*\.word-jlpt-n5\):not\(\s*\.word-known\s*\):not\(\.word-n-plus-one\):not\(\.word-name-match\):not\(\.word-frequency-single\):not\(\.word-frequency-band-1\):not\(\s*\.word-frequency-band-2\s*\):not\(\.word-frequency-band-3\):not\(\.word-frequency-band-4\):not\(\.word-frequency-band-5\)::selection[\s\S]*?color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;[\s\S]*?-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/, ); const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection'); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 5e0dceb..646c2a7 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -9,6 +9,10 @@ type FrequencyRenderSettings = { bandedColors: [string, string, string, string, string]; }; +type TokenRenderSettings = FrequencyRenderSettings & { + nameMatchEnabled: boolean; +}; + export type SubtitleTokenHoverRange = { start: number; end: number; @@ -77,6 +81,7 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = { singleColor: '#f5a97f', bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'], }; +const DEFAULT_NAME_MATCH_ENABLED = true; function sanitizeFrequencyTopX(value: unknown, fallback: number): number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { @@ -218,25 +223,23 @@ export function getJlptLevelLabelForToken(token: MergedToken): string | null { function renderWithTokens( root: HTMLElement, tokens: MergedToken[], - frequencyRenderSettings?: Partial, + tokenRenderSettings?: Partial, sourceText?: string, preserveLineBreaks = false, ): void { - const resolvedFrequencyRenderSettings = { + const resolvedTokenRenderSettings = { ...DEFAULT_FREQUENCY_RENDER_SETTINGS, - ...frequencyRenderSettings, + ...tokenRenderSettings, bandedColors: sanitizeFrequencyBandedColors( - frequencyRenderSettings?.bandedColors, + tokenRenderSettings?.bandedColors, DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors, ), - topX: sanitizeFrequencyTopX( - frequencyRenderSettings?.topX, - DEFAULT_FREQUENCY_RENDER_SETTINGS.topX, - ), + topX: sanitizeFrequencyTopX(tokenRenderSettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX), singleColor: sanitizeHexColor( - frequencyRenderSettings?.singleColor, + tokenRenderSettings?.singleColor, DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, ), + nameMatchEnabled: tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED, }; const fragment = document.createDocumentFragment(); @@ -257,14 +260,14 @@ function renderWithTokens( const token = segment.token; const span = document.createElement('span'); - span.className = computeWordClass(token, resolvedFrequencyRenderSettings); + span.className = computeWordClass(token, resolvedTokenRenderSettings); span.textContent = token.surface; span.dataset.tokenIndex = String(segment.tokenIndex); if (token.reading) span.dataset.reading = token.reading; if (token.headword) span.dataset.headword = token.headword; const frequencyRankLabel = getFrequencyRankLabelForToken( token, - resolvedFrequencyRenderSettings, + resolvedTokenRenderSettings, ); if (frequencyRankLabel) { span.dataset.frequencyRank = frequencyRankLabel; @@ -296,14 +299,14 @@ function renderWithTokens( } const span = document.createElement('span'); - span.className = computeWordClass(token, resolvedFrequencyRenderSettings); + span.className = computeWordClass(token, resolvedTokenRenderSettings); span.textContent = surface; span.dataset.tokenIndex = String(index); if (token.reading) span.dataset.reading = token.reading; if (token.headword) span.dataset.headword = token.headword; const frequencyRankLabel = getFrequencyRankLabelForToken( token, - resolvedFrequencyRenderSettings, + resolvedTokenRenderSettings, ); if (frequencyRankLabel) { span.dataset.frequencyRank = frequencyRankLabel; @@ -401,26 +404,32 @@ export function buildSubtitleTokenHoverRanges( export function computeWordClass( token: MergedToken, - frequencySettings?: Partial, + tokenRenderSettings?: Partial, ): string { - const resolvedFrequencySettings = { + const resolvedTokenRenderSettings = { ...DEFAULT_FREQUENCY_RENDER_SETTINGS, - ...frequencySettings, + ...tokenRenderSettings, bandedColors: sanitizeFrequencyBandedColors( - frequencySettings?.bandedColors, + tokenRenderSettings?.bandedColors, DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors, ), - topX: sanitizeFrequencyTopX(frequencySettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX), + topX: sanitizeFrequencyTopX( + tokenRenderSettings?.topX, + DEFAULT_FREQUENCY_RENDER_SETTINGS.topX, + ), singleColor: sanitizeHexColor( - frequencySettings?.singleColor, + tokenRenderSettings?.singleColor, DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, ), + nameMatchEnabled: tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED, }; const classes = ['word']; if (token.isNPlusOneTarget) { classes.push('word-n-plus-one'); + } else if (resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch) { + classes.push('word-name-match'); } else if (token.isKnown) { classes.push('word-known'); } @@ -429,8 +438,12 @@ export function computeWordClass( classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`); } - if (!token.isKnown && !token.isNPlusOneTarget) { - const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings); + if ( + !token.isKnown && + !token.isNPlusOneTarget && + !(resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch) + ) { + const frequencyClass = getFrequencyDictionaryClass(token, resolvedTokenRenderSettings); if (frequencyClass) { classes.push(frequencyClass); } @@ -494,7 +507,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { renderWithTokens( ctx.dom.subtitleRoot, tokens, - getFrequencyRenderSettings(), + getTokenRenderSettings(), text, ctx.state.preserveSubtitleLineBreaks, ); @@ -503,8 +516,9 @@ export function createSubtitleRenderer(ctx: RendererContext) { renderCharacterLevel(ctx.dom.subtitleRoot, normalized); } - function getFrequencyRenderSettings(): Partial { + function getTokenRenderSettings(): Partial { return { + nameMatchEnabled: ctx.state.nameMatchEnabled, enabled: ctx.state.frequencyDictionaryEnabled, topX: ctx.state.frequencyDictionaryTopX, mode: ctx.state.frequencyDictionaryMode, @@ -577,6 +591,8 @@ export function createSubtitleRenderer(ctx: RendererContext) { if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95'; const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6'; + const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? true; + const nameMatchColor = style.nameMatchColor ?? ctx.state.nameMatchColor ?? '#f5bde6'; const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor); const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor( style.hoverTokenBackgroundColor, @@ -600,8 +616,11 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.state.knownWordColor = knownWordColor; ctx.state.nPlusOneColor = nPlusOneColor; + ctx.state.nameMatchEnabled = nameMatchEnabled; + ctx.state.nameMatchColor = nameMatchColor; ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-name-match-color', nameMatchColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor); ctx.dom.subtitleRoot.style.setProperty( '--subtitle-hover-token-background-color', diff --git a/src/types.ts b/src/types.ts index 830234d..f1a94c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,7 @@ export interface MergedToken { isMerged: boolean; isKnown: boolean; isNPlusOneTarget: boolean; + isNameMatch?: boolean; jlptLevel?: JlptLevel; frequencyRank?: number; } @@ -293,6 +294,8 @@ export interface SubtitleStyleConfig { autoPauseVideoOnYomitanPopup?: boolean; hoverTokenColor?: string; hoverTokenBackgroundColor?: string; + nameMatchEnabled?: boolean; + nameMatchColor?: string; fontFamily?: string; fontSize?: number; fontColor?: string;