mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
Add vendor dict fallback logic
This commit is contained in:
@@ -76,6 +76,15 @@ export type RendererState = {
|
||||
jlptN3Color: string;
|
||||
jlptN4Color: string;
|
||||
jlptN5Color: string;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
frequencyDictionaryTopX: number;
|
||||
frequencyDictionaryMode: "single" | "banded";
|
||||
frequencyDictionarySingleColor: string;
|
||||
frequencyDictionaryBand1Color: string;
|
||||
frequencyDictionaryBand2Color: string;
|
||||
frequencyDictionaryBand3Color: string;
|
||||
frequencyDictionaryBand4Color: string;
|
||||
frequencyDictionaryBand5Color: string;
|
||||
|
||||
keybindingsMap: Map<string, (string | number)[]>;
|
||||
chordPending: boolean;
|
||||
@@ -140,6 +149,15 @@ export function createRendererState(): RendererState {
|
||||
jlptN3Color: "#f9e2af",
|
||||
jlptN4Color: "#a6e3a1",
|
||||
jlptN5Color: "#8aadf4",
|
||||
frequencyDictionaryEnabled: false,
|
||||
frequencyDictionaryTopX: 1000,
|
||||
frequencyDictionaryMode: "single",
|
||||
frequencyDictionarySingleColor: "#f5a97f",
|
||||
frequencyDictionaryBand1Color: "#ed8796",
|
||||
frequencyDictionaryBand2Color: "#f5a97f",
|
||||
frequencyDictionaryBand3Color: "#f9e2af",
|
||||
frequencyDictionaryBand4Color: "#a6e3a1",
|
||||
frequencyDictionaryBand5Color: "#8aadf4",
|
||||
|
||||
keybindingsMap: new Map(),
|
||||
chordPending: false,
|
||||
|
||||
@@ -255,6 +255,12 @@ body {
|
||||
--subtitle-jlpt-n3-color: #f9e2af;
|
||||
--subtitle-jlpt-n4-color: #a6e3a1;
|
||||
--subtitle-jlpt-n5-color: #8aadf4;
|
||||
--subtitle-frequency-single-color: #f5a97f;
|
||||
--subtitle-frequency-band-1-color: #ed8796;
|
||||
--subtitle-frequency-band-2-color: #f5a97f;
|
||||
--subtitle-frequency-band-3-color: #f9e2af;
|
||||
--subtitle-frequency-band-4-color: #a6e3a1;
|
||||
--subtitle-frequency-band-5-color: #8aadf4;
|
||||
text-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.8),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.5);
|
||||
@@ -346,6 +352,39 @@ body.settings-modal-open #subtitleContainer {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single,
|
||||
#subtitleRoot .word.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-frequency-band-5 {
|
||||
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-single {
|
||||
color: var(--subtitle-frequency-single-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-1 {
|
||||
color: var(--subtitle-frequency-band-1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-2 {
|
||||
color: var(--subtitle-frequency-band-2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-3 {
|
||||
color: var(--subtitle-frequency-band-3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-4 {
|
||||
color: var(--subtitle-frequency-band-4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-frequency-band-5 {
|
||||
color: var(--subtitle-frequency-band-5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -22,8 +22,7 @@ function createToken(overrides: Partial<MergedToken>): MergedToken {
|
||||
};
|
||||
}
|
||||
|
||||
function extractClassBlock(cssText: string, level: number): string {
|
||||
const selector = `#subtitleRoot .word.word-jlpt-n${level}`;
|
||||
function extractClassBlock(cssText: string, selector: string): string {
|
||||
const start = cssText.indexOf(selector);
|
||||
if (start < 0) return "";
|
||||
|
||||
@@ -54,6 +53,87 @@ test("computeWordClass preserves known and n+1 classes while adding JLPT classes
|
||||
);
|
||||
});
|
||||
|
||||
test("computeWordClass adds frequency class for single mode when rank is within topX", () => {
|
||||
const token = createToken({
|
||||
surface: "猫",
|
||||
frequencyRank: 50,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word word-frequency-single");
|
||||
});
|
||||
|
||||
test("computeWordClass adds frequency class when rank equals topX", () => {
|
||||
const token = createToken({
|
||||
surface: "水",
|
||||
frequencyRank: 100,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word word-frequency-single");
|
||||
});
|
||||
|
||||
test("computeWordClass adds frequency class for banded mode", () => {
|
||||
const token = createToken({
|
||||
surface: "犬",
|
||||
frequencyRank: 250,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "banded",
|
||||
singleColor: "#000000",
|
||||
bandedColors:
|
||||
["#111111", "#222222", "#333333", "#444444", "#555555"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word word-frequency-band-2");
|
||||
});
|
||||
|
||||
test("computeWordClass skips frequency class when rank is out of topX", () => {
|
||||
const token = createToken({
|
||||
surface: "犬",
|
||||
frequencyRank: 1200,
|
||||
});
|
||||
|
||||
const actual = computeWordClass(
|
||||
token,
|
||||
{
|
||||
enabled: true,
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#000000",
|
||||
bandedColors: ["#000000", "#000000", "#000000", "#000000", "#000000"] as const,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(actual, "word");
|
||||
});
|
||||
|
||||
test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
const distCssPath = path.join(process.cwd(), "dist", "renderer", "style.css");
|
||||
const srcCssPath = path.join(process.cwd(), "src", "renderer", "style.css");
|
||||
@@ -70,11 +150,25 @@ test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
|
||||
const cssText = fs.readFileSync(cssPath, "utf-8");
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const block = extractClassBlock(cssText, level);
|
||||
const block = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level}`,
|
||||
);
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
assert.match(block, /text-decoration-thickness:\s*2px;/);
|
||||
assert.match(block, /text-underline-offset:\s*4px;/);
|
||||
assert.match(block, /color:\s*inherit;/);
|
||||
}
|
||||
|
||||
for (let band = 1; band <= 5; band += 1) {
|
||||
const block = extractClassBlock(
|
||||
cssText,
|
||||
band === 1
|
||||
? "#subtitleRoot .word.word-frequency-single"
|
||||
: `#subtitleRoot .word.word-frequency-band-${band}`,
|
||||
);
|
||||
assert.ok(block.length > 0, `frequency class word-frequency-${band === 1 ? "single" : `band-${band}`} should exist`);
|
||||
assert.match(block, /color:\s*var\(/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,14 @@ import type {
|
||||
} from "../types";
|
||||
import type { RendererContext } from "./context";
|
||||
|
||||
type FrequencyRenderSettings = {
|
||||
enabled: boolean;
|
||||
topX: number;
|
||||
mode: "single" | "banded";
|
||||
singleColor: string;
|
||||
bandedColors: [string, string, string, string, string];
|
||||
};
|
||||
|
||||
function normalizeSubtitle(text: string, trim = true): string {
|
||||
if (!text) return "";
|
||||
|
||||
@@ -24,7 +32,87 @@ function sanitizeHexColor(value: unknown, fallback: string): string {
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
enabled: false,
|
||||
topX: 1000,
|
||||
mode: "single",
|
||||
singleColor: "#f5a97f",
|
||||
bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
|
||||
};
|
||||
|
||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.floor(value));
|
||||
}
|
||||
|
||||
function sanitizeFrequencyBandedColors(
|
||||
value: unknown,
|
||||
fallback: FrequencyRenderSettings["bandedColors"],
|
||||
): FrequencyRenderSettings["bandedColors"] {
|
||||
if (!Array.isArray(value) || value.length !== 5) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return [
|
||||
sanitizeHexColor(value[0], fallback[0]),
|
||||
sanitizeHexColor(value[1], fallback[1]),
|
||||
sanitizeHexColor(value[2], fallback[2]),
|
||||
sanitizeHexColor(value[3], fallback[3]),
|
||||
sanitizeHexColor(value[4], fallback[4]),
|
||||
];
|
||||
}
|
||||
|
||||
function getFrequencyDictionaryClass(
|
||||
token: MergedToken,
|
||||
settings: FrequencyRenderSettings,
|
||||
): string {
|
||||
if (!settings.enabled) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof token.frequencyRank !== "number" || !Number.isFinite(token.frequencyRank)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rank = Math.max(1, Math.floor(token.frequencyRank));
|
||||
const topX = sanitizeFrequencyTopX(settings.topX, DEFAULT_FREQUENCY_RENDER_SETTINGS.topX);
|
||||
if (rank > topX) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (settings.mode === "banded") {
|
||||
const normalizedBand = Math.ceil((rank / topX) * 5);
|
||||
const band = Math.min(5, Math.max(1, normalizedBand));
|
||||
return `word-frequency-band-${band}`;
|
||||
}
|
||||
|
||||
return "word-frequency-single";
|
||||
}
|
||||
|
||||
function renderWithTokens(
|
||||
root: HTMLElement,
|
||||
tokens: MergedToken[],
|
||||
frequencyRenderSettings?: Partial<FrequencyRenderSettings>,
|
||||
): void {
|
||||
const resolvedFrequencyRenderSettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencyRenderSettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencyRenderSettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(
|
||||
frequencyRenderSettings?.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencyRenderSettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const token of tokens) {
|
||||
@@ -35,7 +123,10 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
if (parts[i]) {
|
||||
const span = document.createElement("span");
|
||||
span.className = computeWordClass(token);
|
||||
span.className = computeWordClass(
|
||||
token,
|
||||
resolvedFrequencyRenderSettings,
|
||||
);
|
||||
span.textContent = parts[i];
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -49,7 +140,7 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.className = computeWordClass(token);
|
||||
span.className = computeWordClass(token, resolvedFrequencyRenderSettings);
|
||||
span.textContent = surface;
|
||||
if (token.reading) span.dataset.reading = token.reading;
|
||||
if (token.headword) span.dataset.headword = token.headword;
|
||||
@@ -59,7 +150,27 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
|
||||
root.appendChild(fragment);
|
||||
}
|
||||
|
||||
export function computeWordClass(token: MergedToken): string {
|
||||
export function computeWordClass(
|
||||
token: MergedToken,
|
||||
frequencySettings?: Partial<FrequencyRenderSettings>,
|
||||
): string {
|
||||
const resolvedFrequencySettings = {
|
||||
...DEFAULT_FREQUENCY_RENDER_SETTINGS,
|
||||
...frequencySettings,
|
||||
bandedColors: sanitizeFrequencyBandedColors(
|
||||
frequencySettings?.bandedColors,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.bandedColors,
|
||||
),
|
||||
topX: sanitizeFrequencyTopX(
|
||||
frequencySettings?.topX,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.topX,
|
||||
),
|
||||
singleColor: sanitizeHexColor(
|
||||
frequencySettings?.singleColor,
|
||||
DEFAULT_FREQUENCY_RENDER_SETTINGS.singleColor,
|
||||
),
|
||||
};
|
||||
|
||||
const classes = ["word"];
|
||||
|
||||
if (token.isNPlusOneTarget) {
|
||||
@@ -72,6 +183,14 @@ export function computeWordClass(token: MergedToken): string {
|
||||
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
|
||||
}
|
||||
|
||||
const frequencyClass = getFrequencyDictionaryClass(
|
||||
token,
|
||||
resolvedFrequencySettings,
|
||||
);
|
||||
if (frequencyClass) {
|
||||
classes.push(frequencyClass);
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
@@ -139,12 +258,32 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
|
||||
const normalized = normalizeSubtitle(text);
|
||||
if (tokens && tokens.length > 0) {
|
||||
renderWithTokens(ctx.dom.subtitleRoot, tokens);
|
||||
renderWithTokens(
|
||||
ctx.dom.subtitleRoot,
|
||||
tokens,
|
||||
getFrequencyRenderSettings(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderCharacterLevel(ctx.dom.subtitleRoot, normalized);
|
||||
}
|
||||
|
||||
function getFrequencyRenderSettings(): Partial<FrequencyRenderSettings> {
|
||||
return {
|
||||
enabled: ctx.state.frequencyDictionaryEnabled,
|
||||
topX: ctx.state.frequencyDictionaryTopX,
|
||||
mode: ctx.state.frequencyDictionaryMode,
|
||||
singleColor: ctx.state.frequencyDictionarySingleColor,
|
||||
bandedColors: [
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] as [string, string, string, string, string],
|
||||
};
|
||||
}
|
||||
|
||||
function renderSecondarySub(text: string): void {
|
||||
ctx.dom.secondarySubRoot.innerHTML = "";
|
||||
if (!text) return;
|
||||
@@ -236,6 +375,66 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n3-color", jlptColors.N3);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n4-color", jlptColors.N4);
|
||||
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n5-color", jlptColors.N5);
|
||||
const frequencyDictionarySettings = style.frequencyDictionary ?? {};
|
||||
const frequencyEnabled =
|
||||
frequencyDictionarySettings.enabled ?? ctx.state.frequencyDictionaryEnabled;
|
||||
const frequencyTopX = sanitizeFrequencyTopX(
|
||||
frequencyDictionarySettings.topX,
|
||||
ctx.state.frequencyDictionaryTopX,
|
||||
);
|
||||
const frequencyMode = frequencyDictionarySettings.mode
|
||||
? frequencyDictionarySettings.mode
|
||||
: ctx.state.frequencyDictionaryMode;
|
||||
const frequencySingleColor = sanitizeHexColor(
|
||||
frequencyDictionarySettings.singleColor,
|
||||
ctx.state.frequencyDictionarySingleColor,
|
||||
);
|
||||
const frequencyBandedColors = sanitizeFrequencyBandedColors(
|
||||
frequencyDictionarySettings.bandedColors,
|
||||
[
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] as [string, string, string, string, string],
|
||||
);
|
||||
|
||||
ctx.state.frequencyDictionaryEnabled = frequencyEnabled;
|
||||
ctx.state.frequencyDictionaryTopX = frequencyTopX;
|
||||
ctx.state.frequencyDictionaryMode = frequencyMode;
|
||||
ctx.state.frequencyDictionarySingleColor = frequencySingleColor;
|
||||
[
|
||||
ctx.state.frequencyDictionaryBand1Color,
|
||||
ctx.state.frequencyDictionaryBand2Color,
|
||||
ctx.state.frequencyDictionaryBand3Color,
|
||||
ctx.state.frequencyDictionaryBand4Color,
|
||||
ctx.state.frequencyDictionaryBand5Color,
|
||||
] = frequencyBandedColors;
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-single-color",
|
||||
frequencySingleColor,
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-1-color",
|
||||
frequencyBandedColors[0],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-2-color",
|
||||
frequencyBandedColors[1],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-3-color",
|
||||
frequencyBandedColors[2],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-4-color",
|
||||
frequencyBandedColors[3],
|
||||
);
|
||||
ctx.dom.subtitleRoot.style.setProperty(
|
||||
"--subtitle-frequency-band-5-color",
|
||||
frequencyBandedColors[4],
|
||||
);
|
||||
|
||||
const secondaryStyle = style.secondary;
|
||||
if (!secondaryStyle) return;
|
||||
|
||||
Reference in New Issue
Block a user