mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
fix(subtitles): recover positioned word gaps in reconstructed translations
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user