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

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
- Defined the UTF-8 `.fsrt` format with inclusive, one-based page ranges.
- Added dependency-free JavaScript and Python readers and writers.
- Added English, Japanese and Arabic examples and shared conformance tests.
+20
View File
@@ -0,0 +1,20 @@
# Contributing
Run both implementations before sending a change:
```sh
node --test tests/fsrt.test.js
python -m unittest discover -s tests
```
`tests/cases.json` is shared by JavaScript and Python. Add accepted and rejected
examples there when changing how files are read. Keep the two implementations
in agreement, including their page limits and Unicode handling.
Changes to the format belong in `SPEC.md`. Please discuss incompatible changes
before implementing them. Font choices, positioning and application upload
limits belong to the player or editor using FSRT.
Bug reports are most useful with a small `.fsrt` file, the expected page range
and the parser error or displayed result. Avoid including private animations
or account details.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 RexiMemo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+109
View File
@@ -0,0 +1,109 @@
# Flipnote Subtitle (.fsrt)
Subtitles timed to Flipnote pages, rather than seconds.
```text
1
1 --> 12
Hello!
2
18 --> 30
Thanks for watching.
See you next time!
```
FSRT uses the same numbered blocks as a basic SRT file, with page ranges in
place of timestamps. Pages start at 1, and both ends are included:
`12 --> 12` displays a subtitle for one page. A gap between ranges has no text.
Changing playback speed doesn't change which drawings have subtitles.
Save files as UTF-8 with the `.fsrt` extension. Each file is one language track.
Names such as `my-flipnote.en.fsrt` and `my-flipnote.ar.fsrt` help people tell
tracks apart, but language codes are not part of the file format.
An FSRT contains text and page ranges. It contains no animation, audio,
account details or author signature, and it doesn't change the original
Flipnote. It can be used with PPM or KWZ animations.
## JavaScript
No dependencies are needed. Use Node.js 18 or later:
```js
const fs = require('node:fs');
const {parse, stringify} = require('./fsrt.js');
const cues = parse(fs.readFileSync('examples/english.fsrt', 'utf8'));
fs.writeFileSync('copy.fsrt', stringify(cues));
```
For browser use, load `fsrt.js` with a script tag:
```html
<script src="fsrt.js"></script>
<script>
const cues = FlipnoteSubtitle.parse('1\n1 --> 12\nHello!\n');
console.log(cues[0]); // {start: 1, end: 12, text: 'Hello!'}
</script>
```
Both functions accept optional `{maxPage: 999, maxCues: 200}` limits. Catch
`FsrtError` to show a readable message for an invalid file or range.
## Python
Use Python 3.9 or later. Copy `fsrt.py` into your project:
```python
from pathlib import Path
from fsrt import parse, stringify
cues = parse(Path('examples/english.fsrt').read_text(encoding='utf-8-sig'))
Path('copy.fsrt').write_text(stringify(cues), encoding='utf-8')
```
The optional keyword arguments are `max_page` and `max_cues`. Parser and
validation errors raise `FsrtError`, a subclass of `ValueError`.
## Playing a track
Each parser returns objects or dictionaries with `start`, `end` and `text`
fields, sorted by starting page. If your player reports zero-based frame
indices, add 1 before comparing them with a cue's inclusive range:
```js
const page = player.currentFrame + 1;
const cue = cues.find(cue => cue.start <= page && page <= cue.end);
caption.textContent = cue ? cue.text : '';
```
Render the text literally, including line breaks. Do not put it into
`innerHTML`: `<b>` and similar strings are subtitle text, not formatting.
Fonts, placement and language selection are the player's responsibility.
Readers reject overlapping cues, reversed or out-of-range pages, blank text
and invalid control characters. The reference code accepts UTF-8 BOMs and
common line endings, and limits files to 1 MiB and 10,000 cues by default.
The [specification](SPEC.md) defines the format; individual applications may
apply stricter limits.
## Using it on RexiMemo
See [the RexiMemo instructions](docs/reximemo.md) for importing and exporting
tracks through Manage Flipnote. Those application limits do not restrict the
FSRT format itself.
## Tests
```sh
node --test tests/fsrt.test.js
python -m unittest discover -s tests
```
The two implementations use the same [conformance cases](tests/cases.json).
The [examples](examples/) include English, Japanese and Arabic tracks.
## Licence
[MIT](LICENSE). The format and reference code are maintained by RexiMemo.
+78
View File
@@ -0,0 +1,78 @@
# Flipnote Subtitle format, version 1
- File extension: `.fsrt`
- Encoding: UTF-8, with an optional UTF-8 byte order mark at the start
- Line endings: LF or CRLF (readers may also accept CR)
## Cue blocks
A file contains one or more cue blocks, separated by an empty line. A line
containing only spaces or tabs also counts as a separator.
Each block consists of:
1. A positive decimal sequence number.
2. A page range written as `start --> end`.
3. One or more non-empty lines of subtitle text.
Sequence numbers identify blocks for people editing the file. They do not
determine timing. Writers number blocks consecutively from 1; readers may
accept non-consecutive numbers and out-of-order blocks.
Sequence numbers may not exceed 9,007,199,254,740,991.
## Page ranges
Pages are animation frames, numbered from 1. Both ends of a range are included.
`3 --> 3` is valid and appears on page 3 only. Gaps between cues have no subtitle.
`start` and `end` must be positive decimal integers, with `end >= start`.
For compatibility with JavaScript readers, neither may exceed 9,007,199,254,740,991.
Readers must reject overlapping ranges. Adjacent ranges such as `1 --> 4` and
`5 --> 8` do not overlap.
Spaces and tabs around the page numbers and arrow are allowed. There are no
timestamps, frame-rate declarations or cue settings on this line. A reader
must not silently interpret an ordinary SRT timestamp as a page number.
Applications should check that all ranges fit their target Flipnote. Timing
depends on pages, so it follows the Flipnote when its playback speed changes.
## Text
Text is Unicode and is displayed literally. Tags such as `<b>` have no special
meaning. There is no escaping syntax, markup, positioning, font selection or
colour information. Blank lines delimit cues and cannot occur within text.
Leading and trailing spaces on non-empty text lines are preserved.
Text must be valid UTF-8; unpaired UTF-16 surrogates are not valid text.
Tabs are allowed. Other C0 control characters, except line endings, and DEL
are invalid. Applications may restrict characters or line lengths to fit their
fonts and display. They should report unsupported text rather than silently
discard it.
## Languages and media
One file is one language track. Language is not stored inside the file. A
filename can include a language code, for example `animation.ar.fsrt`, but
the importing application must let the user choose the language.
It may present the available files as separately selectable tracks rather
than assigning language codes to them.
An FSRT contains neither the animation nor its audio. It doesn't carry an
author identity, signature, account token or link to a particular PPM. The
user chooses the Flipnote it belongs to.
## Writer output
The reference writers sort cues by starting page, use consecutive sequence
numbers, put one space on each side of `-->`, and emit LF line endings with
one empty line between cues and one final newline. These choices keep diffs
readable; other accepted spacing and line endings remain valid.
## Reference parser limits
The JavaScript and Python implementations limit input and output to 1 MiB
(1,048,576 UTF-8 bytes), with a default maximum of 10,000 cues. These are
resource limits of the reference code, not additional fields in the format.
Applications can set lower page and cue limits. Invalid data must be reported
as an error rather than silently clipped, truncated or interpreted as HTML.
+20
View File
@@ -0,0 +1,20 @@
# Using FSRT on RexiMemo
Open your Flipnote's **Manage Flipnote** window, then **Subtitle Tool**.
Choose a language and use **Import .fsrt** to load that track. Check its page
ranges and preview, then press **Save language track**.
Importing replaces the selected language's draft after confirmation. Saved
subtitles are kept until you save the draft, and other language tracks are
left alone. **Export .fsrt** downloads the current draft, including unsaved
edits.
The website displays text captions. DSi playback uses a separate subtitled
PPM; saving or downloading the Flipnote still gives the original animation.
Viewers can choose their preferred language in the site's Preferences.
RexiMemo supports up to 200 cues per language, 280 characters per cue and two
rendered lines. Its available characters depend on the subtitle fonts.
Japanese uses the NDS BIOS font's characters, including Hiragana and Katakana;
that font does not contain Kanji. These are RexiMemo's application limits,
rather than requirements of FSRT.
+7
View File
@@ -0,0 +1,7 @@
1
1 --> 12
مرحبا بكم
2
18 --> 30
شكرا للمشاهدة
+8
View File
@@ -0,0 +1,8 @@
1
1 --> 12
Hello!
2
18 --> 30
Thanks for watching.
See you next time!
+7
View File
@@ -0,0 +1,7 @@
1
1 --> 12
こんにちは!
2
18 --> 30
みてくれて ありがとう。
+67
View File
@@ -0,0 +1,67 @@
/* Flipnote Subtitle (.fsrt) — https://reximemo.net */
(function (root, factory) {
'use strict';
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.FlipnoteSubtitle = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const MAX_FILE_BYTES = 1024 * 1024;
const MAX_PAGE = Number.MAX_SAFE_INTEGER;
class FsrtError extends Error {
constructor(message) { super(message); this.name = 'FsrtError'; }
}
function limits(options = {}) {
const {maxPage = MAX_PAGE, maxCues = 10000} = options;
if (!Number.isSafeInteger(maxPage) || maxPage < 1 || !Number.isSafeInteger(maxCues) || maxCues < 1)
throw new FsrtError('Page and subtitle limits must be positive integers.');
return {maxPage, maxCues};
}
function normalise(cues, options) {
const {maxPage, maxCues} = limits(options);
if (!Array.isArray(cues) || !cues.length) throw new FsrtError('The file has no subtitles.');
if (cues.length > maxCues) throw new FsrtError(`Use no more than ${maxCues} subtitles.`);
const result = cues.map((cue, index) => {
const {start, end, text} = cue || {};
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || end > maxPage)
throw new FsrtError(`Subtitle ${index + 1}: choose pages between 1 and ${maxPage}, with the end on or after the start.`);
if (typeof text !== 'string') throw new FsrtError(`Subtitle ${index + 1}: enter some text.`);
const value = text.replace(/\r\n?/g, '\n');
if (!value.trim() || /[\u0000-\u0008\u000b-\u001f\u007f]/u.test(value) || value.split('\n').some(line => !line.trim()))
throw new FsrtError(`Subtitle ${index + 1}: text must not contain blank lines or control characters.`);
return {start, end, text: value};
}).sort((a, b) => a.start - b.start || a.end - b.end);
for (let i = 1; i < result.length; i++)
if (result[i].start <= result[i - 1].end) throw new FsrtError('Two subtitles overlap. End one before the next starts.');
return result;
}
function sizeCheck(text) {
for (const character of text) {
const codePoint = character.codePointAt(0);
if (codePoint >= 0xd800 && codePoint <= 0xdfff)
throw new FsrtError('Subtitle text must be valid UTF-8.');
}
if (new TextEncoder().encode(text).length > MAX_FILE_BYTES) throw new FsrtError('The subtitle file must be 1 MiB or smaller.');
}
function parse(text, options) {
if (typeof text !== 'string') throw new FsrtError('Read the file as UTF-8 text.');
sizeCheck(text);
const value = text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').replace(/^(?:[ \t]*\n)+|(?:\n[ \t]*)+$/g, '');
if (!value.trim()) throw new FsrtError('The file has no subtitles.');
const cues = value.split(/\n(?:[ \t]*\n)+/).map((block, index) => {
const lines = block.split('\n');
if (!/^[ \t]*\d{1,16}[ \t]*$/.test(lines[0]) || !Number.isSafeInteger(Number(lines[0])) || Number(lines[0]) < 1)
throw new FsrtError(`Subtitle ${index + 1}: put a positive sequence number on the first line.`);
const range = /^[ \t]*(\d{1,16})[ \t]*-->[ \t]*(\d{1,16})[ \t]*$/.exec(lines[1] || '');
if (!range) throw new FsrtError(`Subtitle ${index + 1}: use page numbers such as 1 --> 12, instead of timestamps.`);
return {start: Number(range[1]), end: Number(range[2]), text: lines.slice(2).join('\n')};
});
return normalise(cues, options);
}
function stringify(cues, options) {
const text = normalise(cues, options).map((cue, i) => `${i + 1}\n${cue.start} --> ${cue.end}\n${cue.text}`).join('\n\n') + '\n';
sizeCheck(text);
return text;
}
return {parse, stringify, FsrtError, MAX_FILE_BYTES};
});
+70
View File
@@ -0,0 +1,70 @@
"""Read and write frame-timed Flipnote Subtitle files. No dependencies."""
import re
MAX_FILE_BYTES = 1024 * 1024
MAX_PAGE = 2**53 - 1
class FsrtError(ValueError):
pass
def _normalise(cues, max_page, max_cues):
if type(max_page) is not int or not 1 <= max_page <= MAX_PAGE or type(max_cues) is not int or max_cues < 1:
raise FsrtError('Page and subtitle limits must be positive integers.')
if not isinstance(cues, list) or not cues:
raise FsrtError('The file has no subtitles.')
if len(cues) > max_cues:
raise FsrtError(f'Use no more than {max_cues} subtitles.')
result = []
for index, cue in enumerate(cues, 1):
cue = cue if isinstance(cue, dict) else {}
start, end, text = cue.get('start'), cue.get('end'), cue.get('text')
if type(start) is not int or type(end) is not int or not 1 <= start <= end <= min(max_page, MAX_PAGE):
raise FsrtError(f'Subtitle {index}: choose pages between 1 and {max_page}, with the end on or after the start.')
if not isinstance(text, str):
raise FsrtError(f'Subtitle {index}: enter some text.')
text = text.replace('\r\n', '\n').replace('\r', '\n')
if not text.strip() or re.search(r'[\x00-\x08\x0b-\x1f\x7f]', text) or any(not line.strip() for line in text.split('\n')):
raise FsrtError(f'Subtitle {index}: text must not contain blank lines or control characters.')
result.append({'start': start, 'end': end, 'text': text})
result.sort(key=lambda cue: (cue['start'], cue['end']))
if any(b['start'] <= a['end'] for a, b in zip(result, result[1:])):
raise FsrtError('Two subtitles overlap. End one before the next starts.')
return result
def _size_check(text):
try:
size = len(text.encode('utf-8'))
except UnicodeEncodeError:
raise FsrtError('Subtitle text must be valid UTF-8.') from None
if size > MAX_FILE_BYTES:
raise FsrtError('The subtitle file must be 1 MiB or smaller.')
def parse(text, *, max_page=MAX_PAGE, max_cues=10000):
if not isinstance(text, str):
raise FsrtError('Read the file as UTF-8 text.')
_size_check(text)
value = text.removeprefix('\ufeff').replace('\r\n', '\n').replace('\r', '\n')
value = re.sub(r'^(?:[ \t]*\n)+|(?:\n[ \t]*)+$', '', value)
if not value.strip():
raise FsrtError('The file has no subtitles.')
cues = []
for index, block in enumerate(re.split(r'\n(?:[ \t]*\n)+', value), 1):
lines = block.split('\n')
if not re.fullmatch(r'[ \t]*[0-9]{1,16}[ \t]*', lines[0]) or not 1 <= int(lines[0]) <= MAX_PAGE:
raise FsrtError(f'Subtitle {index}: put a positive sequence number on the first line.')
match = re.fullmatch(r'[ \t]*([0-9]{1,16})[ \t]*-->[ \t]*([0-9]{1,16})[ \t]*', lines[1] if len(lines) > 1 else '')
if not match:
raise FsrtError(f'Subtitle {index}: use page numbers such as 1 --> 12, instead of timestamps.')
cues.append({'start': int(match[1]), 'end': int(match[2]), 'text': '\n'.join(lines[2:])})
return _normalise(cues, max_page, max_cues)
def stringify(cues, *, max_page=MAX_PAGE, max_cues=10000):
values = _normalise(cues, max_page, max_cues)
text = '\n\n'.join(f"{index}\n{cue['start']} --> {cue['end']}\n{cue['text']}" for index, cue in enumerate(values, 1)) + '\n'
_size_check(text)
return text
+11
View File
@@ -0,0 +1,11 @@
{
"name": "flipnote-subtitle",
"version": "1.0.0",
"description": "Read and write frame-timed Flipnote Subtitle (.fsrt) files",
"license": "MIT",
"main": "fsrt.js",
"files": ["fsrt.js", "fsrt.py", "SPEC.md", "examples"],
"engines": {"node": ">=18"},
"scripts": {"test": "node --test tests/fsrt.test.js"},
"keywords": ["flipnote", "fsrt", "subtitles", "ppm", "kwz"]
}
+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()