feat(subtitles): highlight character-name tokens

This commit is contained in:
2026-03-06 16:38:19 -08:00
parent c548044c61
commit 82bec02a36
26 changed files with 703 additions and 43 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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<string, unknown>).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<string, unknown>)
.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', () => { test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
const validDir = makeTempDir(); const validDir = makeTempDir();
fs.writeFileSync( 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', () => { test('parses anilist.enabled and warns for invalid value', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -8,6 +8,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
autoPauseVideoOnYomitanPopup: false, autoPauseVideoOnYomitanPopup: false,
hoverTokenColor: '#f4dbd6', hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)', hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
nameMatchEnabled: true,
nameMatchColor: '#f5bde6',
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP', fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35, fontSize: 35,
fontColor: '#cad3f5', fontColor: '#cad3f5',

View File

@@ -47,6 +47,20 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor, defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor,
description: 'CSS color used for hovered subtitle token background highlight in mpv.', 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', path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean', kind: 'boolean',

View File

@@ -105,6 +105,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor; const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
const fallbackSubtitleStyleHoverTokenBackgroundColor = const fallbackSubtitleStyleHoverTokenBackgroundColor =
resolved.subtitleStyle.hoverTokenBackgroundColor; resolved.subtitleStyle.hoverTokenBackgroundColor;
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
const fallbackFrequencyDictionary = { const fallbackFrequencyDictionary = {
...resolved.subtitleStyle.frequencyDictionary, ...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( const frequencyDictionary = isObject(
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
) )

View File

@@ -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', () => { test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
const { context } = createResolveContext({}); 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', () => { test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
const valid = createResolveContext({ const valid = createResolveContext({
subtitleStyle: { subtitleStyle: {

View File

@@ -42,6 +42,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
}, },
texthookerOnlyMode: false, texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true, shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
setVisibleOverlayVisible: (visible) => calls.push(`setVisibleOverlayVisible:${visible}`),
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'), initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
handleInitialArgs: () => calls.push('handleInitialArgs'), handleInitialArgs: () => calls.push('handleInitialArgs'),
logDebug: (message) => calls.push(`debug:${message}`), logDebug: (message) => calls.push(`debug:${message}`),
@@ -57,7 +58,11 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
}); });
await runAppReadyRuntime(deps); await runAppReadyRuntime(deps);
assert.ok(calls.includes('startSubtitleWebsocket:9001')); assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('setVisibleOverlayVisible:true'));
assert.ok(calls.includes('initializeOverlayRuntime')); assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(
calls.indexOf('setVisibleOverlayVisible:true') < calls.indexOf('initializeOverlayRuntime'),
);
assert.ok(calls.includes('startBackgroundWarmups')); assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok( assert.ok(
calls.includes( calls.includes(

View File

@@ -116,6 +116,7 @@ export interface AppReadyRuntimeDeps {
startBackgroundWarmups: () => void; startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean; texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean; shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
handleInitialArgs: () => void; handleInitialArgs: () => void;
logDebug?: (message: string) => void; logDebug?: (message: string) => void;
@@ -226,6 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.'); deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) { } else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
} else { } else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.'); deps.log('Overlay runtime deferred: waiting for explicit overlay command.');

View File

@@ -24,6 +24,7 @@ interface YomitanTokenInput {
surface: string; surface: string;
reading?: string; reading?: string;
headword?: string; headword?: string;
isNameMatch?: boolean;
} }
function makeDepsFromYomitanTokens( function makeDepsFromYomitanTokens(
@@ -53,6 +54,7 @@ function makeDepsFromYomitanTokens(
headword: token.headword ?? token.surface, headword: token.headword ?? token.surface,
startPos, startPos,
endPos, endPos,
isNameMatch: token.isNameMatch ?? false,
}; };
}); });
}, },
@@ -115,6 +117,20 @@ test('tokenizeSubtitle assigns JLPT level to parsed Yomitan tokens', async () =>
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5'); 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 () => { test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
let lookupCalls = 0; let lookupCalls = 0;
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(

View File

@@ -44,6 +44,7 @@ export interface TokenizerServiceDeps {
getJlptLevel: (text: string) => JlptLevel | null; getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean; getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean; getJlptEnabled?: () => boolean;
getNameMatchEnabled?: () => boolean;
getFrequencyDictionaryEnabled?: () => boolean; getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode; getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup; getFrequencyRank?: FrequencyDictionaryLookup;
@@ -73,6 +74,7 @@ export interface TokenizerDepsRuntimeOptions {
getJlptLevel: (text: string) => JlptLevel | null; getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean; getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean; getJlptEnabled?: () => boolean;
getNameMatchEnabled?: () => boolean;
getFrequencyDictionaryEnabled?: () => boolean; getFrequencyDictionaryEnabled?: () => boolean;
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode; getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
getFrequencyRank?: FrequencyDictionaryLookup; getFrequencyRank?: FrequencyDictionaryLookup;
@@ -85,6 +87,7 @@ export interface TokenizerDepsRuntimeOptions {
interface TokenizerAnnotationOptions { interface TokenizerAnnotationOptions {
nPlusOneEnabled: boolean; nPlusOneEnabled: boolean;
jlptEnabled: boolean; jlptEnabled: boolean;
nameMatchEnabled: boolean;
frequencyEnabled: boolean; frequencyEnabled: boolean;
frequencyMatchMode: FrequencyDictionaryMatchMode; frequencyMatchMode: FrequencyDictionaryMatchMode;
minSentenceWordsForNPlusOne: number | undefined; minSentenceWordsForNPlusOne: number | undefined;
@@ -190,6 +193,7 @@ export function createTokenizerDepsRuntime(
getJlptLevel: options.getJlptLevel, getJlptLevel: options.getJlptLevel,
getNPlusOneEnabled: options.getNPlusOneEnabled, getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled, getJlptEnabled: options.getJlptEnabled,
getNameMatchEnabled: options.getNameMatchEnabled,
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled, getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'), getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
getFrequencyRank: options.getFrequencyRank, getFrequencyRank: options.getFrequencyRank,
@@ -301,6 +305,7 @@ function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] {
isMerged: token.isMerged ?? true, isMerged: token.isMerged ?? true,
isKnown: token.isKnown ?? false, isKnown: token.isKnown ?? false,
isNPlusOneTarget: token.isNPlusOneTarget ?? false, isNPlusOneTarget: token.isNPlusOneTarget ?? false,
isNameMatch: token.isNameMatch ?? false,
reading: normalizeYomitanMergedReading(token), reading: normalizeYomitanMergedReading(token),
})); }));
} }
@@ -460,6 +465,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
return { return {
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false, nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
jlptEnabled: deps.getJlptEnabled?.() !== false, jlptEnabled: deps.getJlptEnabled?.() !== false,
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false, frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword', frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(), minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
@@ -473,7 +479,9 @@ async function parseWithYomitanInternalParser(
deps: TokenizerServiceDeps, deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions, options: TokenizerAnnotationOptions,
): Promise<MergedToken[] | null> { ): Promise<MergedToken[] | null> {
const selectedTokens = await requestYomitanScanTokens(text, deps, logger); const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
includeNameMatchMetadata: options.nameMatchEnabled,
});
if (!selectedTokens || selectedTokens.length === 0) { if (!selectedTokens || selectedTokens.length === 0) {
return null; return null;
} }
@@ -489,6 +497,7 @@ async function parseWithYomitanInternalParser(
isMerged: true, isMerged: true,
isKnown: false, isKnown: false,
isNPlusOneTarget: false, isNPlusOneTarget: false,
isNameMatch: token.isNameMatch ?? false,
}), }),
), ),
); );

View File

@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import test from 'node:test'; import test from 'node:test';
import * as vm from 'node:vm';
import { import {
getYomitanDictionaryInfo, getYomitanDictionaryInfo,
importYomitanDictionaryFromZip, importYomitanDictionaryFromZip,
@@ -39,6 +40,40 @@ function createDeps(
}; };
} }
async function runInjectedYomitanScript(
script: string,
handler: (action: string, params: unknown) => unknown,
): Promise<unknown> {
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 () => { test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
let scriptValue = ''; let scriptValue = '';
const deps = createDeps(async (script) => { 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/); 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 () => { test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => {
let scriptValue = ''; let scriptValue = '';
const deps = createDeps(async (script) => { const deps = createDeps(async (script) => {

View File

@@ -45,6 +45,7 @@ export interface YomitanScanToken {
headword: string; headword: string;
startPos: number; startPos: number;
endPos: number; endPos: number;
isNameMatch?: boolean;
} }
interface YomitanProfileMetadata { interface YomitanProfileMetadata {
@@ -75,7 +76,8 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
typeof entry.reading === 'string' && typeof entry.reading === 'string' &&
typeof entry.headword === 'string' && typeof entry.headword === 'string' &&
typeof entry.startPos === 'number' && 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; return segments;
} }
function getPreferredHeadword(dictionaryEntries, token) { function getPreferredHeadword(dictionaryEntries, token) {
for (const dictionaryEntry of dictionaryEntries || []) { function appendDictionaryNames(target, value) {
for (const headword of dictionaryEntry.headwords || []) { if (!value || typeof value !== 'object') {
const validSources = []; 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 || []) { for (const src of headword.sources || []) {
if (src.originalText !== token) { continue; } if (src.originalText !== token) { continue; }
if (!src.isPrimary) { continue; } if (!src.isPrimary) { continue; }
if (src.matchType !== 'exact') { continue; } if (src.matchType !== 'exact') { continue; }
validSources.push(src); return true;
} }
if (validSources.length > 0) { return {term: headword.term, reading: headword.reading}; } 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 || []) {
if (!hasExactPrimarySource(headword, token)) { continue; }
return {
term: headword.term,
reading: headword.reading,
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(dictionaryEntry)
};
} }
} }
const fallback = dictionaryEntries?.[0]?.headwords?.[0]; 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 ` return `
(async () => { (async () => {
const invoke = (action, params) => const invoke = (action, params) =>
@@ -811,6 +881,7 @@ function buildYomitanScanningScript(text: string, profileIndex: number, scanLeng
}); });
}); });
${YOMITAN_SCANNING_HELPERS} ${YOMITAN_SCANNING_HELPERS}
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
const text = ${JSON.stringify(text)}; const text = ${JSON.stringify(text)};
const details = {matchType: "exact", deinflect: true}; const details = {matchType: "exact", deinflect: true};
const tokens = []; const tokens = [];
@@ -834,6 +905,7 @@ ${YOMITAN_SCANNING_HELPERS}
headword: preferredHeadword.term, headword: preferredHeadword.term,
startPos: i, startPos: i,
endPos: i + originalTextLength, endPos: i + originalTextLength,
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
}); });
i += originalTextLength; i += originalTextLength;
continue; continue;
@@ -944,6 +1016,9 @@ export async function requestYomitanScanTokens(
text: string, text: string,
deps: YomitanParserRuntimeDeps, deps: YomitanParserRuntimeDeps,
logger: LoggerLike, logger: LoggerLike,
options?: {
includeNameMatchMetadata?: boolean;
},
): Promise<YomitanScanToken[] | null> { ): Promise<YomitanScanToken[] | null> {
const yomitanExt = deps.getYomitanExt(); const yomitanExt = deps.getYomitanExt();
if (!text || !yomitanExt) { if (!text || !yomitanExt) {
@@ -962,7 +1037,12 @@ export async function requestYomitanScanTokens(
try { try {
const rawResult = await parserWindow.webContents.executeJavaScript( const rawResult = await parserWindow.webContents.executeJavaScript(
buildYomitanScanningScript(text, profileIndex, scanLength), buildYomitanScanningScript(
text,
profileIndex,
scanLength,
options?.includeNameMatchMetadata === true,
),
true, true,
); );
if (isScanTokenArray(rawResult)) { if (isScanTokenArray(rawResult)) {

View File

@@ -2184,6 +2184,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
appState.backgroundMode appState.backgroundMode
? false ? false
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(), : configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), handleInitialArgs: () => handleInitialArgs(),
shouldSkipHeavyStartup: () => shouldSkipHeavyStartup: () =>
@@ -2435,6 +2436,7 @@ const {
'subtitle.annotation.jlpt', 'subtitle.annotation.jlpt',
getResolvedConfig().subtitleStyle.enableJlpt, getResolvedConfig().subtitleStyle.enableJlpt,
), ),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getFrequencyDictionaryEnabled: () => getFrequencyDictionaryEnabled: () =>
getRuntimeBooleanOption( getRuntimeBooleanOption(
'subtitle.annotation.frequency', 'subtitle.annotation.frequency',

View File

@@ -43,6 +43,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode']; texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig']; shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime']; initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs']; handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
@@ -99,6 +100,7 @@ export function createAppReadyRuntimeDeps(
texthookerOnlyMode: params.texthookerOnlyMode, texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: shouldAutoInitializeOverlayRuntimeFromConfig:
params.shouldAutoInitializeOverlayRuntimeFromConfig, params.shouldAutoInitializeOverlayRuntimeFromConfig,
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
initializeOverlayRuntime: params.initializeOverlayRuntime, initializeOverlayRuntime: params.initializeOverlayRuntime,
handleInitialArgs: params.handleInitialArgs, handleInitialArgs: params.handleInitialArgs,
onCriticalConfigErrors: params.onCriticalConfigErrors, onCriticalConfigErrors: params.onCriticalConfigErrors,

View File

@@ -37,6 +37,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
startBackgroundWarmups: () => calls.push('start-warmups'), startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false, texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true, shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'), handleInitialArgs: () => calls.push('handle-initial-args'),
onCriticalConfigErrors: () => { onCriticalConfigErrors: () => {
@@ -58,6 +59,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
await onReady.loadYomitanExtension(); await onReady.loadYomitanExtension();
await onReady.prewarmSubtitleDictionaries?.(); await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups(); onReady.startBackgroundWarmups();
onReady.setVisibleOverlayVisible(true);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'load-subtitle-position', 'load-subtitle-position',
@@ -67,5 +69,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
'load-yomitan', 'load-yomitan',
'prewarm-dicts', 'prewarm-dicts',
'start-warmups', 'start-warmups',
'set-visible-overlay',
]); ]);
}); });

View File

@@ -26,6 +26,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
startBackgroundWarmups: deps.startBackgroundWarmups, startBackgroundWarmups: deps.startBackgroundWarmups,
texthookerOnlyMode: deps.texthookerOnlyMode, texthookerOnlyMode: deps.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig, shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
initializeOverlayRuntime: deps.initializeOverlayRuntime, initializeOverlayRuntime: deps.initializeOverlayRuntime,
handleInitialArgs: deps.handleInitialArgs, handleInitialArgs: deps.handleInitialArgs,
onCriticalConfigErrors: deps.onCriticalConfigErrors, onCriticalConfigErrors: deps.onCriticalConfigErrors,

View File

@@ -48,6 +48,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
startBackgroundWarmups: () => {}, startBackgroundWarmups: () => {},
texthookerOnlyMode: false, texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false, shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {}, initializeOverlayRuntime: () => {},
handleInitialArgs: () => {}, handleInitialArgs: () => {},
logDebug: () => {}, logDebug: () => {},

View File

@@ -24,6 +24,7 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
...config.subtitleStyle, ...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.nPlusOne.knownWord, knownWordColor: config.ankiConnect.nPlusOne.knownWord,
nameMatchColor: config.subtitleStyle.nameMatchColor,
enableJlpt: config.subtitleStyle.enableJlpt, enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary, frequencyDictionary: config.subtitleStyle.frequencyDictionary,
}; };

View File

@@ -34,6 +34,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
getMinSentenceWordsForNPlusOne: () => 3, getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2', getJlptLevel: () => 'N2',
getJlptEnabled: () => true, getJlptEnabled: () => true,
getNameMatchEnabled: () => false,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'surface', getFrequencyDictionaryMatchMode: () => 'surface',
getFrequencyRank: () => 5, getFrequencyRank: () => 5,
@@ -48,6 +49,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
deps.setYomitanParserInitPromise(null); deps.setYomitanParserInitPromise(null);
assert.equal(deps.getNPlusOneEnabled?.(), true); assert.equal(deps.getNPlusOneEnabled?.(), true);
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3); assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
assert.equal(deps.getNameMatchEnabled?.(), false);
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface'); assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']); assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
}); });

View File

@@ -2,6 +2,7 @@ import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer'
type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>; getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
getFrequencyDictionaryEnabled: NonNullable< getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled'] TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>; >;
@@ -43,6 +44,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(), getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(),
getJlptLevel: (text: string) => deps.getJlptLevel(text), getJlptLevel: (text: string) => deps.getJlptLevel(text),
getJlptEnabled: () => deps.getJlptEnabled(), getJlptEnabled: () => deps.getJlptEnabled(),
...(deps.getNameMatchEnabled
? {
getNameMatchEnabled: () => deps.getNameMatchEnabled!(),
}
: {}),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(), getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(), getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text), getFrequencyRank: (text: string) => deps.getFrequencyRank(text),

View File

@@ -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 = { const FALLBACK_COLORS = {
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796', jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f', jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af', jlptN3Color: '#f9e2af',
@@ -207,6 +208,7 @@ function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
function buildColorSection(style: { function buildColorSection(style: {
knownWordColor?: unknown; knownWordColor?: unknown;
nPlusOneColor?: unknown; nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: { jlptColors?: {
N1?: unknown; N1?: unknown;
N2?: unknown; N2?: unknown;
@@ -228,6 +230,11 @@ function buildColorSection(style: {
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor), action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: 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', shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color), action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),

View File

@@ -58,6 +58,8 @@ export type RendererState = {
knownWordColor: string; knownWordColor: string;
nPlusOneColor: string; nPlusOneColor: string;
nameMatchEnabled: boolean;
nameMatchColor: string;
jlptN1Color: string; jlptN1Color: string;
jlptN2Color: string; jlptN2Color: string;
jlptN3Color: string; jlptN3Color: string;
@@ -125,6 +127,8 @@ export function createRendererState(): RendererState {
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',
nameMatchEnabled: true,
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796', jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f', jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af', jlptN3Color: '#f9e2af',

View File

@@ -285,6 +285,7 @@ body {
color: #cad3f5; color: #cad3f5;
--subtitle-known-word-color: #a6da95; --subtitle-known-word-color: #a6da95;
--subtitle-n-plus-one-color: #c6a0f6; --subtitle-n-plus-one-color: #c6a0f6;
--subtitle-name-match-color: #f5bde6;
--subtitle-jlpt-n1-color: #ed8796; --subtitle-jlpt-n1-color: #ed8796;
--subtitle-jlpt-n2-color: #f5a97f; --subtitle-jlpt-n2-color: #f5a97f;
--subtitle-jlpt-n3-color: #f9e2af; --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); 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 { #subtitleRoot .word.word-jlpt-n1 {
text-decoration-line: underline; text-decoration-line: underline;
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
@@ -510,7 +516,7 @@ body.settings-modal-open #subtitleContainer {
} }
#subtitleRoot #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 .word-frequency-band-1
):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not( ):not(.word-frequency-band-2):not(.word-frequency-band-3):not(.word-frequency-band-4):not(
.word-frequency-band-5 .word-frequency-band-5
@@ -523,6 +529,7 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot .word.word-known:hover, #subtitleRoot .word.word-known:hover,
#subtitleRoot .word.word-n-plus-one:hover, #subtitleRoot .word.word-n-plus-one:hover,
#subtitleRoot .word.word-name-match:hover,
#subtitleRoot .word.word-frequency-single:hover, #subtitleRoot .word.word-frequency-single:hover,
#subtitleRoot .word.word-frequency-band-1:hover, #subtitleRoot .word.word-frequency-band-1:hover,
#subtitleRoot .word.word-frequency-band-2: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-known .c:hover,
#subtitleRoot .word.word-n-plus-one .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-single .c:hover,
#subtitleRoot .word.word-frequency-band-1 .c:hover, #subtitleRoot .word.word-frequency-band-1 .c:hover,
#subtitleRoot .word.word-frequency-band-2 .c:hover, #subtitleRoot .word.word-frequency-band-2 .c:hover,
@@ -550,7 +558,7 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot #subtitleRoot
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not( .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
.word-known .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 .word-frequency-band-2
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover { ):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5):hover {
color: var(--subtitle-hover-token-color, #f4dbd6) !important; 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; -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::selection,
#subtitleRoot .word.word-frequency-single .c::selection { #subtitleRoot .word.word-frequency-single .c::selection {
color: var(--subtitle-frequency-single-color, #f5a97f) !important; color: var(--subtitle-frequency-single-color, #f5a97f) !important;
@@ -622,13 +636,13 @@ body.settings-modal-open #subtitleContainer {
#subtitleRoot #subtitleRoot
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not( .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
.word-known .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 .word-frequency-band-2
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection, ):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)::selection,
#subtitleRoot #subtitleRoot
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not( .word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
.word-known .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 .word-frequency-band-2
):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5) ):not(.word-frequency-band-3):not(.word-frequency-band-4):not(.word-frequency-band-5)
.c::selection { .c::selection {

View File

@@ -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'); 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', () => { test('computeWordClass keeps known and N+1 color classes exclusive over frequency classes', () => {
const known = createToken({ const known = createToken({
isKnown: true, 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<string, string> }).values?.get(
'--subtitle-name-match-color',
),
'#f5bde6',
);
} finally {
restoreDocument();
}
});
test('computeWordClass adds frequency class for single mode when rank is within topX', () => { test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
const token = createToken({ const token = createToken({
surface: '猫', surface: '猫',
@@ -598,7 +670,7 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
assert.match( assert.match(
cssText, 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'); 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( assert.match(
cssText, 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( assert.match(
cssText, 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'); const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');

View File

@@ -9,6 +9,10 @@ type FrequencyRenderSettings = {
bandedColors: [string, string, string, string, string]; bandedColors: [string, string, string, string, string];
}; };
type TokenRenderSettings = FrequencyRenderSettings & {
nameMatchEnabled: boolean;
};
export type SubtitleTokenHoverRange = { export type SubtitleTokenHoverRange = {
start: number; start: number;
end: number; end: number;
@@ -77,6 +81,7 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
singleColor: '#f5a97f', singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'], bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
}; };
const DEFAULT_NAME_MATCH_ENABLED = true;
function sanitizeFrequencyTopX(value: unknown, fallback: number): number { function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
@@ -218,25 +223,23 @@ export function getJlptLevelLabelForToken(token: MergedToken): string | null {
function renderWithTokens( function renderWithTokens(
root: HTMLElement, root: HTMLElement,
tokens: MergedToken[], tokens: MergedToken[],
frequencyRenderSettings?: Partial<FrequencyRenderSettings>, tokenRenderSettings?: Partial<TokenRenderSettings>,
sourceText?: string, sourceText?: string,
preserveLineBreaks = false, preserveLineBreaks = false,
): void { ): void {
const resolvedFrequencyRenderSettings = { const resolvedTokenRenderSettings = {
...DEFAULT_FREQUENCY_RENDER_SETTINGS, ...DEFAULT_FREQUENCY_RENDER_SETTINGS,
...frequencyRenderSettings, ...tokenRenderSettings,
bandedColors: sanitizeFrequencyBandedColors( bandedColors: sanitizeFrequencyBandedColors(
frequencyRenderSettings?.bandedColors, tokenRenderSettings?.bandedColors,
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors, DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
), ),
topX: sanitizeFrequencyTopX( topX: sanitizeFrequencyTopX(tokenRenderSettings?.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX),
frequencyRenderSettings?.topX,
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
),
singleColor: sanitizeHexColor( singleColor: sanitizeHexColor(
frequencyRenderSettings?.singleColor, tokenRenderSettings?.singleColor,
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
), ),
nameMatchEnabled: tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED,
}; };
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -257,14 +260,14 @@ function renderWithTokens(
const token = segment.token; const token = segment.token;
const span = document.createElement('span'); const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings); span.className = computeWordClass(token, resolvedTokenRenderSettings);
span.textContent = token.surface; span.textContent = token.surface;
span.dataset.tokenIndex = String(segment.tokenIndex); span.dataset.tokenIndex = String(segment.tokenIndex);
if (token.reading) span.dataset.reading = token.reading; if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword; if (token.headword) span.dataset.headword = token.headword;
const frequencyRankLabel = getFrequencyRankLabelForToken( const frequencyRankLabel = getFrequencyRankLabelForToken(
token, token,
resolvedFrequencyRenderSettings, resolvedTokenRenderSettings,
); );
if (frequencyRankLabel) { if (frequencyRankLabel) {
span.dataset.frequencyRank = frequencyRankLabel; span.dataset.frequencyRank = frequencyRankLabel;
@@ -296,14 +299,14 @@ function renderWithTokens(
} }
const span = document.createElement('span'); const span = document.createElement('span');
span.className = computeWordClass(token, resolvedFrequencyRenderSettings); span.className = computeWordClass(token, resolvedTokenRenderSettings);
span.textContent = surface; span.textContent = surface;
span.dataset.tokenIndex = String(index); span.dataset.tokenIndex = String(index);
if (token.reading) span.dataset.reading = token.reading; if (token.reading) span.dataset.reading = token.reading;
if (token.headword) span.dataset.headword = token.headword; if (token.headword) span.dataset.headword = token.headword;
const frequencyRankLabel = getFrequencyRankLabelForToken( const frequencyRankLabel = getFrequencyRankLabelForToken(
token, token,
resolvedFrequencyRenderSettings, resolvedTokenRenderSettings,
); );
if (frequencyRankLabel) { if (frequencyRankLabel) {
span.dataset.frequencyRank = frequencyRankLabel; span.dataset.frequencyRank = frequencyRankLabel;
@@ -401,26 +404,32 @@ export function buildSubtitleTokenHoverRanges(
export function computeWordClass( export function computeWordClass(
token: MergedToken, token: MergedToken,
frequencySettings?: Partial<FrequencyRenderSettings>, tokenRenderSettings?: Partial<TokenRenderSettings>,
): string { ): string {
const resolvedFrequencySettings = { const resolvedTokenRenderSettings = {
...DEFAULT_FREQUENCY_RENDER_SETTINGS, ...DEFAULT_FREQUENCY_RENDER_SETTINGS,
...frequencySettings, ...tokenRenderSettings,
bandedColors: sanitizeFrequencyBandedColors( bandedColors: sanitizeFrequencyBandedColors(
frequencySettings?.bandedColors, tokenRenderSettings?.bandedColors,
DEFAULT_FREQUENCY_RENDER_SETTINGS.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( singleColor: sanitizeHexColor(
frequencySettings?.singleColor, tokenRenderSettings?.singleColor,
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor, DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
), ),
nameMatchEnabled: tokenRenderSettings?.nameMatchEnabled ?? DEFAULT_NAME_MATCH_ENABLED,
}; };
const classes = ['word']; const classes = ['word'];
if (token.isNPlusOneTarget) { if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one'); classes.push('word-n-plus-one');
} else if (resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch) {
classes.push('word-name-match');
} else if (token.isKnown) { } else if (token.isKnown) {
classes.push('word-known'); classes.push('word-known');
} }
@@ -429,8 +438,12 @@ export function computeWordClass(
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`); classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
} }
if (!token.isKnown && !token.isNPlusOneTarget) { if (
const frequencyClass = getFrequencyDictionaryClass(token, resolvedFrequencySettings); !token.isKnown &&
!token.isNPlusOneTarget &&
!(resolvedTokenRenderSettings.nameMatchEnabled && token.isNameMatch)
) {
const frequencyClass = getFrequencyDictionaryClass(token, resolvedTokenRenderSettings);
if (frequencyClass) { if (frequencyClass) {
classes.push(frequencyClass); classes.push(frequencyClass);
} }
@@ -494,7 +507,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
renderWithTokens( renderWithTokens(
ctx.dom.subtitleRoot, ctx.dom.subtitleRoot,
tokens, tokens,
getFrequencyRenderSettings(), getTokenRenderSettings(),
text, text,
ctx.state.preserveSubtitleLineBreaks, ctx.state.preserveSubtitleLineBreaks,
); );
@@ -503,8 +516,9 @@ export function createSubtitleRenderer(ctx: RendererContext) {
renderCharacterLevel(ctx.dom.subtitleRoot, normalized); renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
} }
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> { function getTokenRenderSettings(): Partial<TokenRenderSettings> {
return { return {
nameMatchEnabled: ctx.state.nameMatchEnabled,
enabled: ctx.state.frequencyDictionaryEnabled, enabled: ctx.state.frequencyDictionaryEnabled,
topX: ctx.state.frequencyDictionaryTopX, topX: ctx.state.frequencyDictionaryTopX,
mode: ctx.state.frequencyDictionaryMode, mode: ctx.state.frequencyDictionaryMode,
@@ -577,6 +591,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95'; const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6'; 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 hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor( const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
style.hoverTokenBackgroundColor, style.hoverTokenBackgroundColor,
@@ -600,8 +616,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
ctx.state.knownWordColor = knownWordColor; ctx.state.knownWordColor = knownWordColor;
ctx.state.nPlusOneColor = nPlusOneColor; 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-known-word-color', knownWordColor);
ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor); 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-color', hoverTokenColor);
ctx.dom.subtitleRoot.style.setProperty( ctx.dom.subtitleRoot.style.setProperty(
'--subtitle-hover-token-background-color', '--subtitle-hover-token-background-color',

View File

@@ -54,6 +54,7 @@ export interface MergedToken {
isMerged: boolean; isMerged: boolean;
isKnown: boolean; isKnown: boolean;
isNPlusOneTarget: boolean; isNPlusOneTarget: boolean;
isNameMatch?: boolean;
jlptLevel?: JlptLevel; jlptLevel?: JlptLevel;
frequencyRank?: number; frequencyRank?: number;
} }
@@ -293,6 +294,8 @@ export interface SubtitleStyleConfig {
autoPauseVideoOnYomitanPopup?: boolean; autoPauseVideoOnYomitanPopup?: boolean;
hoverTokenColor?: string; hoverTokenColor?: string;
hoverTokenBackgroundColor?: string; hoverTokenBackgroundColor?: string;
nameMatchEnabled?: boolean;
nameMatchColor?: string;
fontFamily?: string; fontFamily?: string;
fontSize?: number; fontSize?: number;
fontColor?: string; fontColor?: string;