Add opt-in JLPT tagging flow

This commit is contained in:
2026-02-15 16:28:00 -08:00
parent ca2b7bb2fe
commit f492622a8b
27 changed files with 1116 additions and 38 deletions

View File

@@ -71,6 +71,11 @@ export type RendererState = {
knownWordColor: string;
nPlusOneColor: string;
jlptN1Color: string;
jlptN2Color: string;
jlptN3Color: string;
jlptN4Color: string;
jlptN5Color: string;
keybindingsMap: Map<string, (string | number)[]>;
chordPending: boolean;
@@ -130,6 +135,11 @@ export function createRendererState(): RendererState {
knownWordColor: "#a6da95",
nPlusOneColor: "#c6a0f6",
jlptN1Color: "#ed8796",
jlptN2Color: "#f5a97f",
jlptN3Color: "#f9e2af",
jlptN4Color: "#a6e3a1",
jlptN5Color: "#8aadf4",
keybindingsMap: new Map(),
chordPending: false,

View File

@@ -250,6 +250,11 @@ body {
color: #cad3f5;
--subtitle-known-word-color: #a6da95;
--subtitle-n-plus-one-color: #c6a0f6;
--subtitle-jlpt-n1-color: #ed8796;
--subtitle-jlpt-n2-color: #f5a97f;
--subtitle-jlpt-n3-color: #f9e2af;
--subtitle-jlpt-n4-color: #a6e3a1;
--subtitle-jlpt-n5-color: #8aadf4;
text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8),
-1px -1px 2px rgba(0, 0, 0, 0.5);
@@ -296,6 +301,51 @@ body.settings-modal-open #subtitleContainer {
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
}
#subtitleRoot .word.word-jlpt-n1 {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796);
text-decoration-style: solid;
}
#subtitleRoot .word.word-jlpt-n2 {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f);
text-decoration-style: solid;
}
#subtitleRoot .word.word-jlpt-n3 {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af);
text-decoration-style: solid;
}
#subtitleRoot .word.word-jlpt-n4 {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
text-decoration-style: solid;
}
#subtitleRoot .word.word-jlpt-n5 {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4);
text-decoration-style: solid;
}
#subtitleRoot .word:hover {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;

View File

@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import type { MergedToken } from "../types";
import { PartOfSpeech } from "../types.js";
import { computeWordClass } from "./subtitle-render.js";
function createToken(overrides: Partial<MergedToken>): MergedToken {
return {
surface: "",
reading: "",
headword: "",
startPos: 0,
endPos: 0,
partOfSpeech: PartOfSpeech.other,
isMerged: true,
isKnown: false,
isNPlusOneTarget: false,
...overrides,
};
}
function extractClassBlock(cssText: string, level: number): string {
const selector = `#subtitleRoot .word.word-jlpt-n${level}`;
const start = cssText.indexOf(selector);
if (start < 0) return "";
const openBrace = cssText.indexOf("{", start);
if (openBrace < 0) return "";
const closeBrace = cssText.indexOf("}", openBrace);
if (closeBrace < 0) return "";
return cssText.slice(openBrace + 1, closeBrace);
}
test("computeWordClass preserves known and n+1 classes while adding JLPT classes", () => {
const knownJlpt = createToken({
isKnown: true,
jlptLevel: "N1",
surface: "猫",
});
const nPlusOneJlpt = createToken({
isNPlusOneTarget: true,
jlptLevel: "N2",
surface: "犬",
});
assert.equal(computeWordClass(knownJlpt), "word word-known word-jlpt-n1");
assert.equal(
computeWordClass(nPlusOneJlpt),
"word word-n-plus-one word-jlpt-n2",
);
});
test("JLPT CSS rules use underline-only styling in renderer stylesheet", () => {
const cssText = fs.readFileSync(
path.join(process.cwd(), "dist", "renderer", "style.css"),
"utf-8",
);
for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(cssText, 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*2px;/);
assert.match(block, /color:\s*inherit;/);
}
});

View File

@@ -15,6 +15,15 @@ function normalizeSubtitle(text: string, trim = true): string {
return trim ? normalized.trim() : normalized;
}
const HEX_COLOR_PATTERN =
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
function sanitizeHexColor(value: unknown, fallback: string): string {
return typeof value === "string" && HEX_COLOR_PATTERN.test(value.trim())
? value.trim()
: fallback;
}
function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
const fragment = document.createDocumentFragment();
@@ -50,16 +59,20 @@ function renderWithTokens(root: HTMLElement, tokens: MergedToken[]): void {
root.appendChild(fragment);
}
function computeWordClass(token: MergedToken): string {
export function computeWordClass(token: MergedToken): string {
const classes = ["word"];
if (token.isNPlusOneTarget) {
return "word word-n-plus-one";
classes.push("word-n-plus-one");
} else if (token.isKnown) {
classes.push("word-known");
}
if (token.isKnown) {
return "word word-known";
if (token.jlptLevel) {
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
}
return "word";
return classes.join(" ");
}
function renderCharacterLevel(root: HTMLElement, text: string): void {
@@ -189,6 +202,22 @@ export function createSubtitleRenderer(ctx: RendererContext) {
style.knownWordColor ?? ctx.state.knownWordColor ?? "#a6da95";
const nPlusOneColor =
style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? "#c6a0f6";
const jlptColors = {
N1: ctx.state.jlptN1Color ?? "#ed8796",
N2: ctx.state.jlptN2Color ?? "#f5a97f",
N3: ctx.state.jlptN3Color ?? "#f9e2af",
N4: ctx.state.jlptN4Color ?? "#a6e3a1",
N5: ctx.state.jlptN5Color ?? "#8aadf4",
...(style.jlptColors
? {
N1: sanitizeHexColor(style.jlptColors?.N1, ctx.state.jlptN1Color),
N2: sanitizeHexColor(style.jlptColors?.N2, ctx.state.jlptN2Color),
N3: sanitizeHexColor(style.jlptColors?.N3, ctx.state.jlptN3Color),
N4: sanitizeHexColor(style.jlptColors?.N4, ctx.state.jlptN4Color),
N5: sanitizeHexColor(style.jlptColors?.N5, ctx.state.jlptN5Color),
}
: {}),
};
ctx.state.knownWordColor = knownWordColor;
ctx.state.nPlusOneColor = nPlusOneColor;
@@ -197,6 +226,16 @@ export function createSubtitleRenderer(ctx: RendererContext) {
knownWordColor,
);
ctx.dom.subtitleRoot.style.setProperty("--subtitle-n-plus-one-color", nPlusOneColor);
ctx.state.jlptN1Color = jlptColors.N1;
ctx.state.jlptN2Color = jlptColors.N2;
ctx.state.jlptN3Color = jlptColors.N3;
ctx.state.jlptN4Color = jlptColors.N4;
ctx.state.jlptN5Color = jlptColors.N5;
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n1-color", jlptColors.N1);
ctx.dom.subtitleRoot.style.setProperty("--subtitle-jlpt-n2-color", jlptColors.N2);
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 secondaryStyle = style.secondary;
if (!secondaryStyle) return;