inital commit

This commit is contained in:
Rekushi authored and GitHub committed 2026-10-25 22:54:32 +00:00
commit 8e155b599b
15 files changed
+711

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
{
"valid": [
{
"name": "multiline and inclusive single-page range",
"input": "1\n1 --> 4\nHello\nAgain\n\n2\n5 --> 5\nBye\n",
"cues": [
{
"start": 1,
"end": 4,
"text": "Hello\nAgain"
},
{
"start": 5,
"end": 5,
"text": "Bye"
}
]
},
{
"name": "BOM CRLF and Japanese",
"input": "\ufeff1\r\n1 --> 12\r\n\u3053\u3093\u306b\u3061\u306f\uff01\r\n",
"cues": [
{
"start": 1,
"end": 12,
"text": "\u3053\u3093\u306b\u3061\u306f\uff01"
}
]
},
{
"name": "Arabic and literal markup",
"input": "1\n1 --> 3\n\u0645\u0631\u062d\u0628\u0627 <b>123</b>",
"cues": [
{
"start": 1,
"end": 3,
"text": "\u0645\u0631\u062d\u0628\u0627 <b>123</b>"
}
]
},
{
"name": "non-consecutive numbers sorted by page",
"input": "8\n20-->30\nLater\n \t\n3\n1 --> 8\n Earlier \n",
"cues": [
{
"start": 1,
"end": 8,
"text": " Earlier "
},
{
"start": 20,
"end": 30,
"text": "Later"
}
]
},
{
"name": "leading blank lines and CR",
"input": "\n\n1\r1 --> 1\rOnly\r",
"cues": [
{
"start": 1,
"end": 1,
"text": "Only"
}
]
},
{
"name": "multiple whitespace separators",
"input": "1\n1 --> 4\nHello\n \n\t\n\n2\n5 --> 8\nAgain\n",
"cues": [
{
"start": 1,
"end": 4,
"text": "Hello"
},
{
"start": 5,
"end": 8,
"text": "Again"
}
]
},
{
"name": "emoji and combining marks",
"input": "1\n1 --> 3\n\ud83d\ude00 \u062d\u0648\u0649\u0670 \u0639\u0650\u0644\u0645\u0627\u064b\n",
"cues": [
{
"start": 1,
"end": 3,
"text": "\ud83d\ude00 \u062d\u0648\u0649\u0670 \u0639\u0650\u0644\u0645\u0627\u064b"
}
]
},
{
"name": "largest supported page",
"input": "1\n9007199254740991 --> 9007199254740991\nLast\n",
"cues": [
{
"start": 9007199254740991,
"end": 9007199254740991,
"text": "Last"
}
]
}
],
"invalid": [
{
"name": "empty",
"input": "\ufeff\r\n"
},
{
"name": "ordinary SRT",
"input": "1\n00:00:00,000 --> 00:00:01,000\nHello"
},
{
"name": "missing sequence",
"input": "1 --> 4\nHello"
},
{
"name": "zero sequence",
"input": "0\n1 --> 4\nHello"
},
{
"name": "zero page",
"input": "1\n0 --> 4\nHello"
},
{
"name": "negative page",
"input": "1\n-1 --> 4\nHello"
},
{
"name": "fractional page",
"input": "1\n1.5 --> 4\nHello"
},
{
"name": "reversed range",
"input": "1\n4 --> 1\nHello"
},
{
"name": "overlapping inclusive boundary",
"input": "1\n1 --> 4\nHello\n\n2\n4 --> 8\nAgain"
},
{
"name": "unsafe integer",
"input": "1\n1 --> 9007199254740992\nHello"
},
{
"name": "missing text",
"input": "1\n1 --> 4\n"
},
{
"name": "control character",
"input": "1\n1 --> 4\nHello\u0000"
},
{
"name": "past last page",
"input": "1\n1 --> 4\nHello",
"maxPage": 3
},
{
"name": "too many cues",
"input": "1\n1 --> 4\nHello\n\n2\n5 --> 8\nAgain",
"maxCues": 1
},
{
"name": "unsafe sequence number",
"input": "9007199254740992\n1 --> 3\nHello"
},
{
"name": "timing settings are not page numbers",
"input": "1\n1 --> 3 align:start\nHello"
},
{
"name": "unpaired high surrogate",
"input": "1\n1 --> 3\n\ud800"
},
{
"name": "unpaired low surrogate",
"input": "1\n1 --> 3\n\udfff"
},
{
"name": "unsafe configured page limit",
"input": "1\n1 --> 3\nHello",
"maxPage": 9007199254740992
}
]
}
+47
View File
@@ -0,0 +1,47 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const {parse, stringify, FsrtError, MAX_FILE_BYTES} = require('../fsrt.js');
const cases = require('./cases.json');
for (const item of cases.valid) test(item.name, () => {
const cues = parse(item.input);
assert.deepEqual(cues, item.cues);
assert.deepEqual(parse(stringify(cues)), cues);
});
for (const item of cases.invalid) test(`reject ${item.name}`, () => {
const options = {};
if (item.maxPage) options.maxPage = item.maxPage;
if (item.maxCues) options.maxCues = item.maxCues;
assert.throws(() => parse(item.input, options), FsrtError);
});
test('all examples round-trip without changing their text', () => {
for (const file of fs.readdirSync(path.join(__dirname, '../examples'))) {
const text = fs.readFileSync(path.join(__dirname, '../examples', file), 'utf8');
assert.deepEqual(parse(stringify(parse(text))), parse(text));
}
});
test('reject oversized files and invalid exported ranges or blank lines', () => {
assert.throws(() => parse('a'.repeat(MAX_FILE_BYTES + 1)), FsrtError);
for (const cues of [[], [{start: 0, end: 1, text: 'Hi'}], [{start: 1, end: 2, text: 'Hi\n\nBye'}]])
assert.throws(() => stringify(cues), FsrtError);
});
test('UTF-8 byte limits and malformed Unicode apply to exports too', () => {
assert.throws(() => parse('1\n1 --> 1\n' + 'あ'.repeat(Math.ceil(MAX_FILE_BYTES / 3))), FsrtError);
assert.throws(() => stringify([{start: 1, end: 1, text: '\ud800'}]), FsrtError);
assert.throws(() => stringify([{start: 1, end: 1, text: '\udfff'}]), FsrtError);
assert.throws(() => parse('1\n1 --> 1\nHello', {maxPage: Number.MAX_SAFE_INTEGER + 1}), FsrtError);
});
test('the same file exposes its API directly in a browser', () => {
const browser = {TextEncoder};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../fsrt.js'), 'utf8'), browser);
const input = '1\n1 --> 3\nこんにちは! مرحبا 😀\n';
const cues = browser.FlipnoteSubtitle.parse(input);
assert.equal(cues[0].start, 1);
assert.equal(cues[0].text, 'こんにちは! مرحبا 😀');
assert.equal(browser.FlipnoteSubtitle.stringify(cues), input);
});
+51
View File
@@ -0,0 +1,51 @@
import json
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fsrt import FsrtError, MAX_FILE_BYTES, parse, stringify
ROOT = Path(__file__).resolve().parents[1]
CASES = json.loads((ROOT / 'tests/cases.json').read_text(encoding='utf-8'))
class FsrtTests(unittest.TestCase):
def test_valid_cases_round_trip(self):
for item in CASES['valid']:
with self.subTest(item['name']):
cues = parse(item['input'])
self.assertEqual(cues, item['cues'])
self.assertEqual(parse(stringify(cues)), cues)
def test_invalid_cases(self):
for item in CASES['invalid']:
with self.subTest(item['name']):
options = {key: item[source] for source, key in [('maxPage', 'max_page'), ('maxCues', 'max_cues')] if source in item}
with self.assertRaises(FsrtError):
parse(item['input'], **options)
def test_examples(self):
for file in (ROOT / 'examples').glob('*.fsrt'):
cues = parse(file.read_text(encoding='utf-8'))
self.assertEqual(parse(stringify(cues)), cues)
def test_invalid_export_and_oversized_files(self):
with self.assertRaises(FsrtError):
parse('a' * (MAX_FILE_BYTES + 1))
for cues in [[], [{'start': 0, 'end': 1, 'text': 'Hi'}], [{'start': 1, 'end': 2, 'text': 'Hi\n\nBye'}]]:
with self.assertRaises(FsrtError):
stringify(cues)
def test_utf8_byte_limits_and_malformed_unicode_apply_to_exports(self):
with self.assertRaises(FsrtError):
parse('1\n1 --> 1\n' + 'あ' * (MAX_FILE_BYTES // 3 + 1))
for text in ('\ud800', '\udfff'):
with self.subTest(text=repr(text)), self.assertRaises(FsrtError):
stringify([{'start': 1, 'end': 1, 'text': text}])
with self.assertRaises(FsrtError):
parse('1\n1 --> 1\nHello', max_page=2**53)
if __name__ == '__main__':
unittest.main()