fix(subtitles): recover positioned ASS word spacing and drop control deb

- Recover Latin/romaji word spaces encoded only by positioned `\pos`/`\move` fragment gaps in ASS karaoke
- Drop malformed ASS rotation-reset spacer lines and skip zero-duration metadata events
- Dedupe duplicate lines across multiline primary/secondary cues, including full-width variants
- Treat dense fragment-grid sign layouts as visual typesetting, not publishable text, during primary subtitle resolution
This commit is contained in:
2026-08-23 16:34:36 -07:00
parent ed7d3f4c3d
commit c87dcd6239
12 changed files with 576 additions and 40 deletions
+9
View File
@@ -10,6 +10,7 @@ import {
isAssTemporalCommand,
normalizePlainSubtitleText,
parseAssEffectField,
removeAssControlDebrisLines,
} from './ass-text';
test('assToPlainText drops vector drawing runs', () => {
@@ -74,6 +75,14 @@ test('assToPlainText normalizes CRLF before converting', () => {
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
});
test('removeAssControlDebrisLines drops malformed spacer resets without eating dialogue', () => {
assert.equal(
removeAssControlDebrisLines('Visible line\n\\\n{\\fr0\n\\{\\frz287.5'),
'Visible line',
);
assert.equal(removeAssControlDebrisLines('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
});
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
// A brace reaching this layer is literal text mpv chose to show, not markup.
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
+17
View File
@@ -91,6 +91,23 @@ export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): st
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
}
const MALFORMED_ASS_ROTATION_RESET = /^\\?\{\\(?:fr|frx|fry|frz|fax|fay)[-+.0-9]*$/u;
/**
* Drop non-rendering spacer events left as literal text by a malformed, unclosed ASS
* rotation reset. These events otherwise become repeated `\\` or `{\\fr0` subtitle
* lines after mpv-compatible decoding.
*/
export function removeAssControlDebrisLines(text: string): string {
return text
.split('\n')
.filter((line) => {
const compact = line.replace(/\s+/gu, '');
return compact !== '\\' && !MALFORMED_ASS_ROTATION_RESET.test(compact);
})
.join('\n');
}
export interface NormalizePlainSubtitleTextOptions {
/** Fold every line break into a single space. */
collapseLineBreaks?: boolean;
@@ -35,6 +35,12 @@ test('parseSrtCues handles multi-line subtitle text', () => {
assert.equal(cues[0]!.text, 'これは\nテストです');
});
test('parseSrtCues preserves lines that only resemble malformed ASS controls', () => {
const content = ['1', '00:01:00,000 --> 00:01:05,000', '\\', '{\\fr0', ''].join('\n');
assert.equal(parseSrtCues(content)[0]?.text, '\\\n{\\fr0');
});
test('parseSrtCues strips HTML-like markup while preserving line breaks', () => {
const content = [
'1',
@@ -1021,3 +1027,215 @@ test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'URLテスト');
});
test('parseSubtitleCues skips zero-duration ASS metadata events', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,[Script Info]',
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Real subtitle',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'Real subtitle' },
]);
});
test('parseSubtitleCues drops malformed ASS spacer reset debris', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Background,,0,0,0,,{\\pos(10,10)}\\h\\h\\h\\{\\fr0',
'Dialogue: 1,0:00:01.00,0:00:02.00,Default,,0,0,0,,Visible line',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'Visible line' },
]);
});
test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph gaps', () => {
const glyphs = [
['T', 100],
['h', 118],
['e', 136],
['s', 164],
['t', 178],
['a', 194],
['r', 210],
['s', 227],
['I', 255],
['s', 275],
['e', 293],
['e', 311],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
glyphs.map(
([glyph, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'The stars I see');
});
test('parseSubtitleCues adds a missing word space after positioned punctuation', () => {
const fragments = [
['H', 100],
['i,', 119],
['t', 153],
['h', 168],
['e', 186],
['r', 202],
['e', 216],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Hi, there');
});
test('parseSubtitleCues does not split a positioned thousands separator', () => {
const fragments = [
['1,', 100],
['000', 145],
['0', 185],
['0', 205],
['0', 225],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, '1,000000');
});
test('parseSubtitleCues does not split a wide glyph from its punctuated suffix', () => {
const fragments = [
['v', 904],
['o', 924],
['i', 939],
['c', 955],
['e', 976],
['r', 1004],
['e', 1021],
['a', 1042],
['c', 1063],
['h', 1083],
['e', 1104],
['d', 1125],
['m', 1161],
['e,', 1193],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'voice reached me,');
});
test('parseSubtitleCues spaces positioned lyric fragments across authored rows', () => {
const fragments = [
['My', 472, 39],
['song!', 543, 39],
['My', 507, 78],
['song!', 578, 78],
['ku', 643, 39],
['chi', 683, 39],
['zu', 722, 39],
['sa', 757, 39],
['n', 783, 39],
['de', 811, 39],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x, y], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'My song! My song! kuchizusande');
});
test('parseSubtitleCues recovers positioned word gaps between romaji fragments', () => {
const fragments = [
['sa', 380],
['ga', 421],
['shi', 467],
['te', 510],
['ta', 545],
['ha', 593],
['ji', 624],
['ke', 655],
['ta', 693],
['i', 726],
['ro', 749],
['no', 798],
['yu', 849],
['me', 895],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'sagashiteta hajiketa iro no yume');
});
test('parseSubtitleCues recovers clear word gaps in a short romaji line', () => {
const fragments = [
['bo', 542],
['ku', 584],
['wo', 640],
['yo', 697],
['bu', 738],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'boku wo yobu');
});
+126 -6
View File
@@ -3,6 +3,7 @@ import {
assToPlainText,
collectAssOverrideCommands,
parseAssEffectField,
removeAssControlDebrisLines,
type AssEffectKind,
type AssOverrideCommand,
} from './ass-text';
@@ -91,6 +92,10 @@ function sanitizeSubtitleCueText(text: string): string {
return decodeSubtitleCueText(text).trim();
}
function sanitizeAssCueText(text: string): string {
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
}
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
if (assLayout) {
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
@@ -387,6 +392,124 @@ interface AssFragmentPart {
text: string;
}
interface AssFragmentPosition {
x: number;
y: number;
}
const MIN_LATIN_POSITION_GAP_SAMPLES = 4;
const LATIN_WORD_GAP_RATIO = 1.2;
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
for (const command of cue.overrides) {
if (command.animated) continue;
const name = command.name.toLowerCase();
const args = command.args.split(',').map((value) => Number(value.trim()));
if (
name === 'pos' &&
args.length >= 2 &&
Number.isFinite(args[0]) &&
Number.isFinite(args[1])
) {
return { x: args[0]!, y: args[1]! };
}
if (
name === 'move' &&
args.length >= 4 &&
args.slice(0, 4).every((value) => Number.isFinite(value))
) {
return { x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 };
}
}
return null;
}
function latinGlyphWidthWeight(glyph: string): number {
if (/[ilIjtfr]/u.test(glyph)) return 0.6;
if (/[mwMW]/u.test(glyph)) return 1.4;
if (/[A-Z]/u.test(glyph)) return 1.1;
return 1;
}
function latinFragmentWidthWeight(text: string): number | null {
if (!/^[A-Za-z0-9'.,!?;:-]+$/u.test(text)) return null;
const punctuationWeight = /^[A-Za-z0-9]['.,!?;:-]$/u.test(text) ? 0.5 : 0.25;
return [...text].reduce(
(width, glyph) =>
width + (/['.,!?;:-]/u.test(glyph) ? punctuationWeight : latinGlyphWidthWeight(glyph)),
0,
);
}
function normalizedLatinFragmentGap(
previous: AssFragmentPart,
current: AssFragmentPart,
): number | null {
const previousWeight = latinFragmentWidthWeight(previous.text);
const currentWeight = latinFragmentWidthWeight(current.text);
const previousPosition = fragmentPosition(previous.cue);
const currentPosition = fragmentPosition(current.cue);
if (previousWeight === null || currentWeight === null || !previousPosition || !currentPosition) {
return null;
}
const xDistance = currentPosition.x - previousPosition.x;
const yDistance = Math.abs(currentPosition.y - previousPosition.y);
if (yDistance <= 2 && xDistance <= 0) return null;
// A wrapped authored line can return to the left on its next visual row. Preserve
// that measured row transition as a separator without treating backwards movement
// on the same row as a word gap.
const positionDistance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance;
return positionDistance / ((previousWeight + currentWeight) / 2);
}
function commonLatinFragmentGap(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right);
// Romaji lines contain many short particles, so real word gaps can outnumber
// within-word transitions. A lower quantile still represents ordinary glyph advance
// while ignoring the narrowest character pair as an outlier.
return sorted[Math.floor((sorted.length - 1) * 0.35)]!;
}
/**
* Character-by-character typesetting often omits literal spaces because the authored
* word gap exists only in each glyph's `\pos`. Estimate the normal adjacent-glyph
* advance within that one line, then preserve only materially larger horizontal gaps.
* Normalizing each gap by the neighboring fragment widths supports both single glyphs
* and multi-character karaoke syllables without guessing from the text itself.
*/
function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
if (parts.some((part) => /\s/u.test(part.text))) {
return parts
.map((part) => part.text)
.join('')
.trim();
}
const normalizedGaps: number[] = [];
for (let index = 1; index < parts.length; index += 1) {
const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!);
if (gap !== null) normalizedGaps.push(gap);
}
const wordGapThreshold =
normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES
? commonLatinFragmentGap(normalizedGaps) * LATIN_WORD_GAP_RATIO
: Infinity;
let text = parts[0]?.text ?? '';
for (let index = 1; index < parts.length; index += 1) {
const previous = parts[index - 1]!;
const current = parts[index]!;
const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text);
const normalizedGap = normalizedLatinFragmentGap(previous, current);
const hasPositionedWordGap = normalizedGap !== null && normalizedGap > wordGapThreshold;
if (!hasAuthoredSpace && hasPositionedWordGap) {
text += ' ';
}
text += current.text;
}
return text.trim();
}
function reconstructedAssFragmentLayout(
parts: readonly AssFragmentPart[],
owner: AnnotatedSubtitleCue,
@@ -522,10 +645,7 @@ function reconstructAssFragmentLine(
return null;
}
const text = parts
.map((part) => part.text)
.join('')
.trim();
const text = joinAssFragmentParts(parts);
if (!text) {
return null;
}
@@ -911,12 +1031,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
if (startTime === null || endTime === null) {
if (startTime === null || endTime === null || endTime <= startTime) {
continue;
}
const rawText = fields.slice(fieldIndex.text).join(',');
const text = sanitizeSubtitleCueText(rawText);
const text = sanitizeAssCueText(rawText);
if (!text) {
continue;
}