inital commit
This commit is contained in:
15 files changed
+711
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
1
|
||||
1 --> 12
|
||||
مرحبا بكم
|
||||
|
||||
2
|
||||
18 --> 30
|
||||
شكرا للمشاهدة
|
||||
@@ -0,0 +1,8 @@
|
||||
1
|
||||
1 --> 12
|
||||
Hello!
|
||||
|
||||
2
|
||||
18 --> 30
|
||||
Thanks for watching.
|
||||
See you next time!
|
||||
@@ -0,0 +1,7 @@
|
||||
1
|
||||
1 --> 12
|
||||
こんにちは!
|
||||
|
||||
2
|
||||
18 --> 30
|
||||
みてくれて ありがとう。
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user
