fix(subtitles): widen halfwidth katakana where subtitle text enters the app (#295)

* fix(subtitles): widen halfwidth katakana where subtitle text enters the app

Broadcast-derived Japanese subs write katakana halfwidth (アンタ, ダキニ), so
overlay lines mixed squashed and normal-width text. Normalize live mpv
sub-text and parsed cue text to fullwidth so display, lookups, mining,
and stats all see one form. The tokenizer reuses the shared helper.

* fix(subtitles): fold stray voiced marks and widen before cue dedup

- A lone halfwidth ゙ after fullwidth kana (ガ) now composes to ガ
  instead of leaving a combining mark that broke name matching.
- Parsed cue text is widened before duplicate merging so ガク and ガク
  on the same span collapse into one sidebar row.
- The YouTube track-load refresh widens the sub-text it reads directly.

* fix(stats): widen halfwidth katakana in stored lines and mined cards (#296)

Lines recorded before ingest widening still hold halfwidth katakana.
Occurrence and sentence-search queries widen line text on read, and the
mine-card route widens the sentence and word it receives, so the
dashboard and cards mined from history match newer rows. Word identity
columns are left as stored since the dashboard queries by them.

* fix(stats): match katakana in either width in sentence search

- Add narrowFullwidthKana, derived from widenHalfwidthKana so the two stay in sync
- Match subtitle text against the original, widened, and narrowed forms of each term so a search finds older lines stored as halfwidth
This commit is contained in:
sudacode authored and GitHub committed 2026-10-13 22:26:01 -07:00
1 parent bf4e5bb609
commit 01613f1be5
16 files changed
+285 -26

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Subtitle lines recorded before halfwidth katakana widening now show fullwidth katakana in the stats dashboard (word/kanji occurrences, sentence search), and cards mined from stats history get the fullwidth sentence and word.
- Sentence search matches katakana in either width, so searching アンタ also finds older lines stored as アンタ.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Halfwidth katakana in subtitles (common in broadcast-derived Japanese subs, e.g. アンタ, ダキニ) now displays as normal fullwidth katakana, so lines no longer mix squashed and normal-width text. Mined sentences and stats get the fullwidth form too.
@@ -1934,6 +1934,27 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card widens halfwidth katakana from older stats lines', async () => {
await withMiningEnv({}, async (env) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
ankiConnectConfig: ankiConfig(env, { fields: { sentence: 'Sentence' } }),
});
const { status, body } = await mineCard(app, 'word', {
sourcePath: env.sourcePath,
sentence: 'アンタがアータルたん?',
word: 'アンタ',
});
assert.equal(status, 200, JSON.stringify(body));
assert.equal(
sentNote(env.requests, 'updateNoteFields').fields.Sentence,
'<b>アンタ</b>がアータルたん?',
);
});
});
it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
await withMiningEnv({}, async (env) => {
const app = createStatsApp(createMockTracker(), {
@@ -3936,6 +3936,83 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
}
});
test('getWordOccurrences widens halfwidth katakana in lines recorded before ingest widening', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
canonicalTitle: 'Episode 8',
sourcePath: '/tmp/KamiKatsu S01E08.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
const wordResult = db
.prepare(
`INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run('アンタ', 'アンタ', 'あんた', 'noun', '名詞', '代名詞', '', 1_000, 1_000, 1);
const lineResult = db
.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
db.prepare(
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
VALUES (?, ?, ?)`,
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
const rows = getWordOccurrences(db, 'アンタ', 'アンタ', 'あんた', 10);
assert.deepEqual(
rows.map((row) => row.text),
['アンタがアータルたん?'],
);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('searchSubtitleSentences matches halfwidth and fullwidth katakana in either direction', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
canonicalTitle: 'Episode 8',
sourcePath: '/tmp/KamiKatsu S01E08.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
const insertLine = db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
insertLine.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
insertLine.run(sessionId, null, videoId, null, 2, 1000, 2000, 'アンタ、ダキニ', 2_000, 2_000);
for (const query of ['アンタ', 'アンタ']) {
assert.deepEqual(
searchSubtitleSentences(db, query, 10).map((row) => row.text),
['アンタ、ダキニ', 'アンタがアータルたん?'],
);
}
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
@@ -18,6 +18,7 @@ import type {
} from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time';
import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
@@ -382,6 +383,13 @@ export function getKanjiStats(db: DatabaseSync, limit = 100): KanjiStatsRow[] {
return stmt.all(limit) as KanjiStatsRow[];
}
// Lines recorded before subtitle text was widened at ingest can still hold halfwidth
// katakana; widen them on the way out so the dashboard and cards mined from it match
// newer rows. Display-only: word identity columns stay as stored since they are keys.
function widenLineText<T extends { text: string }>(row: T): T {
return { ...row, text: widenHalfwidthKana(row.text) };
}
export function getWordOccurrences(
db: DatabaseSync,
headword: string,
@@ -390,7 +398,7 @@ export function getWordOccurrences(
limit = 100,
offset = 0,
): WordOccurrenceRow[] {
return db
const rows = db
.prepare(
`
SELECT
@@ -418,6 +426,7 @@ export function getWordOccurrences(
`,
)
.all(headword, word, reading, limit, offset) as unknown as WordOccurrenceRow[];
return rows.map(widenLineText);
}
export function getKanjiOccurrences(
@@ -426,7 +435,7 @@ export function getKanjiOccurrences(
limit = 100,
offset = 0,
): KanjiOccurrenceRow[] {
return db
const rows = db
.prepare(
`
SELECT
@@ -454,6 +463,7 @@ export function getKanjiOccurrences(
`,
)
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
return rows.map(widenLineText);
}
export function searchSubtitleSentences(
@@ -470,6 +480,14 @@ export function searchSubtitleSentences(
const params: string[] = [];
for (const term of terms) {
const likeTerm = `%${escapeLikeTerm(term)}%`;
// Lines recorded before ingest widening can hold halfwidth katakana, so subtitle
// text is matched against the term's widened and narrowed forms as well.
const widenedTerm = widenHalfwidthKana(term);
const textLikeTerms = uniqueNonEmptyTerms([
term,
widenedTerm,
narrowFullwidthKana(widenedTerm),
]).map((variant) => `%${escapeLikeTerm(variant)}%`);
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
const headwordClause =
headwords.length > 0
@@ -485,16 +503,16 @@ export function searchSubtitleSentences(
: '';
clauses.push(`
(
l.text LIKE ? ESCAPE '\\'
${textLikeTerms.map(() => "l.text LIKE ? ESCAPE '\\'").join(' OR ')}
OR v.canonical_title LIKE ? ESCAPE '\\'
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
${headwordClause}
)
`);
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
params.push(...textLikeTerms, likeTerm, likeTerm, ...headwords);
}
return db
const rows = db
.prepare(
`
SELECT
@@ -518,6 +536,7 @@ export function searchSubtitleSentences(
`,
)
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
return rows.map(widenLineText);
}
export function getSessionEvents(
+12
View File
@@ -131,6 +131,18 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-text', data: 'アンタがアータルたん?' },
deps,
);
assert.equal(state.subText, 'アンタがアータルたん?');
assert.deepEqual(state.events, [{ text: 'アンタがアータルたん?', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload),
+3 -2
View File
@@ -1,5 +1,6 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
export type MpvMessage = {
event?: string;
@@ -244,7 +245,7 @@ export async function dispatchMpvProtocolMessage(
): Promise<void> {
if (msg.event === 'property-change') {
if (msg.name === 'sub-text') {
const nextSubText = (msg.data as string) || '';
const nextSubText = widenHalfwidthKana((msg.data as string) || '');
const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({
text: nextSubText,
@@ -458,7 +459,7 @@ export async function dispatchMpvProtocolMessage(
}>,
);
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
const nextSubText = (msg.data as string) || '';
const nextSubText = widenHalfwidthKana((msg.data as string) || '');
deps.setCurrentSubText(nextSubText);
deps.emitSubtitleChange({
text: nextSubText,
@@ -7,6 +7,7 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import { widenHalfwidthKana } from '../../utils/halfwidth-kana.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
@@ -35,8 +36,11 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
const word = typeof body?.word === 'string' ? body.word.trim() : '';
// Older stats rows can carry halfwidth katakana; cards get the fullwidth form that
// live mining produces, and the word is widened alongside so highlighting still hits.
const sentence =
typeof body?.sentence === 'string' ? widenHalfwidthKana(body.sentence.trim()) : '';
const word = typeof body?.word === 'string' ? widenHalfwidthKana(body.word.trim()) : '';
const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
@@ -55,6 +55,36 @@ test('parseSrtCues strips HTML-like markup while preserving line breaks', () =>
assert.equal(cues[0]!.text, 'これは\nテストです');
});
test('parseSubtitleCues widens halfwidth katakana in cue text', () => {
const cues = parseSubtitleCues(
'1\n00:00:01,000 --> 00:00:02,000\n(トーカ)ダキニお姉ちゃん\n',
'ep.srt',
);
assert.equal(cues[0]?.text, '(トーカ)ダキニお姉ちゃん');
});
test('parseSubtitleCues merges cues that differ only in halfwidth katakana', () => {
const cues = parseSubtitleCues(
[
'1',
'00:00:01,000 --> 00:00:02,000',
'山ガク',
'',
'2',
'00:00:01,000 --> 00:00:02,000',
'山ガク',
'',
].join('\n'),
'ep.srt',
);
assert.deepEqual(
cues.map((cue) => cue.text),
['山ガク'],
);
});
test('parseSrtCues handles hours in timestamps', () => {
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
+9 -2
View File
@@ -9,6 +9,7 @@ import {
type AssOverrideCommand,
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
/** Vertical third of the screen a cue is authored to occupy. */
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
@@ -104,7 +105,7 @@ function decodeSubtitleCueText(text: string): string {
}
function sanitizeSubtitleCueText(text: string): string {
return decodeSubtitleCueText(text).trim();
return widenHalfwidthKana(decodeSubtitleCueText(text)).trim();
}
function sanitizeAssCueText(text: string): string {
@@ -2655,10 +2656,16 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
placement.playResY,
);
return mergeAssCaptionRows(
const cues = mergeAssCaptionRows(
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
placement.playResY,
);
// Widened after reconstruction, which works glyph by glyph, but before duplicate
// merging, so ガク and ガク authored for the same span collapse into one cue.
for (const cue of cues) {
cue.text = widenHalfwidthKana(cue.text);
}
return cues;
}
export function parseAssCues(content: string): SubtitleCue[] {
@@ -1,23 +1,12 @@
import { MergedToken, PartOfSpeech } from '../../../types';
import { widenHalfwidthKana } from '../../utils/halfwidth-kana';
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
// No `u` flag: the range is entirely BMP so it changes nothing here, and
// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
const HALFWIDTH_KANA_RUN = /[\uff66-\uff9f]+/g;
// NFKC over the halfwidth kana only, never the whole string: it composes the
// voiced pairs (カ + ゙) into single characters so ガク compares equal to ガク
// instead of counting one character longer than the word it spells, but run
// over everything it would also rewrite unrelated text (① → 1, ㍑ → リットル).
function composeHalfwidthKana(text: string): string {
return text.replace(HALFWIDTH_KANA_RUN, (run) => run.normalize('NFKC'));
}
export function normalizeKana(text: string): string {
const raw = composeHalfwidthKana(text).trim();
const raw = widenHalfwidthKana(text).trim();
if (!raw) {
return '';
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { narrowFullwidthKana, widenHalfwidthKana } from './halfwidth-kana';
test('widenHalfwidthKana widens halfwidth katakana and composes voiced marks', () => {
assert.equal(
widenHalfwidthKana('(トーカ)ダキニお姉ちゃん アンタがアータルたん。'),
'(トーカ)ダキニお姉ちゃん アンタがアータルたん。',
);
});
test('widenHalfwidthKana leaves other compatibility characters alone', () => {
assert.equal(widenHalfwidthKana('見て見てっ!①㍑AB'), '見て見てっ!①㍑AB');
});
test('widenHalfwidthKana folds a lone halfwidth voiced mark into the kana before it', () => {
assert.equal(widenHalfwidthKana('山ガク パン'), '山ガク パン');
assert.equal(widenHalfwidthKana('ア゙'), 'ア\u3099');
});
test('narrowFullwidthKana inverts widening and leaves other text alone', () => {
assert.equal(narrowFullwidthKana('ダキニお姉ちゃん アンタ。ヴ'), 'ダキニお姉ちゃん アンタ。ヴ');
assert.equal(narrowFullwidthKana('漢字ひらがな!'), '漢字ひらがな!');
});
+49
View File
@@ -0,0 +1,49 @@
// No `u` flag: the range is entirely BMP so it changes nothing here, and
// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
// The optional leading character is captured so a run opening with a voiced
// mark (ガ) can fold the mark into the fullwidth kana before it.
const HALFWIDTH_KANA_RUN = /([^。-゚]?)([。-゚]+)/g;
const LEADING_COMBINING_VOICED_MARK = /^[゙゚]/;
/**
* Rewrites halfwidth katakana and its punctuation (アンタ, ダキニ, 。「」、・) to the fullwidth
* forms. Broadcast-derived subtitles write katakana halfwidth to save space; subtitle
* text is normalized with this where it enters the app (live mpv text, parsed cues) so
* display, lookups, mining, and stats all see one form.
*
* NFKC runs over the halfwidth runs only, never the whole string: it composes the voiced
* pairs (カ + ゙) into single characters, but over everything it would also rewrite
* unrelated text (① → 1, ! → !, ㍑ → リットル).
*/
export function widenHalfwidthKana(text: string): string {
return text.replace(HALFWIDTH_KANA_RUN, (_match, previous: string, run: string) => {
const widened = run.normalize('NFKC');
if (!previous || !LEADING_COMBINING_VOICED_MARK.test(widened)) {
return previous + widened;
}
// A lone ゙ widens to a combining mark; NFC composes it with the kana before it
// (カ + ゛ → ガ) and leaves pairs that have no composed form untouched.
return (previous + widened[0]).normalize('NFC') + widened.slice(1);
});
}
// Inverse of widenHalfwidthKana per character, derived from it so the two stay in sync.
// Voiced pairs (ガ) are included because they widen to a single character (ガ).
const HALFWIDTH_BY_FULLWIDTH = new Map<string, string>();
for (let code = 0xff61; code <= 0xff9f; code += 1) {
const halfwidth = String.fromCharCode(code);
for (const form of [halfwidth, `${halfwidth}゙`, `${halfwidth}゚`]) {
const widened = widenHalfwidthKana(form);
if (widened.length === 1 && !HALFWIDTH_BY_FULLWIDTH.has(widened)) {
HALFWIDTH_BY_FULLWIDTH.set(widened, form);
}
}
}
/**
* Rewrites fullwidth katakana and punctuation to halfwidth forms (アンタ → アンタ). Used to
* search text stored before subtitle ingest widened it; display paths should widen instead.
*/
export function narrowFullwidthKana(text: string): string {
return Array.from(text, (char) => HALFWIDTH_BY_FULLWIDTH.get(char) ?? char).join('');
}
+5 -1
View File
@@ -3,6 +3,7 @@ import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
@@ -26,11 +27,14 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool
);
}
// Live text also arrives from direct `sub-text` reads that bypass the mpv protocol
// handler, so it is widened here too to match the parsed cues.
function decodedLiveText(
liveText: string,
cues: readonly SubtitleCue[] | null | undefined,
): string {
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
const text = widenHalfwidthKana(liveText);
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(text) : text;
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
+12
View File
@@ -256,6 +256,18 @@ test('youtube flow announces manual picker opening before probing tracks', async
assertNoProblems(harness);
});
test('youtube flow widens halfwidth katakana in the refreshed subtitle', async () => {
const harness = createFlowHarness({
mpv: { subText: 'アンタがアータルたん?' },
probeTracks: [primaryTrack],
pick: { primaryTrackId: primaryTrack.id, secondaryTrackId: null },
});
await harness.runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(harness.refreshedSubtitles, ['アンタがアータルたん?']);
});
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const harness = createFlowHarness({
probeTracks: [primaryTrack, secondaryTrack],
+2 -1
View File
@@ -21,6 +21,7 @@ import {
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
import { extractYoutubeVideoId } from './youtube-playback';
import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
@@ -457,7 +458,7 @@ async function injectDownloadedSubtitles(
const currentSubText = await deps.requestMpvProperty('sub-text').catch(() => null);
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
deps.refreshCurrentSubtitle(currentSubText);
deps.refreshCurrentSubtitle(widenHalfwidthKana(currentSubText));
}
deps.showMpvOsd(secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.');