Files
flipnote-subtitle/fsrt.js
T
2026-10-25 22:54:32 +00:00

68 lines
3.7 KiB
JavaScript

/* 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};
});