mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 01:55:50 -07:00
fix(subtitles): collapse duplicate ASS events and decode text once (#186)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assOverrideSignature,
|
||||
assToPlainText,
|
||||
collectAssOverrideCommands,
|
||||
extractAssOverrideBlocks,
|
||||
hasAssTemporalOverride,
|
||||
isAnimatedAssEffectKind,
|
||||
isAssTemporalCommand,
|
||||
normalizePlainSubtitleText,
|
||||
parseAssEffectField,
|
||||
} from './ass-text';
|
||||
|
||||
test('assToPlainText drops vector drawing runs', () => {
|
||||
assert.equal(
|
||||
assToPlainText(
|
||||
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
||||
assert.equal(
|
||||
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
||||
'本文続き',
|
||||
);
|
||||
});
|
||||
|
||||
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
||||
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
||||
});
|
||||
|
||||
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
||||
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
||||
});
|
||||
|
||||
test('assToPlainText resolves line-break and space escapes', () => {
|
||||
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
||||
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
||||
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
||||
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
||||
});
|
||||
|
||||
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
||||
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
||||
// braces still open and close an override block.
|
||||
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
||||
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
||||
});
|
||||
|
||||
test('assToPlainText renders an unclosed override block verbatim', () => {
|
||||
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
||||
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||
});
|
||||
|
||||
test('assToPlainText is idempotent', () => {
|
||||
const samples = [
|
||||
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
||||
'\\{注\\}',
|
||||
'\\\\N',
|
||||
'本文{\\pos(1,2)',
|
||||
'一行目\\N二行目\\h終わり',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const once = assToPlainText(sample);
|
||||
assert.equal(assToPlainText(once), once, sample);
|
||||
}
|
||||
});
|
||||
|
||||
test('assToPlainText normalizes CRLF before converting', () => {
|
||||
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||
});
|
||||
|
||||
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)');
|
||||
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
||||
'一行目 二行目',
|
||||
);
|
||||
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText is idempotent', () => {
|
||||
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
||||
const once = normalizePlainSubtitleText(sample);
|
||||
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
||||
}
|
||||
});
|
||||
|
||||
test('extractAssOverrideBlocks returns block contents', () => {
|
||||
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
||||
'\\an8',
|
||||
'\\fad(200,200)',
|
||||
]);
|
||||
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
||||
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
{ name: 'pos', args: '1,2', animated: false },
|
||||
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
||||
{ name: 'kf', args: '30', animated: false },
|
||||
]);
|
||||
|
||||
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
||||
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
||||
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
||||
|
||||
assert.deepEqual(
|
||||
commands.map((command) => [command.name, command.animated]),
|
||||
[
|
||||
['clip', false],
|
||||
['t', false],
|
||||
['frz', true],
|
||||
],
|
||||
);
|
||||
assert.equal(hasAssTemporalOverride(commands), true);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
|
||||
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
|
||||
// pathological line (real files reach one or two levels) overflows the stack.
|
||||
const nesting = 32;
|
||||
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
|
||||
|
||||
const commands = collectAssOverrideCommands(block);
|
||||
|
||||
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
|
||||
assert.equal(commands.length, 9);
|
||||
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
|
||||
assert.equal(hasAssTemporalOverride(commands), true);
|
||||
});
|
||||
|
||||
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||
assert.equal(
|
||||
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||
false,
|
||||
);
|
||||
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
||||
});
|
||||
|
||||
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
||||
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
||||
assert.equal(isAssTemporalCommand(command), true, command);
|
||||
}
|
||||
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
||||
assert.equal(isAssTemporalCommand(command), false, command);
|
||||
}
|
||||
});
|
||||
|
||||
test('assOverrideSignature distinguishes events by their override values', () => {
|
||||
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
||||
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
||||
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(first, repeat);
|
||||
});
|
||||
|
||||
test('parseAssEffectField classifies the event-level Effect column', () => {
|
||||
assert.equal(parseAssEffectField(''), 'none');
|
||||
assert.equal(parseAssEffectField(' '), 'none');
|
||||
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
||||
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
||||
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
||||
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
||||
assert.equal(parseAssEffectField('fx-template'), 'other');
|
||||
});
|
||||
|
||||
test('parseAssEffectField matches stock effect names exactly', () => {
|
||||
// Custom effect names that merely start with a stock name are not stock effects.
|
||||
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
||||
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
||||
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
||||
assert.equal(parseAssEffectField('Scroll'), 'other');
|
||||
});
|
||||
|
||||
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
||||
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
||||
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
||||
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
||||
// Typesetting groups put static template names in this column too.
|
||||
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||
assert.equal(isAnimatedAssEffectKind('none'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user