From 8e155b599b4d16bdef0e14dcc2d271c44a30d9ee Mon Sep 17 00:00:00 2001
From: Rekushi <170186554+RekuNote@users.noreply.github.com>
Date: Sun, 25 Oct 2026 22:54:32 +0000
Subject: [PATCH] inital commit
---
CHANGELOG.md | 7 ++
CONTRIBUTING.md | 20 +++++
LICENSE | 21 +++++
README.md | 109 ++++++++++++++++++++++++
SPEC.md | 78 +++++++++++++++++
docs/reximemo.md | 20 +++++
examples/arabic.fsrt | 7 ++
examples/english.fsrt | 8 ++
examples/japanese.fsrt | 7 ++
fsrt.js | 67 +++++++++++++++
fsrt.py | 70 +++++++++++++++
package.json | 11 +++
tests/cases.json | 188 +++++++++++++++++++++++++++++++++++++++++
tests/fsrt.test.js | 47 +++++++++++
tests/test_fsrt.py | 51 +++++++++++
15 files changed, 711 insertions(+)
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 SPEC.md
create mode 100644 docs/reximemo.md
create mode 100644 examples/arabic.fsrt
create mode 100644 examples/english.fsrt
create mode 100644 examples/japanese.fsrt
create mode 100644 fsrt.js
create mode 100644 fsrt.py
create mode 100644 package.json
create mode 100644 tests/cases.json
create mode 100644 tests/fsrt.test.js
create mode 100644 tests/test_fsrt.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..73b6880
--- /dev/null
+++ b/CHANGELOG.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..4e80a70
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -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.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0276918
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2f21fa9
--- /dev/null
+++ b/README.md
@@ -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
+
+
+```
+
+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`: `` 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.
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000..6b29c12
--- /dev/null
+++ b/SPEC.md
@@ -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 `` 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.
diff --git a/docs/reximemo.md b/docs/reximemo.md
new file mode 100644
index 0000000..b49f62e
--- /dev/null
+++ b/docs/reximemo.md
@@ -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.
diff --git a/examples/arabic.fsrt b/examples/arabic.fsrt
new file mode 100644
index 0000000..fe31e97
--- /dev/null
+++ b/examples/arabic.fsrt
@@ -0,0 +1,7 @@
+1
+1 --> 12
+مرحبا بكم
+
+2
+18 --> 30
+شكرا للمشاهدة
diff --git a/examples/english.fsrt b/examples/english.fsrt
new file mode 100644
index 0000000..01f0da2
--- /dev/null
+++ b/examples/english.fsrt
@@ -0,0 +1,8 @@
+1
+1 --> 12
+Hello!
+
+2
+18 --> 30
+Thanks for watching.
+See you next time!
diff --git a/examples/japanese.fsrt b/examples/japanese.fsrt
new file mode 100644
index 0000000..42a7a1a
--- /dev/null
+++ b/examples/japanese.fsrt
@@ -0,0 +1,7 @@
+1
+1 --> 12
+こんにちは!
+
+2
+18 --> 30
+みてくれて ありがとう。
diff --git a/fsrt.js b/fsrt.js
new file mode 100644
index 0000000..eb77419
--- /dev/null
+++ b/fsrt.js
@@ -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};
+});
diff --git a/fsrt.py b/fsrt.py
new file mode 100644
index 0000000..aacc95e
--- /dev/null
+++ b/fsrt.py
@@ -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
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d559580
--- /dev/null
+++ b/package.json
@@ -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"]
+}
diff --git a/tests/cases.json b/tests/cases.json
new file mode 100644
index 0000000..8d87d1f
--- /dev/null
+++ b/tests/cases.json
@@ -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 123",
+ "cues": [
+ {
+ "start": 1,
+ "end": 3,
+ "text": "\u0645\u0631\u062d\u0628\u0627 123"
+ }
+ ]
+ },
+ {
+ "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
+ }
+ ]
+}
diff --git a/tests/fsrt.test.js b/tests/fsrt.test.js
new file mode 100644
index 0000000..c9d3d68
--- /dev/null
+++ b/tests/fsrt.test.js
@@ -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);
+});
diff --git a/tests/test_fsrt.py b/tests/test_fsrt.py
new file mode 100644
index 0000000..7e60d42
--- /dev/null
+++ b/tests/test_fsrt.py
@@ -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()