fix(subtitles): recover positioned word gaps in reconstructed translations

This commit is contained in:
2026-08-23 21:37:48 -07:00
parent 6d1a1b841a
commit 9044340676
3 changed files with 105 additions and 20 deletions
+79 -2
View File
@@ -1150,8 +1150,8 @@ test('parseSubtitleCues recovers a word gap measured across a wide glyph', () =>
const text = 'youcanhearthesoundofthewaveswithinmyheart';
const positions = [
202, 223, 244, 274, 296, 317, 346, 367, 389, 408, 433, 450, 470, 499, 517, 538, 557, 578, 609,
627, 651, 668, 688, 723, 748, 770, 792, 811, 843, 863, 875, 892, 907, 922, 957, 983, 1013,
1034, 1055, 1075, 1090,
627, 651, 668, 688, 723, 748, 770, 792, 811, 843, 863, 875, 892, 907, 922, 957, 983, 1013, 1034,
1055, 1075, 1090,
];
const content = [
'[Events]',
@@ -1596,3 +1596,80 @@ test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixel
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'menomaeninobiru');
});
test('parseSubtitleCues recovers positional word gaps beside an authored space', () => {
// Real ED line: every glyph is placed by `\move`, but the `star` fragment alone carries
// a literal leading space. The authored space must not disable positional recovery for
// the rest of the line.
const fragments = [
['s', 633],
['e', 665],
['a', 697],
['r', 723],
['c', 747],
['h', 774],
['i', 793],
['n', 813],
['g', 838],
['f', 884],
['o', 911],
['r', 937],
['a', 986],
['s', 1041],
['h', 1070],
['o', 1098],
['o', 1128],
['t', 1153],
['i', 1169],
['n', 1188],
['g', 1214],
[' s', 1264],
['t', 1290],
['a', 1316],
['r', 1342],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:22:44.83,0:22:47.70,ED English,,0,0,0,fx,{\\move(${x},1020,${x},1020,0,300)\\t(${index * 2},${index * 2 + 300},\\fs90)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'searching for a shooting star');
});
test('parseSubtitleCues splits chunked words whose gap only the excess rule catches', () => {
// `Choices|presumably` normalizes to just under the ratio threshold because both
// neighbors are wide three-letter chunks; its constant word-space excess still shows.
const fragments = [
['Ch', 526],
['oi', 584],
['ces', 648],
['pre', 747],
['su', 819],
['mab', 904],
['ly', 981],
['ma', 1059],
['de', 1128],
['by', 1203],
['cha', 1295],
['nce', 1385],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:01:53.01,0:01:55.52,OP English,,0,0,0,fx,{\\pos(${x},1055)\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`,
),
),
].join('\n');
assert.equal(
parseSubtitleCues(content, 'test.ass')[0]?.text,
'Choices presumably made by chance',
);
});
+25 -17
View File
@@ -440,6 +440,12 @@ const LATIN_GLYPH_WORD_GAP_RATIO = 1.4;
// line (`Swelling`) skews the unit low and its ordinary advances read as word gaps.
const LATIN_GLYPH_WORD_EXCESS_RATIO = 0.36;
const MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES = 10;
// Multi-character syllable chunks average out proportional-font variation, so their
// advances track the width model far more closely than single glyphs do. Measured on a
// chunked lyric line, within-word excess stayed under 0.07 of the common unit while every
// word gap cleared 0.31, so a tighter margin separates them without splitting words.
const LATIN_CHUNK_WORD_EXCESS_RATIO = 0.2;
const MIN_LATIN_CHUNK_EXCESS_GAP_SAMPLES = 6;
const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2;
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
@@ -581,19 +587,20 @@ function isLikelyTwoGlyphCapitalizedWord(options: {
* and multi-character karaoke syllables without guessing from the text itself. Per-glyph
* runs use a wider safety margin because proportional fonts vary more than syllable chunks.
*
* The ratio test alone under-detects a word gap next to a wide glyph (`waves within`
* measured across `s`/`w` normalizes to nearly a common advance), so per-glyph runs also
* treat a gap as a word boundary when its advance exceeds the width-predicted advance by
* a material fraction of the line's common unit -- a word space adds a roughly constant
* extra distance no matter how wide its neighbors are.
* The ratio test alone under-detects a word gap next to a wide fragment (`waves within`
* measured across `s`/`w`, or `Choices presumably` across two three-letter chunks, both
* normalize to nearly a common advance), so a gap also counts as a word boundary when its
* advance exceeds the width-predicted advance by a material fraction of the line's common
* unit -- a word space adds a roughly constant extra distance no matter how wide its
* neighbors are. Chunk runs use a tighter margin than per-glyph runs because their
* advances deviate less from the width model.
*
* A line may mix both conventions: one fragment carrying a literal space while its
* neighbors rely on position alone. Whitespace-bearing fragments have no width weight, so
* they drop out of the estimate and their own boundary comes from the authored space,
* leaving the surrounding positional gaps to be recovered normally.
*/
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]!);
@@ -620,13 +627,16 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
// (`S|miles`), and capitals overrun the width table too easily, so the excess rule
// never fires there. A lone capital word like `I` is narrow enough for the ratio
// test to catch its word gap on its own.
const excessRatio = isGlyphRun ? LATIN_GLYPH_WORD_EXCESS_RATIO : LATIN_CHUNK_WORD_EXCESS_RATIO;
const minimumExcessSamples = isGlyphRun
? MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES
: MIN_LATIN_CHUNK_EXCESS_GAP_SAMPLES;
const hasAdvanceExcess =
isGlyphRun &&
commonGap !== null &&
normalizedGaps.length >= MIN_LATIN_GLYPH_EXCESS_GAP_SAMPLES &&
normalizedGaps.length >= minimumExcessSamples &&
measure !== null &&
!(/^[A-Z]$/u.test(previous.text) && /^[a-z]$/u.test(current.text)) &&
measure.distance - measure.meanWeight * commonGap > LATIN_GLYPH_WORD_EXCESS_RATIO * commonGap;
measure.distance - measure.meanWeight * commonGap > excessRatio * commonGap;
const hasPositionedWordGap =
startsNewPositionedFragmentSequence(previous, current) ||
(normalizedGap !== null &&
@@ -1005,9 +1015,7 @@ function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): Annotate
const suppressed = new Set<AnnotatedSubtitleCue>();
for (const events of groups.values()) {
const decorative = decorativeGlyphEvents(events);
const lineEvents = decorative.size
? events.filter((event) => !decorative.has(event))
: events;
const lineEvents = decorative.size ? events.filter((event) => !decorative.has(event)) : events;
if (isProgressiveHighlightSweepGroup(lineEvents)) {
lineEvents.forEach((event) => suppressed.add(event));
continue;