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()