easycaptionkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -2
- package/package.json +42 -4
- package/skills/easycaptionkit/SKILL.md +58 -0
- package/skills/easycaptionkit/scripts/easycaptionkit.js +386 -0
- package/skills/easycaptionkit/scripts/lib/apng.js +136 -0
- package/skills/easycaptionkit/scripts/lib/caption.js +1395 -0
- package/skills/easycaptionkit/scripts/lib/chrome.js +424 -0
- package/skills/easycaptionkit/scripts/lib/cues.js +317 -0
- package/skills/easycaptionkit/scripts/lib/draw.js +288 -0
- package/skills/easycaptionkit/scripts/lib/ffmpeg.js +1032 -0
- package/skills/easycaptionkit/scripts/lib/files.js +249 -0
- package/skills/easycaptionkit/scripts/lib/fontfile.js +355 -0
- package/skills/easycaptionkit/scripts/lib/look.js +571 -0
- package/skills/easycaptionkit/scripts/lib/places.js +337 -0
- package/skills/easycaptionkit/scripts/lib/render.js +374 -0
- package/skills/easycaptionkit/scripts/lib/report.js +604 -0
- package/skills/easycaptionkit/scripts/lib/running.js +62 -0
- package/skills/easycaptionkit/scripts/lib/subtitles.js +489 -0
- package/skills/easycaptionkit/scripts/lib/text.js +115 -0
- package/skills/easycaptionkit/scripts/lib/version.js +5 -0
- package/skills/easycaptionkit/scripts/lib/words.js +1090 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Reading the files other tools wrote: never more than such a file can
|
|
4
|
+
// hold, whatever the file is (a pipe or a device says it holds nothing and
|
|
5
|
+
// can go on forever), in UTF-8 or UTF-16, and saying where a JSON file
|
|
6
|
+
// stops being JSON.
|
|
7
|
+
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
|
|
10
|
+
// The order of the bytes of a text in UTF-16 with no byte order mark. UTF-8
|
|
11
|
+
// text has no NUL, and in UTF-16 each digit, space and Latin letter (an
|
|
12
|
+
// SRT's times, a JSON's keys) has one in its high byte, so NULs on one
|
|
13
|
+
// side of the pairs of bytes and next to none on the other tell UTF-16.
|
|
14
|
+
// "le", "be", or "" for any other text.
|
|
15
|
+
function utf16Order(buf) {
|
|
16
|
+
const n = Math.min(buf.length, 512) & ~1;
|
|
17
|
+
if (n < 4) return "";
|
|
18
|
+
let even = 0;
|
|
19
|
+
let odd = 0;
|
|
20
|
+
for (let i = 0; i < n; i += 2) {
|
|
21
|
+
if (buf[i] === 0) even++;
|
|
22
|
+
if (buf[i + 1] === 0) odd++;
|
|
23
|
+
}
|
|
24
|
+
const many = (n / 2) * 0.3;
|
|
25
|
+
if (odd >= many && even <= odd / 8) return "le";
|
|
26
|
+
if (even >= many && odd <= even / 8) return "be";
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A file's bytes as text: UTF-16 when it starts with UTF-16's byte order
|
|
31
|
+
// mark, as Notepad's Unicode and some Windows subtitle tools save it, or
|
|
32
|
+
// reads as UTF-16 without one; else UTF-8. The mark is kept, as UTF-8's
|
|
33
|
+
// is, for the reader to take off.
|
|
34
|
+
function decodeText(buf) {
|
|
35
|
+
const swapped = () => Buffer.from(buf.subarray(0, buf.length - (buf.length % 2))).swap16();
|
|
36
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) return buf.toString("utf16le");
|
|
37
|
+
if (buf[0] === 0xfe && buf[1] === 0xff) return swapped().toString("utf16le");
|
|
38
|
+
const order = utf16Order(buf);
|
|
39
|
+
if (order === "le") return buf.toString("utf16le");
|
|
40
|
+
if (order === "be") return swapped().toString("utf16le");
|
|
41
|
+
return buf.toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The file as text, or an error with code EFBIG and the size, when known,
|
|
45
|
+
// once it holds more than `most` bytes.
|
|
46
|
+
function readAtMost(file, most) {
|
|
47
|
+
const fd = fs.openSync(file, "r");
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.fstatSync(fd);
|
|
50
|
+
if (stat.isDirectory()) throw Object.assign(new Error(`${file} is a folder`), { code: "EISDIR" });
|
|
51
|
+
const big = (size) => Object.assign(new Error(`${file} holds more than ${most} bytes`), { code: "EFBIG", size });
|
|
52
|
+
if (stat.isFile() && stat.size > most) throw big(stat.size);
|
|
53
|
+
const chunks = [];
|
|
54
|
+
const chunk = Buffer.allocUnsafe(1 << 16);
|
|
55
|
+
let total = 0;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
58
|
+
if (n === 0) break;
|
|
59
|
+
total += n;
|
|
60
|
+
if (total > most) throw big(stat.isFile() ? stat.size : null);
|
|
61
|
+
chunks.push(Buffer.from(chunk.subarray(0, n)));
|
|
62
|
+
}
|
|
63
|
+
return decodeText(Buffer.concat(chunks));
|
|
64
|
+
} finally {
|
|
65
|
+
fs.closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The same, read without holding up the process, for a pipe or a device:
|
|
70
|
+
// one that nothing writes to yet waits in the background, so a signal
|
|
71
|
+
// still stops the run while it waits.
|
|
72
|
+
async function readAtMostLater(file, most) {
|
|
73
|
+
const handle = await fs.promises.open(file, "r");
|
|
74
|
+
try {
|
|
75
|
+
const stat = await handle.stat();
|
|
76
|
+
if (stat.isDirectory()) throw Object.assign(new Error(`${file} is a folder`), { code: "EISDIR" });
|
|
77
|
+
const big = (size) => Object.assign(new Error(`${file} holds more than ${most} bytes`), { code: "EFBIG", size });
|
|
78
|
+
if (stat.isFile() && stat.size > most) throw big(stat.size);
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let total = 0;
|
|
81
|
+
for (;;) {
|
|
82
|
+
const chunk = Buffer.allocUnsafe(1 << 16);
|
|
83
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
84
|
+
if (bytesRead === 0) break;
|
|
85
|
+
total += bytesRead;
|
|
86
|
+
if (total > most) throw big(stat.isFile() ? stat.size : null);
|
|
87
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
88
|
+
}
|
|
89
|
+
return decodeText(Buffer.concat(chunks));
|
|
90
|
+
} finally {
|
|
91
|
+
await handle.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// How many lists and objects a JSON text opens, and how deep they nest,
|
|
96
|
+
// found in one pass that steps over its strings and makes nothing of it,
|
|
97
|
+
// so that a text of millions of them, or of thousands opened one inside
|
|
98
|
+
// another, is told before JSON.parse makes them all, or badAt keeps a
|
|
99
|
+
// stack of them: 64 MB of either took gigabytes and half a minute. The
|
|
100
|
+
// pass stops once either passes `most` or `deepest`. { opened, deepest,
|
|
101
|
+
// over }.
|
|
102
|
+
function jsonShape(text, { most = Infinity, deepest = Infinity } = {}) {
|
|
103
|
+
let opened = 0;
|
|
104
|
+
let depth = 0;
|
|
105
|
+
let deep = 0;
|
|
106
|
+
for (let i = 0; i < text.length; i++) {
|
|
107
|
+
const c = text.charCodeAt(i);
|
|
108
|
+
if (c === 0x22) {
|
|
109
|
+
// To the quote that ends the string, one no backslash escapes.
|
|
110
|
+
for (i++; i < text.length; i++) {
|
|
111
|
+
const d = text.charCodeAt(i);
|
|
112
|
+
if (d === 0x5c) i++;
|
|
113
|
+
else if (d === 0x22) break;
|
|
114
|
+
}
|
|
115
|
+
} else if (c === 0x7b || c === 0x5b) {
|
|
116
|
+
opened++;
|
|
117
|
+
if (++depth > deep) deep = depth;
|
|
118
|
+
if (opened > most || deep > deepest) return { opened, deepest: deep, over: true };
|
|
119
|
+
} else if ((c === 0x7d || c === 0x5d) && depth > 0) depth--;
|
|
120
|
+
}
|
|
121
|
+
return { opened, deepest: deep, over: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Where JSON text stops being JSON: the position of the first character
|
|
125
|
+
// a JSON reader cannot take there, or the end of the text when it stops
|
|
126
|
+
// short. -1 when it is all JSON. Walked with a stack rather than by
|
|
127
|
+
// recursion, so a deep nesting cannot overflow.
|
|
128
|
+
function badAt(text) {
|
|
129
|
+
let i = 0;
|
|
130
|
+
const n = text.length;
|
|
131
|
+
const skip = () => {
|
|
132
|
+
while (i < n && (text[i] === " " || text[i] === "\t" || text[i] === "\n" || text[i] === "\r")) i++;
|
|
133
|
+
};
|
|
134
|
+
// A string from its opening quote; false at the character that breaks it.
|
|
135
|
+
const string = () => {
|
|
136
|
+
i++;
|
|
137
|
+
while (i < n) {
|
|
138
|
+
const c = text.charCodeAt(i);
|
|
139
|
+
if (c === 0x22) {
|
|
140
|
+
i++;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
if (c < 0x20) return false;
|
|
144
|
+
if (c === 0x5c) {
|
|
145
|
+
const e = text[i + 1];
|
|
146
|
+
if (e === "u") {
|
|
147
|
+
if (!/^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))) {
|
|
148
|
+
i += 2;
|
|
149
|
+
while (i < n && /[0-9a-fA-F]/.test(text[i])) i++;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
i += 6;
|
|
153
|
+
} else if (e !== undefined && '"\\/bfnrt'.includes(e)) i += 2;
|
|
154
|
+
else {
|
|
155
|
+
i++;
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
} else i++;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
};
|
|
162
|
+
const value = () => {
|
|
163
|
+
const c = text[i];
|
|
164
|
+
if (c === '"') return string();
|
|
165
|
+
for (const word of ["true", "false", "null"]) {
|
|
166
|
+
if (c !== word[0]) continue;
|
|
167
|
+
for (const letter of word) {
|
|
168
|
+
if (text[i] !== letter) return false;
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i, i + 400));
|
|
174
|
+
if (!m) return false;
|
|
175
|
+
// A number cut short, like 1. or -, breaks where it stops.
|
|
176
|
+
i += m[0].length;
|
|
177
|
+
return !/^[.eE\d]/.test(text[i] || "");
|
|
178
|
+
};
|
|
179
|
+
const stack = [];
|
|
180
|
+
for (;;) {
|
|
181
|
+
skip();
|
|
182
|
+
if (i >= n) return n;
|
|
183
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
184
|
+
stack.push(text[i]);
|
|
185
|
+
i++;
|
|
186
|
+
skip();
|
|
187
|
+
if (text[i] === (stack[stack.length - 1] === "{" ? "}" : "]")) {
|
|
188
|
+
stack.pop();
|
|
189
|
+
i++;
|
|
190
|
+
} else {
|
|
191
|
+
if (stack[stack.length - 1] === "{") {
|
|
192
|
+
if (text[i] !== '"') return i < n ? i : n;
|
|
193
|
+
if (!string()) return Math.min(i, n);
|
|
194
|
+
skip();
|
|
195
|
+
if (text[i] !== ":") return i < n ? i : n;
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
} else if (!value()) return Math.min(i, n);
|
|
201
|
+
// After a value: a comma and the next one, the end of what holds it, or
|
|
202
|
+
// the end of the text.
|
|
203
|
+
for (;;) {
|
|
204
|
+
skip();
|
|
205
|
+
if (!stack.length) return i < n ? i : -1;
|
|
206
|
+
const open = stack[stack.length - 1];
|
|
207
|
+
if (text[i] === ",") {
|
|
208
|
+
i++;
|
|
209
|
+
if (open === "{") {
|
|
210
|
+
skip();
|
|
211
|
+
if (text[i] !== '"') return i < n ? i : n;
|
|
212
|
+
if (!string()) return Math.min(i, n);
|
|
213
|
+
skip();
|
|
214
|
+
if (text[i] !== ":") return i < n ? i : n;
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (text[i] === (open === "{" ? "}" : "]")) {
|
|
220
|
+
stack.pop();
|
|
221
|
+
i++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
return i < n ? i : n;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// "at line 7, column 17: Expected ',' or '}' after property value", from
|
|
230
|
+
// JSON.parse's error and the text it read. Where the error gives no
|
|
231
|
+
// position, as for a word typed without quotes, it is found in the text.
|
|
232
|
+
// What the error quotes of the text is left out: it can hold a line break
|
|
233
|
+
// or any other character the file has.
|
|
234
|
+
function jsonProblem(err, text) {
|
|
235
|
+
const message = String((err && err.message) || err);
|
|
236
|
+
const m = /^(.*?(?: after JSON)?)(?: in JSON)? at position (\d+)/.exec(message);
|
|
237
|
+
const at = m ? Number(m[2]) : badAt(text);
|
|
238
|
+
const why = (m ? m[1] : message.replace(/, (?:\.\.\.)?".*$/s, "").replace(/^(Unexpected end of JSON input).*$/s, "$1")).replace(/[\u0000-\u001f\u007f]+/g, " ");
|
|
239
|
+
if (at < 0) return `: ${why}`;
|
|
240
|
+
const before = text.slice(0, at);
|
|
241
|
+
const line = before.split("\n").length;
|
|
242
|
+
const column = Array.from(before.slice(before.lastIndexOf("\n") + 1)).length + 1;
|
|
243
|
+
return ` at line ${line}, column ${column}: ${why}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The size of a file in words: "65 MB", or "more than 64 MB" for a pipe.
|
|
247
|
+
const sizeText = (err, most) => (err.size ? `${Math.round(err.size / 1048576)} MB` : `more than ${Math.round(most / 1048576)} MB`);
|
|
248
|
+
|
|
249
|
+
module.exports = { readAtMost, readAtMostLater, decodeText, jsonShape, jsonProblem, badAt, sizeText };
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The few things of a font file captions.ass needs: the name libass finds
|
|
4
|
+
// it by, how tall libass makes the font for a size, and how wide each
|
|
5
|
+
// letter is. Read from TrueType and
|
|
6
|
+
// OpenType files, collections of them, and WOFF and WOFF2, the files
|
|
7
|
+
// easyfontkit saves. A file that is not one of them, or is cut short, gives
|
|
8
|
+
// null: the caller then takes Arial's numbers.
|
|
9
|
+
|
|
10
|
+
const fs = require("node:fs");
|
|
11
|
+
const zlib = require("node:zlib");
|
|
12
|
+
|
|
13
|
+
// No font file the captions use is this big, nor do its tables unpack to
|
|
14
|
+
// more; a file made to unpack to gigabytes is not unpacked.
|
|
15
|
+
const MOST = 64 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
// What a file's tables would unpack to is more than the room left for it.
|
|
18
|
+
const TOO_BIG = "too big";
|
|
19
|
+
|
|
20
|
+
// WOFF2 names its tables by a number into this list.
|
|
21
|
+
const KNOWN = "cmap head hhea hmtx maxp name OS/2 post cvt fpgm glyf loca prep CFF VORG EBDT EBLC gasp hdmx kern LTSH PCLT VDMX vhea vmtx BASE GDEF GPOS GSUB EBSC JSTF MATH CBDT CBLC COLR CPAL SVG sbix acnt avar bdat bloc bsln cvar fdsc feat fmtx fvar gvar hsty just lcar mort morx opbd prop trak Zapf Silf Glat Gloc Feat Sill"
|
|
22
|
+
.match(/.{4}\s?/g)
|
|
23
|
+
.map((t) => t.slice(0, 4));
|
|
24
|
+
|
|
25
|
+
const tag = (buf, at) => buf.toString("latin1", at, at + 4);
|
|
26
|
+
|
|
27
|
+
// The tables of a font, { tag: Buffer }, for the ones asked for.
|
|
28
|
+
function sfntTables(buf, at, wanted) {
|
|
29
|
+
const count = buf.readUInt16BE(at + 4);
|
|
30
|
+
const tables = {};
|
|
31
|
+
for (let i = 0; i < count; i++) {
|
|
32
|
+
const rec = at + 12 + i * 16;
|
|
33
|
+
const name = tag(buf, rec);
|
|
34
|
+
if (!wanted.includes(name)) continue;
|
|
35
|
+
const offset = buf.readUInt32BE(rec + 8);
|
|
36
|
+
const length = buf.readUInt32BE(rec + 12);
|
|
37
|
+
if (offset + length > buf.length) throw new Error("cut short");
|
|
38
|
+
tables[name] = buf.subarray(offset, offset + length);
|
|
39
|
+
}
|
|
40
|
+
return tables;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The tables of a WOFF, each packed on its own. What the tables asked for
|
|
44
|
+
// unpack to, as the table directory gives it, is checked against `room`
|
|
45
|
+
// and passed to `charge` before any of them is unpacked, and none unpacks
|
|
46
|
+
// past its own length.
|
|
47
|
+
function woffTables(buf, wanted, room, charge) {
|
|
48
|
+
const count = buf.readUInt16BE(12);
|
|
49
|
+
const records = [];
|
|
50
|
+
for (let i = 0; i < count; i++) {
|
|
51
|
+
const rec = 44 + i * 20;
|
|
52
|
+
const name = tag(buf, rec);
|
|
53
|
+
if (!wanted.includes(name)) continue;
|
|
54
|
+
const offset = buf.readUInt32BE(rec + 4);
|
|
55
|
+
const compressed = buf.readUInt32BE(rec + 8);
|
|
56
|
+
const length = buf.readUInt32BE(rec + 12);
|
|
57
|
+
if (offset + compressed > buf.length) throw new Error("cut short");
|
|
58
|
+
records.push({ name, data: buf.subarray(offset, offset + compressed), length, packed: compressed < length });
|
|
59
|
+
}
|
|
60
|
+
const unpacked = records.filter((r) => r.packed).reduce((sum, r) => sum + r.length, 0);
|
|
61
|
+
if (unpacked > room) throw new Error(TOO_BIG);
|
|
62
|
+
charge(unpacked);
|
|
63
|
+
const tables = {};
|
|
64
|
+
for (const r of records) tables[r.name] = r.packed ? zlib.inflateSync(r.data, { maxOutputLength: Math.max(1, r.length) }) : r.data;
|
|
65
|
+
return tables;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A number of WOFF2's table directory: seven bits a byte, at most five.
|
|
69
|
+
function base128(buf, state) {
|
|
70
|
+
let value = 0;
|
|
71
|
+
for (let i = 0; i < 5; i++) {
|
|
72
|
+
const byte = buf[state.at++];
|
|
73
|
+
if (byte === undefined || (i === 0 && byte === 0x80)) throw new Error("bad number");
|
|
74
|
+
value = value * 128 + (byte & 0x7f);
|
|
75
|
+
if (!(byte & 0x80)) return value;
|
|
76
|
+
}
|
|
77
|
+
throw new Error("bad number");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The tables of a WOFF2, all packed in one stream. The stream's length, the
|
|
81
|
+
// sum of its tables' as the table directory gives them, is checked against
|
|
82
|
+
// `room` and passed to `charge` before the stream is unpacked, and the
|
|
83
|
+
// stream does not unpack past it.
|
|
84
|
+
function woff2Tables(buf, wanted, room, charge) {
|
|
85
|
+
if (tag(buf, 4) === "ttcf") return null;
|
|
86
|
+
const count = buf.readUInt16BE(12);
|
|
87
|
+
const compressed = buf.readUInt32BE(20);
|
|
88
|
+
const state = { at: 48 };
|
|
89
|
+
const entries = [];
|
|
90
|
+
for (let i = 0; i < count; i++) {
|
|
91
|
+
const flags = buf[state.at++];
|
|
92
|
+
let name = KNOWN[flags & 0x3f];
|
|
93
|
+
if ((flags & 0x3f) === 63) {
|
|
94
|
+
name = tag(buf, state.at);
|
|
95
|
+
state.at += 4;
|
|
96
|
+
}
|
|
97
|
+
const version = flags >> 6;
|
|
98
|
+
const length = base128(buf, state);
|
|
99
|
+
// glyf and loca are transformed unless the version is 3, the others
|
|
100
|
+
// only when it is not 0.
|
|
101
|
+
const transformed = name === "glyf" || name === "loca" ? version !== 3 : version !== 0;
|
|
102
|
+
entries.push({ name, length: transformed ? base128(buf, state) : length, transformed });
|
|
103
|
+
}
|
|
104
|
+
if (state.at + compressed > buf.length) throw new Error("cut short");
|
|
105
|
+
const unpacked = entries.reduce((sum, e) => sum + e.length, 0);
|
|
106
|
+
if (unpacked > room) throw new Error(TOO_BIG);
|
|
107
|
+
charge(unpacked);
|
|
108
|
+
const data = zlib.brotliDecompressSync(buf.subarray(state.at, state.at + compressed), { maxOutputLength: Math.max(1, unpacked) });
|
|
109
|
+
const tables = {};
|
|
110
|
+
let at = 0;
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
if (wanted.includes(e.name)) {
|
|
113
|
+
if (at + e.length > data.length) throw new Error("cut short");
|
|
114
|
+
tables[e.name] = Object.assign(data.subarray(at, at + e.length), { transformed: e.transformed });
|
|
115
|
+
}
|
|
116
|
+
at += e.length;
|
|
117
|
+
}
|
|
118
|
+
return tables;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The glyph of each character a cmap maps, in its format 4 or 12 table.
|
|
122
|
+
// A table that says it holds more than it does is cut short: it is
|
|
123
|
+
// checked here, while the file is read, so that no lookup later reads
|
|
124
|
+
// past its end.
|
|
125
|
+
function cmapOf(table) {
|
|
126
|
+
const short = (end) => {
|
|
127
|
+
if (end > table.length) throw new Error("cut short");
|
|
128
|
+
};
|
|
129
|
+
short(4);
|
|
130
|
+
const count = table.readUInt16BE(2);
|
|
131
|
+
short(4 + count * 8);
|
|
132
|
+
let best = null;
|
|
133
|
+
for (let i = 0; i < count; i++) {
|
|
134
|
+
const platform = table.readUInt16BE(4 + i * 8);
|
|
135
|
+
const encoding = table.readUInt16BE(6 + i * 8);
|
|
136
|
+
const offset = table.readUInt32BE(8 + i * 8);
|
|
137
|
+
short(offset + 2);
|
|
138
|
+
const format = table.readUInt16BE(offset);
|
|
139
|
+
const rank = format === 12 && (platform === 3 || platform === 0) ? 2 : format === 4 && ((platform === 3 && encoding === 1) || platform === 0) ? 1 : 0;
|
|
140
|
+
if (rank && (!best || rank > best.rank)) best = { rank, offset, format };
|
|
141
|
+
}
|
|
142
|
+
if (!best) return null;
|
|
143
|
+
const at = best.offset;
|
|
144
|
+
// The first of `n` entries whose end is at or past code, found by halves
|
|
145
|
+
// where the ends come in order, as the format asks, else by going
|
|
146
|
+
// through them: a CJK font has thousands, and every letter of every
|
|
147
|
+
// caption is looked up. `ordered` says which.
|
|
148
|
+
const firstEnding = (n, endOf) => {
|
|
149
|
+
let ordered = true;
|
|
150
|
+
for (let i = 1; i < n && ordered; i++) ordered = endOf(i) >= endOf(i - 1);
|
|
151
|
+
const find = (code) => {
|
|
152
|
+
if (!ordered) {
|
|
153
|
+
for (let i = 0; i < n; i++) if (code <= endOf(i)) return i;
|
|
154
|
+
return n;
|
|
155
|
+
}
|
|
156
|
+
let lo = 0;
|
|
157
|
+
let hi = n;
|
|
158
|
+
while (lo < hi) {
|
|
159
|
+
const mid = (lo + hi) >> 1;
|
|
160
|
+
if (endOf(mid) >= code) hi = mid;
|
|
161
|
+
else lo = mid + 1;
|
|
162
|
+
}
|
|
163
|
+
return lo;
|
|
164
|
+
};
|
|
165
|
+
return Object.assign(find, { ordered });
|
|
166
|
+
};
|
|
167
|
+
if (best.format === 12) {
|
|
168
|
+
short(at + 16);
|
|
169
|
+
const groups = table.readUInt32BE(at + 12);
|
|
170
|
+
short(at + 16 + groups * 12);
|
|
171
|
+
const list = [];
|
|
172
|
+
for (let g = 0; g < groups; g++) list.push([table.readUInt32BE(at + 16 + g * 12), table.readUInt32BE(at + 20 + g * 12), table.readUInt32BE(at + 24 + g * 12)]);
|
|
173
|
+
const find = firstEnding(list.length, (i) => list[i][1]);
|
|
174
|
+
// Groups in order, each after the one before: the group a code is in
|
|
175
|
+
// is the first that ends at or past it.
|
|
176
|
+
const apart = find.ordered && list.every((g, i) => i === 0 || g[0] > list[i - 1][1]);
|
|
177
|
+
return (code) => {
|
|
178
|
+
if (apart) {
|
|
179
|
+
const group = list[find(code)];
|
|
180
|
+
return group && code >= group[0] ? group[2] + code - group[0] : 0;
|
|
181
|
+
}
|
|
182
|
+
for (const [from, to, glyph] of list) if (code >= from && code <= to) return glyph + code - from;
|
|
183
|
+
return 0;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
short(at + 14);
|
|
187
|
+
const segments = Math.floor(table.readUInt16BE(at + 6) / 2);
|
|
188
|
+
const ends = at + 14;
|
|
189
|
+
const starts = ends + segments * 2 + 2;
|
|
190
|
+
const deltas = starts + segments * 2;
|
|
191
|
+
const ranges = deltas + segments * 2;
|
|
192
|
+
short(ranges + segments * 2);
|
|
193
|
+
const find = firstEnding(segments, (s) => table.readUInt16BE(ends + s * 2));
|
|
194
|
+
return (code) => {
|
|
195
|
+
if (code > 0xffff) return 0;
|
|
196
|
+
for (let s = find(code); s < segments; s++) {
|
|
197
|
+
if (code > table.readUInt16BE(ends + s * 2)) continue;
|
|
198
|
+
const start = table.readUInt16BE(starts + s * 2);
|
|
199
|
+
if (code < start) return 0;
|
|
200
|
+
const delta = table.readInt16BE(deltas + s * 2);
|
|
201
|
+
const range = table.readUInt16BE(ranges + s * 2);
|
|
202
|
+
if (!range) return (code + delta) & 0xffff;
|
|
203
|
+
// A glyph looked up past the end of the table is none.
|
|
204
|
+
const where = ranges + s * 2 + range + (code - start) * 2;
|
|
205
|
+
if (where + 2 > table.length) return 0;
|
|
206
|
+
const glyph = table.readUInt16BE(where);
|
|
207
|
+
return glyph ? (glyph + delta) & 0xffff : 0;
|
|
208
|
+
}
|
|
209
|
+
return 0;
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// UTF-16 as the name table keeps it, high byte first.
|
|
214
|
+
function utf16be(bytes) {
|
|
215
|
+
let text = "";
|
|
216
|
+
for (let i = 0; i + 1 < bytes.length; i += 2) text += String.fromCharCode(bytes.readUInt16BE(i));
|
|
217
|
+
return text;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The family name a font file gives itself, the one libass finds a file of
|
|
221
|
+
// its fonts folder by (and Windows a font by): name 1 of its Windows
|
|
222
|
+
// names, in English where it has one. A file with no Windows names gives
|
|
223
|
+
// its Unicode or its Mac one. libass does not look at the typographic
|
|
224
|
+
// family (name 16), so neither does this. Empty when there is none.
|
|
225
|
+
function familyOf(name) {
|
|
226
|
+
if (!name || name.length < 6) return "";
|
|
227
|
+
const count = name.readUInt16BE(2);
|
|
228
|
+
const strings = name.readUInt16BE(4);
|
|
229
|
+
const found = [];
|
|
230
|
+
for (let i = 0; i < count && 6 + i * 12 + 12 <= name.length; i++) {
|
|
231
|
+
const rec = 6 + i * 12;
|
|
232
|
+
const [platform, encoding, language, id, length, offset] = [0, 2, 4, 6, 8, 10].map((k) => name.readUInt16BE(rec + k));
|
|
233
|
+
const at = strings + offset;
|
|
234
|
+
if (id !== 1 || at + length > name.length || (platform === 1 && encoding !== 0) || ![0, 1, 3].includes(platform)) continue;
|
|
235
|
+
const bytes = name.subarray(at, at + length);
|
|
236
|
+
const text = (platform === 1 ? bytes.toString("latin1") : utf16be(bytes)).replace(/\u0000/g, "").trim();
|
|
237
|
+
if (text) found.push({ platform, language, text });
|
|
238
|
+
}
|
|
239
|
+
const pick = found.find((n) => n.platform === 3 && n.language === 0x409) || found.find((n) => n.platform === 3) || found.find((n) => n.platform === 0) || found[0];
|
|
240
|
+
return pick ? pick.text : "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The advance of each glyph, from hmtx as it is or as WOFF2 keeps it,
|
|
244
|
+
// with its side bearings left out.
|
|
245
|
+
function advancesOf(hmtx, count) {
|
|
246
|
+
const at = hmtx.transformed ? 1 : 0;
|
|
247
|
+
const step = hmtx.transformed ? 2 : 4;
|
|
248
|
+
if (at + count * step > hmtx.length) throw new Error("cut short");
|
|
249
|
+
const widths = new Uint16Array(count);
|
|
250
|
+
for (let i = 0; i < count; i++) widths[i] = hmtx.readUInt16BE(at + i * step);
|
|
251
|
+
return widths;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// { family, unitsPerEm, winAscent, winDescent, ascender, descender,
|
|
255
|
+
// advance }: family is the name libass finds the file by (empty when it
|
|
256
|
+
// gives none), advance(character) a letter's width in ems, or NaN when the
|
|
257
|
+
// font has no glyph for it. `index` picks a font of a collection.
|
|
258
|
+
const readFont = (file, index = 0) => loadFont(file, index).font;
|
|
259
|
+
|
|
260
|
+
// { font, size }: the font as readFont gives it, or null, and the bytes
|
|
261
|
+
// reading it took: the file's, and what its tables unpacked to. A file
|
|
262
|
+
// that would take more than `budget` is not unpacked, and its font is
|
|
263
|
+
// null. The font keeps a copy of the cmap and the widths it reads, and
|
|
264
|
+
// nothing else of the file, so a font kept for a whole run takes little
|
|
265
|
+
// memory however much its file unpacked to.
|
|
266
|
+
function loadFont(file, index = 0, budget = Infinity) {
|
|
267
|
+
let size = 0;
|
|
268
|
+
try {
|
|
269
|
+
const stat = fs.statSync(file);
|
|
270
|
+
if (!stat.isFile() || stat.size > MOST || stat.size > budget) return { font: null, size: 0 };
|
|
271
|
+
const buf = fs.readFileSync(file);
|
|
272
|
+
size = buf.length;
|
|
273
|
+
const room = Math.min(MOST, budget - size);
|
|
274
|
+
const wanted = ["head", "hhea", "OS/2", "cmap", "hmtx", "name"];
|
|
275
|
+
const kind = tag(buf, 0);
|
|
276
|
+
let tables;
|
|
277
|
+
const charge = (n) => (size += n);
|
|
278
|
+
if (kind === "wOF2") tables = woff2Tables(buf, wanted, room, charge);
|
|
279
|
+
else if (kind === "wOFF") tables = woffTables(buf, wanted, room, charge);
|
|
280
|
+
else if (kind === "ttcf") {
|
|
281
|
+
const fonts = buf.readUInt32BE(8);
|
|
282
|
+
if (index >= fonts) return { font: null, size };
|
|
283
|
+
tables = sfntTables(buf, buf.readUInt32BE(12 + index * 4), wanted);
|
|
284
|
+
} else if (kind === "\u0000\u0001\u0000\u0000" || kind === "OTTO" || kind === "true") tables = sfntTables(buf, 0, wanted);
|
|
285
|
+
if (!tables || !tables.head || !tables.hhea || tables.head.readUInt32BE(12) !== 0x5f0f3cf5) return { font: null, size };
|
|
286
|
+
const unitsPerEm = tables.head.readUInt16BE(18);
|
|
287
|
+
if (!(unitsPerEm >= 16 && unitsPerEm <= 16384)) return { font: null, size };
|
|
288
|
+
const os2 = tables["OS/2"] && tables["OS/2"].length >= 78 ? tables["OS/2"] : null;
|
|
289
|
+
let family = "";
|
|
290
|
+
try {
|
|
291
|
+
family = familyOf(tables.name);
|
|
292
|
+
} catch {
|
|
293
|
+
// A name table cut short names nothing.
|
|
294
|
+
}
|
|
295
|
+
const font = {
|
|
296
|
+
family,
|
|
297
|
+
unitsPerEm,
|
|
298
|
+
ascender: tables.hhea.readInt16BE(4),
|
|
299
|
+
descender: tables.hhea.readInt16BE(6),
|
|
300
|
+
winAscent: os2 ? os2.readUInt16BE(74) : 0,
|
|
301
|
+
winDescent: os2 ? os2.readUInt16BE(76) : 0,
|
|
302
|
+
advance: () => NaN,
|
|
303
|
+
};
|
|
304
|
+
if (tables.cmap && tables.hmtx) {
|
|
305
|
+
// A copy: the lookup keeps the cmap, and a view would keep the whole
|
|
306
|
+
// file, or all it unpacked to, with it.
|
|
307
|
+
const glyphOf = cmapOf(Buffer.from(tables.cmap));
|
|
308
|
+
const widths = advancesOf(tables.hmtx, tables.hhea.readUInt16BE(34));
|
|
309
|
+
if (glyphOf && widths.length) {
|
|
310
|
+
font.advance = (c) => {
|
|
311
|
+
const glyph = glyphOf(c.codePointAt(0));
|
|
312
|
+
return glyph ? widths[Math.min(glyph, widths.length - 1)] / unitsPerEm : NaN;
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { font, size };
|
|
317
|
+
} catch {
|
|
318
|
+
return { font: null, size };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// What a font file loadFont reads starts with: WOFF2, WOFF, a collection,
|
|
323
|
+
// TrueType or OpenType.
|
|
324
|
+
const KINDS = new Set(["wOF2", "wOFF", "ttcf", "\u0000\u0001\u0000\u0000", "OTTO", "true"]);
|
|
325
|
+
|
|
326
|
+
// Whether a file is one loadFont would read: a file of no more than MOST
|
|
327
|
+
// bytes that starts as a font file does. Only its first four bytes are
|
|
328
|
+
// read, so a video named as a font is not.
|
|
329
|
+
function fontLike(file) {
|
|
330
|
+
let fd;
|
|
331
|
+
try {
|
|
332
|
+
const stat = fs.statSync(file);
|
|
333
|
+
if (!stat.isFile() || stat.size > MOST) return false;
|
|
334
|
+
fd = fs.openSync(file, "r");
|
|
335
|
+
const head = Buffer.alloc(4);
|
|
336
|
+
return fs.readSync(fd, head, 0, 4, 0) === 4 && KINDS.has(tag(head, 0));
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
} finally {
|
|
340
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// The ASS font size for a font whose em is one pixel. libass, as VSFilter
|
|
345
|
+
// does, makes the font as tall as its OS/2 winAscent and winDescent: an
|
|
346
|
+
// em is the size times unitsPerEm over their sum, where the font has
|
|
347
|
+
// both those and its hhea ascender and descender.
|
|
348
|
+
function assScale(font) {
|
|
349
|
+
const win = font.winAscent + font.winDescent;
|
|
350
|
+
const hhea = font.ascender - font.descender;
|
|
351
|
+
const height = win > 0 && hhea > 0 ? win : hhea > 0 ? hhea : win;
|
|
352
|
+
return height > 0 ? height / font.unitsPerEm : 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = { readFont, loadFont, assScale, fontLike };
|