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.
@@ -0,0 +1,571 @@
1
+ "use strict";
2
+
3
+ // The look of the captions from the product's own kit: the font files
4
+ // easyfontkit saved (fonts.css, and fonts.json for each family's license),
5
+ // and the colors easybrandkit read (brand.json, or _brand.yml). Without
6
+ // them the captions are white, bold and outlined, in a common sans.
7
+
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
10
+ const { spawnSync } = require("node:child_process");
11
+ const { pathToFileURL } = require("node:url");
12
+ const { readAtMost, sizeText } = require("./files");
13
+ const { ARABIC: ARABIC_LETTER } = require("./text");
14
+
15
+ const DEFAULT_HIGHLIGHT = "#ffd23f";
16
+ // The emoji fonts come before Noto Sans and DejaVu Sans, which draw some
17
+ // emoji in black and white: 😀 would be an outline next to a 👍 in color.
18
+ const EMOJI = ['"Noto Color Emoji"', '"Apple Color Emoji"', '"Segoe UI Emoji"'];
19
+ const LATIN = ['"Helvetica Neue"', "Helvetica", "Arial", '"Liberation Sans"'];
20
+ const REST = ['"Noto Sans"', '"DejaVu Sans"', "sans-serif"];
21
+ // The CJK families of each language on Linux, macOS and Windows. Named in
22
+ // the list, they are matched with the weight asked for; letters left to
23
+ // the system's own fallback come in its regular face, which no bold is
24
+ // made of, as font-synthesis is off.
25
+ const CJK = {
26
+ ja: ['"Noto Sans CJK JP"', '"Noto Sans JP"', '"Hiragino Sans"', '"Hiragino Kaku Gothic ProN"', '"Yu Gothic"', "Meiryo"],
27
+ ko: ['"Noto Sans CJK KR"', '"Noto Sans KR"', '"Apple SD Gothic Neo"', '"Malgun Gothic"'],
28
+ "zh-Hans": ['"Noto Sans CJK SC"', '"Noto Sans SC"', '"PingFang SC"', '"Microsoft YaHei"'],
29
+ "zh-Hant": ['"Noto Sans CJK TC"', '"Noto Sans TC"', '"PingFang TC"', '"Microsoft JhengHei"'],
30
+ };
31
+ // The Arabic script's families on Linux, macOS and Windows, for words in
32
+ // Arabic, Persian or Urdu, named for the same reason: Chrome's fallback
33
+ // on Ubuntu 22.04 gives Noto Kufi Arabic's regular face for them.
34
+ const ARABIC = ['"Noto Sans Arabic"', '"Noto Naskh Arabic"', '"Geeza Pro"', '"Segoe UI"'];
35
+ // The families of the scripts of India and Southeast Asia on Linux, macOS
36
+ // and Windows, named when the words hold letters of their script, for the
37
+ // same reason: Chrome's fallback gives Noto Sans Devanagari's, Bengali's
38
+ // or Thai's regular face though its bold is there.
39
+ const SCRIPT_FAMILIES = {
40
+ Devanagari: ['"Noto Sans Devanagari"', '"Kohinoor Devanagari"', '"Devanagari Sangam MN"', '"Nirmala UI"'],
41
+ Bengali: ['"Noto Sans Bengali"', '"Kohinoor Bangla"', '"Bangla Sangam MN"', '"Nirmala UI"'],
42
+ Gurmukhi: ['"Noto Sans Gurmukhi"', '"Gurmukhi Sangam MN"', '"Nirmala UI"'],
43
+ Gujarati: ['"Noto Sans Gujarati"', '"Kohinoor Gujarati"', '"Gujarati Sangam MN"', '"Nirmala UI"'],
44
+ Oriya: ['"Noto Sans Oriya"', '"Oriya Sangam MN"', '"Nirmala UI"'],
45
+ Tamil: ['"Noto Sans Tamil"', '"Tamil Sangam MN"', '"Nirmala UI"'],
46
+ Telugu: ['"Noto Sans Telugu"', '"Kohinoor Telugu"', '"Telugu Sangam MN"', '"Nirmala UI"'],
47
+ Kannada: ['"Noto Sans Kannada"', '"Kannada Sangam MN"', '"Nirmala UI"'],
48
+ Malayalam: ['"Noto Sans Malayalam"', '"Malayalam Sangam MN"', '"Nirmala UI"'],
49
+ Sinhala: ['"Noto Sans Sinhala"', '"Sinhala Sangam MN"', '"Nirmala UI"'],
50
+ Thai: ['"Noto Sans Thai"', "Thonburi", '"Leelawadee UI"'],
51
+ Lao: ['"Noto Sans Lao"', '"Lao Sangam MN"', '"Leelawadee UI"'],
52
+ Khmer: ['"Noto Sans Khmer"', '"Khmer Sangam MN"', '"Leelawadee UI"'],
53
+ Myanmar: ['"Noto Sans Myanmar"', '"Myanmar Sangam MN"', '"Myanmar Text"'],
54
+ };
55
+
56
+ // ─── colors ──────────────────────────────────────────────────────────────
57
+
58
+ // A color as text or a number of six digits, from --highlight or a brand
59
+ // kit; anything else a brand.json gives is none.
60
+ function parseColor(value) {
61
+ const text = (typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) ? String(value) : "").trim().toLowerCase();
62
+ let m = /^#?([0-9a-f]{6})$/.exec(text);
63
+ if (m) return `#${m[1]}`;
64
+ m = /^#?([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(text);
65
+ if (m) return `#${m[1]}${m[1]}${m[2]}${m[2]}${m[3]}${m[3]}`;
66
+ return null;
67
+ }
68
+
69
+ function rgb(hex) {
70
+ return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
71
+ }
72
+
73
+ function luminance(hex) {
74
+ const [r, g, b] = rgb(hex).map((c) => {
75
+ const s = c / 255;
76
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
77
+ });
78
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
79
+ }
80
+
81
+ // WCAG contrast ratio, 1 to 21.
82
+ function contrast(a, b) {
83
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
84
+ return (x + 0.05) / (y + 0.05);
85
+ }
86
+
87
+ function hsl(hex) {
88
+ const [r, g, b] = rgb(hex).map((c) => c / 255);
89
+ const max = Math.max(r, g, b);
90
+ const min = Math.min(r, g, b);
91
+ const l = (max + min) / 2;
92
+ const d = max - min;
93
+ const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
94
+ return { s, l };
95
+ }
96
+
97
+ // How the spoken word is lit: in the color itself when it reads against
98
+ // the dark outline or box, or on a rounded patch of the color when the
99
+ // color is too dark for that (a deep brand green, a navy).
100
+ function highlightMode(color, { box = false } = {}) {
101
+ const ground = "#000000";
102
+ if (contrast(color, ground) >= (box ? 3.5 : 4.5) && contrast(color, "#ffffff") >= 1.3) return { mode: "text", ink: color };
103
+ return { mode: "patch", ink: contrast("#ffffff", color) >= 3 ? "#ffffff" : "#000000" };
104
+ }
105
+
106
+ // The brand color that lights words best: a button's, a link's or the
107
+ // logo's, saturated and neither near black nor near white.
108
+ const ROLE_ORDER = ["button", "link", "logo", "header", "heading", "footer", "surface"];
109
+ function brandHighlight(colors) {
110
+ const usable = colors.filter((c) => {
111
+ const { s, l } = hsl(c.hex);
112
+ return s >= 0.25 && l >= 0.12 && l <= 0.9;
113
+ });
114
+ usable.sort((a, b) => rank(a.role) - rank(b.role));
115
+ return usable[0] || null;
116
+ }
117
+ const rank = (role) => {
118
+ const i = ROLE_ORDER.indexOf(String(role).replace(/-\d+$/, ""));
119
+ return i < 0 ? ROLE_ORDER.length : i;
120
+ };
121
+
122
+ // ─── names from other tools ───────────────────────────────────────────────
123
+
124
+ // Font names and ranges come from fonts.css and brand.json, which other
125
+ // tools read from websites, and they end up in the page that draws the
126
+ // captions and in captions.ass. A family name holds no control characters
127
+ // and no < or >; a unicode-range is U+ ranges and nothing else.
128
+ const fontName = (name) => typeof name === "string" && name.length <= 200 && !/[\u0000-\u001f\u007f<>]/.test(name);
129
+ const RANGE = /^U\+[0-9a-f?]{1,6}(?:-[0-9a-f]{1,6})?(?:\s*,\s*U\+[0-9a-f?]{1,6}(?:-[0-9a-f]{1,6})?)*$/i;
130
+
131
+ // What a name that is no file or folder is: a pipe, a device or a socket.
132
+ function notAFile(file) {
133
+ let stat = null;
134
+ try {
135
+ stat = fs.statSync(file);
136
+ } catch {
137
+ return "";
138
+ }
139
+ if (stat.isFile() || stat.isDirectory()) return "";
140
+ return stat.isFIFO() ? "a pipe" : stat.isSocket() ? "a socket" : "a device";
141
+ }
142
+
143
+ // A file other tools wrote, read whole, as long as it is the size such a
144
+ // file has. easybrandkit and easyfontkit write files, and only a file is
145
+ // read: a pipe that nothing writes to yet would hold the run in the
146
+ // system, where neither Ctrl-C nor a kill reaches it. `kit` says what to
147
+ // give instead.
148
+ function readSmall(file, what, kit, most = 8 * 1024 * 1024) {
149
+ const odd = notAFile(file);
150
+ if (odd) throw Object.assign(new Error(`${file} is ${odd}, not a file. Give ${kit}`), { code: "ENOTFILE" });
151
+ try {
152
+ return readAtMost(file, most);
153
+ } catch (err) {
154
+ if (err.code === "EFBIG") throw new Error(`${file} is ${sizeText(err, most)}, too big to be ${what}`);
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ // ─── the brand folder ─────────────────────────────────────────────────────
160
+
161
+ // An easybrandkit folder, its brand.json or its _brand.yml: the name, the
162
+ // colors with the role the site gives them and the fonts by name.
163
+ function readBrand(given) {
164
+ let file = given;
165
+ if (fs.existsSync(given) && fs.statSync(given).isDirectory()) {
166
+ file = ["brand.json", "_brand.yml", "brand/brand.json"].map((name) => path.join(given, name)).find((f) => fs.existsSync(f));
167
+ if (!file) throw new Error(`${given} holds no brand.json or _brand.yml. Give the folder easybrandkit made, or its brand.json`);
168
+ } else if (!fs.existsSync(given)) throw new Error(`Cannot read ${given}: no such file or folder`);
169
+ const text = readSmall(file, "a brand kit's file", "the folder easybrandkit made, or its brand.json");
170
+ if (/\.ya?ml$/i.test(file)) return { file, ...brandYml(text) };
171
+ let data;
172
+ try {
173
+ data = JSON.parse(text);
174
+ } catch (err) {
175
+ throw new Error(`Cannot read ${file}: ${err.message}`);
176
+ }
177
+ if (!object(data)) throw new Error(`Cannot read ${file}: it holds no brand kit`);
178
+ const colors = (Array.isArray(data.colors) ? data.colors : [])
179
+ .map((c) => ({ hex: parseColor(c && c.hex), role: textOf(c && c.role) }))
180
+ .filter((c) => c.hex);
181
+ const fonts = object(data.fonts) ? data.fonts : {};
182
+ return { file, name: textOf(data.name), colors, fonts: { heading: textOf(fonts.heading), body: textOf(fonts.body) } };
183
+ }
184
+
185
+ // A JSON object, not null, a list or a value; and a value that is text.
186
+ const object = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
187
+ const textOf = (value) => (typeof value === "string" ? value : "");
188
+
189
+ // The brand's font names that can be used as names; the others are named
190
+ // in a note and left out.
191
+ function brandFonts(brand, notes) {
192
+ const out = {};
193
+ for (const role of ["heading", "body"]) {
194
+ const name = brand.fonts[role];
195
+ if (name && !fontName(name)) notes.push(`${path.basename(brand.file)} names a ${role} font with characters a font name cannot have, so it was left out.`);
196
+ out[role] = name && fontName(name) ? name : "";
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // The few parts of a brand.yml the captions use: the palette, the theme
202
+ // colors and the heading and base fonts.
203
+ function brandYml(text) {
204
+ const colors = [];
205
+ const fonts = { heading: "", body: "" };
206
+ let name = "";
207
+ let section = "";
208
+ let sub = "";
209
+ for (const raw of text.split(/\r?\n/)) {
210
+ const line = raw.trim();
211
+ if (!line || line.startsWith("#")) continue;
212
+ const indent = raw.match(/^ */)[0].length;
213
+ // The key and the rest of the line, split at the first colon.
214
+ const colon = line.indexOf(":");
215
+ const key = colon > 0 ? line.slice(0, colon).trim() : "";
216
+ if (!/^[\w-]+$/.test(key)) continue;
217
+ const value = line.slice(colon + 1).trim().replace(/^["']|["']$/g, "");
218
+ if (indent === 0) {
219
+ section = key;
220
+ sub = "";
221
+ continue;
222
+ }
223
+ if (section === "meta" && key === "name" && indent <= 2) name = value;
224
+ if (section === "color") {
225
+ if (indent === 2) sub = key;
226
+ const hex = parseColor(value);
227
+ if (hex && (sub === "palette" ? indent >= 4 : indent === 2)) colors.push({ hex, role: key === "primary" ? "button" : key });
228
+ }
229
+ if (section === "typography" && indent === 2) {
230
+ if (key === "headings" && value) fonts.heading = value;
231
+ if (key === "base" && value) fonts.body = value;
232
+ }
233
+ }
234
+ return { name, colors, fonts };
235
+ }
236
+
237
+ // ─── fonts.css ────────────────────────────────────────────────────────────
238
+
239
+ const unquote = (text) => String(text || "").trim().replace(/^["']|["']$/g, "").trim();
240
+
241
+ function weightRange(value) {
242
+ const text = String(value || "400").trim().toLowerCase();
243
+ const named = { normal: 400, bold: 700, lighter: 300, bolder: 800 };
244
+ const parts = text.split(/\s+/).map((p) => (Object.hasOwn(named, p) ? named[p] : Number(p))).filter(Number.isFinite);
245
+ if (!parts.length) return [400, 400];
246
+ return [Math.min(...parts), Math.max(...parts)];
247
+ }
248
+
249
+ // A url() of fonts.css as a path: percent escapes decoded, or the text as
250
+ // it is when a % in it is no escape.
251
+ function urlPath(url) {
252
+ const raw = url.replace(/^file:\/\//, "");
253
+ try {
254
+ return decodeURIComponent(raw);
255
+ } catch {
256
+ return raw;
257
+ }
258
+ }
259
+
260
+ // A font file fonts.css names, as easyfontkit saves them: in the folder of
261
+ // fonts.css or under it, reached without a link. { file, name } with the
262
+ // whole path (empty when there is no such file) and the name from that
263
+ // folder, or null for any other path, which a fonts.css made elsewhere
264
+ // could name to have files read from anywhere on the disk; that one is
265
+ // left out without looking at it.
266
+ function inKit(dir, given) {
267
+ const full = path.resolve(dir, given);
268
+ const rel = path.relative(dir, full);
269
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
270
+ const name = rel.split(path.sep).join("/");
271
+ let at = dir;
272
+ for (const part of rel.split(path.sep)) {
273
+ at = path.join(at, part);
274
+ const stat = fs.lstatSync(at, { throwIfNoEntry: false });
275
+ if (!stat) return { file: "", name };
276
+ if (stat.isSymbolicLink()) return null;
277
+ }
278
+ return fs.statSync(at).isFile() ? { file: full, name } : { file: "", name };
279
+ }
280
+
281
+ // CSS without its comments: each goes to its first */, or to the end of
282
+ // the text when it never closes, as CSS reads it. Each is looked for from
283
+ // where the one before ended, so a file of many that never close is read
284
+ // once, not again from each.
285
+ function withoutComments(css) {
286
+ let out = "";
287
+ let at = 0;
288
+ for (;;) {
289
+ const open = css.indexOf("/*", at);
290
+ if (open < 0) return out + css.slice(at);
291
+ out += css.slice(at, open);
292
+ const close = css.indexOf("*/", open + 2);
293
+ if (close < 0) return out;
294
+ at = close + 2;
295
+ }
296
+ }
297
+
298
+ // easyfontkit's fonts.css: each @font-face with its file in the kit's
299
+ // folder, and the family each role (--font-heading, --font-body ...)
300
+ // starts with.
301
+ function readFontsCss(given) {
302
+ let file = given;
303
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) {
304
+ file = path.join(given, "fonts.css");
305
+ if (!fs.existsSync(file)) throw new Error(`${given} holds no fonts.css. Give the folder easyfontkit made, or its fonts.css`);
306
+ }
307
+ // A pipe could not carry the font files fonts.css names either: they are
308
+ // next to it.
309
+ let css;
310
+ try {
311
+ css = readSmall(file, "a fonts.css", "the folder easyfontkit made, or its fonts.css with the font files it names next to it");
312
+ } catch (err) {
313
+ throw new Error(err.code === "ENOENT" ? `Cannot read ${file}: no such file` : err.code === "ENOTFILE" || /too big/.test(err.message) ? err.message : `Cannot read ${file}: ${err.message}`);
314
+ }
315
+ css = withoutComments(css);
316
+ const dir = path.dirname(path.resolve(file));
317
+ const faces = [];
318
+ const missing = [];
319
+ const outside = new Set();
320
+ const odd = new Set();
321
+ // A block holds no other brace, and a url() no other bracket, so a file
322
+ // of many that never close is looked at once, not again from each.
323
+ for (const block of css.matchAll(/@font-face\s*\{([^{}]*)\}/gi)) {
324
+ const body = block[1];
325
+ const prop = (name) => (new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, "i").exec(body) || [])[1];
326
+ const family = unquote(prop("font-family"));
327
+ if (!family) continue;
328
+ if (!fontName(family)) {
329
+ odd.add(family);
330
+ continue;
331
+ }
332
+ const urls = [...String(prop("src") || "").matchAll(/url\(([^()]*)\)/gi)].map((m) => unquote(m[1])).filter(Boolean);
333
+ // Only files on this disk, the kit's own: the captions are drawn
334
+ // offline.
335
+ const local = [];
336
+ for (const url of urls.filter((u) => !/^(?:https?:|data:)/i.test(u))) {
337
+ const where = inKit(dir, urlPath(url));
338
+ if (where) local.push(where);
339
+ else outside.add(url);
340
+ }
341
+ const found = local.find((f) => f.file);
342
+ if (!found) {
343
+ if (local.length) missing.push(local[0].name);
344
+ continue;
345
+ }
346
+ // A range the browser would not take is left out, as the browser
347
+ // would leave it out: the face then covers every letter it has.
348
+ const range = String(prop("unicode-range") || "").trim();
349
+ faces.push({
350
+ family,
351
+ file: found.file,
352
+ name: found.name,
353
+ weight: weightRange(prop("font-weight")),
354
+ italic: /italic|oblique/i.test(String(prop("font-style") || "")),
355
+ unicodeRange: RANGE.test(range) ? range : "",
356
+ });
357
+ }
358
+ // Each declaration on its own, so a role whose value never ends is read
359
+ // to the end of its rule once.
360
+ const roles = {};
361
+ for (const declaration of css.split(/[;{}]/)) {
362
+ const m = /^\s*--font-(heading|body|button|code)\s*:(.*)$/is.exec(declaration);
363
+ if (!m) continue;
364
+ const firstFamily = unquote(m[2].split(",")[0]);
365
+ if (firstFamily && fontName(firstFamily) && !roles[m[1].toLowerCase()]) roles[m[1].toLowerCase()] = firstFamily;
366
+ }
367
+ // fonts.json next to it says how each family is licensed. One that is no
368
+ // file is passed over, as one that is not JSON is.
369
+ let kit = null;
370
+ try {
371
+ kit = JSON.parse(readSmall(path.join(dir, "fonts.json"), "a fonts.json", "the folder easyfontkit made"));
372
+ } catch {
373
+ kit = null;
374
+ }
375
+ return { file: path.resolve(file), faces, roles, missing, outside: [...outside], odd: [...odd], kit };
376
+ }
377
+
378
+ // ─── the choice ───────────────────────────────────────────────────────────
379
+
380
+ // Two font names, the same but for case and the spaces around them. A name
381
+ // that is not text, as a fonts.json made elsewhere can give, is no name.
382
+ const sameName = (a, b) => typeof a === "string" && typeof b === "string" && a.trim().toLowerCase() === b.trim().toLowerCase();
383
+
384
+ // The weight to draw with: bold where the family has it, else the
385
+ // heaviest it has, so Chrome never fakes a bold.
386
+ function pickWeight(faces) {
387
+ const upright = faces.filter((f) => !f.italic);
388
+ const list = upright.length ? upright : faces;
389
+ if (!list.length) return 700;
390
+ if (list.some((f) => f.weight[0] <= 700 && f.weight[1] >= 700)) return 700;
391
+ const heavy = list.filter((f) => f.weight[0] >= 600).sort((a, b) => a.weight[0] - b.weight[0]);
392
+ if (heavy.length) return heavy[0].weight[0];
393
+ return Math.max(...list.map((f) => f.weight[1]));
394
+ }
395
+
396
+ // The font file libass cannot draw at the weight asked for: one face of
397
+ // fonts.css for a range of weights (a variable font), when no face has
398
+ // that weight alone. libass draws such a file at its default weight, as
399
+ // its own, while Chrome picks the weight from the range. Null otherwise.
400
+ function variableFace(look) {
401
+ const upright = look.faces.filter((f) => !f.italic);
402
+ const faces = upright.length ? upright : look.faces;
403
+ if (faces.some((f) => f.weight[0] === look.weight && f.weight[1] === look.weight)) return null;
404
+ return faces.find((f) => f.weight[0] < f.weight[1] && f.weight[0] <= look.weight && f.weight[1] >= look.weight) || null;
405
+ }
406
+
407
+ // What fc-match gives for a pattern: { family, style, file, index,
408
+ // outline }, outline false for a font of pictures only, such as a color
409
+ // emoji font. Null without fc-match, or when it gives no file.
410
+ function fcMatch(pattern) {
411
+ let r;
412
+ try {
413
+ r = spawnSync("fc-match", ["-f", "%{family[0]}\t%{style[0]}\t%{file}\t%{index}\t%{outline}", pattern], { encoding: "utf8", timeout: 10_000 });
414
+ } catch {
415
+ return null;
416
+ }
417
+ if (r.error || r.status !== 0 || !r.stdout) return null;
418
+ const [family, style, file, index, outline] = r.stdout.split("\t");
419
+ if (!family || !file || !path.isAbsolute(file)) return null;
420
+ return { family, style: style || "", file, index: Number(index) || 0, outline: outline === "True" };
421
+ }
422
+
423
+ // A family name in a fontconfig pattern, where a backslash, dash, colon or
424
+ // comma is escaped; and the weight captions.ass asks for, bold at 600 and
425
+ // up.
426
+ const fcName = (family) => String(family).replace(/[\\\-:,]/g, "\\$&");
427
+ const fcWeight = (weight) => `:weight=${weight >= 600 ? 200 : 80}`;
428
+
429
+ // The font fontconfig gives for a family name, which is the one libass
430
+ // draws with on Linux, where it looks fonts up through fontconfig.
431
+ // { family, style, file, index }, or null on other systems, without
432
+ // fc-match, or when it gives nothing.
433
+ function machineFont(family, weight, { platform = process.platform, match = fcMatch } = {}) {
434
+ if (platform !== "linux") return null;
435
+ const got = match(`${fcName(family)}${fcWeight(weight)}`);
436
+ return got ? { family: got.family, style: got.style, file: got.file, index: got.index } : null;
437
+ }
438
+
439
+ // fontconfig's names of the CJK languages cjkLanguage gives.
440
+ const FC_LANG = { ja: "ja", ko: "ko", "zh-Hans": "zh-cn", "zh-Hant": "zh-tw" };
441
+
442
+ // The families an Arabic letter is looked for in first, in the order of
443
+ // the list Chrome draws with: the Arabic ones, then those of the list's
444
+ // end that have Arabic letters, such as DejaVu Sans.
445
+ const ARABIC_ORDER = [...ARABIC, ...REST].map(unquote).filter((name) => name !== "sans-serif");
446
+
447
+ // The font fontconfig gives for a letter a font lacks, as libass draws it
448
+ // with one on Linux, bold as captions.ass asks for it and in the language
449
+ // of the words. An Arabic letter is set in the first family of
450
+ // ARABIC_ORDER that has it, the one Chrome draws it with. { family, style,
451
+ // file, index, outline }, where outline is false for a font libass cannot
452
+ // draw, as it draws no color emoji; null on other systems or without
453
+ // fc-match. `match` stands in for fc-match in the tests.
454
+ function fallbackFont(codePoint, weight, lang = "", { platform = process.platform, match = fcMatch } = {}) {
455
+ if (platform !== "linux" || !Number.isInteger(codePoint)) return null;
456
+ const charset = `:charset=${codePoint.toString(16)}`;
457
+ // fontconfig weighs a font's letters before its name, so it gives the
458
+ // family named only when that family has the letter, or no font does.
459
+ if (ARABIC_LETTER.test(String.fromCodePoint(codePoint))) {
460
+ for (const name of ARABIC_ORDER) {
461
+ const got = match(`${fcName(name)}${charset}${fcWeight(weight)}`);
462
+ if (got && got.outline && sameName(got.family, name)) return got;
463
+ }
464
+ }
465
+ return match(`sans-serif${charset}${fcWeight(weight)}${FC_LANG[lang] ? `:lang=${FC_LANG[lang]}` : ""}`);
466
+ }
467
+
468
+ // A fact of fonts.json as text on one line, and not too long for one:
469
+ // easyfontkit copies a copyright from the font file's names as it is, over
470
+ // several lines at times, and CAPTIONS.md gives each fact in a sentence or
471
+ // a list item. A fact that is not text is none.
472
+ function factOf(value, most = 300) {
473
+ const one = textOf(value).replace(/[\s\u0000-\u001f\u007f]+/g, " ").trim();
474
+ return one.length > most ? `${one.slice(0, most - 1).trim()}…` : one;
475
+ }
476
+
477
+ // The license facts fonts.json gives for a family, passing over what in
478
+ // its list is no family.
479
+ function licenseOf(kit, family) {
480
+ if (!object(kit) || !Array.isArray(kit.families)) return null;
481
+ const entry = kit.families.filter(object).find((f) => sameName(f.name, family) || (Array.isArray(f.cssNames) && f.cssNames.some((n) => sameName(n, family))));
482
+ if (!entry) return null;
483
+ const l = object(entry.license) ? entry.license : {};
484
+ const free = Array.isArray(entry.free) && object(entry.free[0]) && factOf(entry.free[0].family) ? entry.free[0] : null;
485
+ return {
486
+ family: factOf(entry.name) || family,
487
+ status: factOf(l.status) || "unknown",
488
+ name: factOf(l.name),
489
+ url: factOf(l.url),
490
+ maker: factOf(l.maker),
491
+ copyright: factOf(l.copyright),
492
+ basis: factOf(l.basis),
493
+ from: factOf(entry.host),
494
+ free: free ? { family: factOf(free.family), from: factOf(free.from), license: factOf(free.license) } : null,
495
+ };
496
+ }
497
+
498
+ // options: { fonts (path to fonts.css), brand (path), font (family name),
499
+ // highlight (color), box, lang (of CJK words, from cjkLanguage), arabic
500
+ // (whether the words hold Arabic letters), scripts (the scripts of
501
+ // SCRIPT_FAMILIES they hold letters of, from scriptsIn) }
502
+ function chooseLook(options = {}) {
503
+ const notes = [];
504
+ if (options.font && !fontName(options.font)) throw new Error("--font takes a font family name, like Inter");
505
+ const css = options.fonts ? readFontsCss(options.fonts) : null;
506
+ const brand = options.brand ? readBrand(options.brand) : null;
507
+ if (css && css.missing.length) notes.push(`fonts.css names ${css.missing.length} font ${css.missing.length === 1 ? "file" : "files"} that ${css.missing.length === 1 ? "is" : "are"} not on this disk, like ${css.missing[0]}.`);
508
+ if (css && css.outside.length) notes.push(`fonts.css names ${css.outside.length} font ${css.outside.length === 1 ? "file" : "files"} outside its own folder or through a link, like ${css.outside[0]}, and ${css.outside.length === 1 ? "it was" : "they were"} left out: easycaptionkit reads font files only from the folder of fonts.css, where easyfontkit saves them.`);
509
+ if (css && css.odd.length) notes.push(`fonts.css names ${css.odd.length === 1 ? "a family" : `${css.odd.length} families`} with characters a font name cannot have, so ${css.odd.length === 1 ? "it was" : "they were"} left out.`);
510
+ const brandNames = brand ? brandFonts(brand, notes) : { heading: "", body: "" };
511
+ let family = "";
512
+ let from = "default";
513
+ if (options.font) {
514
+ family = options.font;
515
+ from = css && css.faces.some((f) => sameName(f.family, family)) ? "fonts.css" : "machine";
516
+ } else if (css) {
517
+ const roleFamily = ["heading", "body", "button"].map((r) => css.roles[r]).find((name) => name && css.faces.some((f) => sameName(f.family, name)));
518
+ family = roleFamily || (css.faces[0] && css.faces[0].family) || "";
519
+ from = family ? "fonts.css" : "default";
520
+ if (!family) notes.push("fonts.css declares no font file on this disk, so a common sans is used.");
521
+ } else if (brandNames.heading || brandNames.body) {
522
+ family = brandNames.heading || brandNames.body;
523
+ from = "machine";
524
+ }
525
+ const faces = css && family ? css.faces.filter((f) => sameName(f.family, family)) : [];
526
+ if (faces.length) family = faces[0].family;
527
+ const weight = pickWeight(faces);
528
+ if (faces.length && weight < 600) notes.push(`${family} has no bold file in fonts.css, so the captions use its ${weight} weight.`);
529
+ const lang = Object.hasOwn(CJK, options.lang || "") ? options.lang : "";
530
+ const scripts = (options.scripts || []).filter((name) => Object.hasOwn(SCRIPT_FAMILIES, name)).flatMap((name) => SCRIPT_FAMILIES[name]);
531
+ const after = [...new Set([...LATIN, ...EMOJI, ...(lang ? CJK[lang] : []), ...(options.arabic ? ARABIC : []), ...scripts, ...REST])];
532
+
533
+ // The color of the spoken word.
534
+ let highlight = DEFAULT_HIGHLIGHT;
535
+ let highlightFrom = "default";
536
+ if (options.highlight) {
537
+ highlight = parseColor(options.highlight);
538
+ highlightFrom = "given";
539
+ } else if (brand) {
540
+ const picked = brandHighlight(brand.colors);
541
+ if (picked) {
542
+ highlight = picked.hex;
543
+ highlightFrom = `the brand's ${picked.role || "color"} color`;
544
+ } else if (brand.colors.length) notes.push("None of the brand's colors is bright enough to light a word, so the default yellow is used. Give --highlight to pick one.");
545
+ }
546
+ const lit = highlightMode(highlight, { box: Boolean(options.box) });
547
+ return {
548
+ family,
549
+ from,
550
+ stack: [...(family ? [JSON.stringify(family)] : []), ...after].join(", "),
551
+ // The families of the list by name, which Chrome matches with the
552
+ // weight asked for.
553
+ listed: [...(family ? [family] : []), ...after.map(unquote).filter((name) => name !== "sans-serif")],
554
+ lang,
555
+ weight,
556
+ faces: faces.map((f) => ({ ...f, url: pathToFileURL(f.file).href })),
557
+ css: css ? css.file : "",
558
+ license: css && family ? licenseOf(css.kit, family) : null,
559
+ brand: brand ? { file: brand.file, name: brand.name, fonts: brandNames } : null,
560
+ textColor: "#ffffff",
561
+ highlight,
562
+ highlightFrom,
563
+ highlightMode: lit.mode,
564
+ highlightInk: lit.ink,
565
+ box: Boolean(options.box),
566
+ boxOpacity: 0.62,
567
+ notes,
568
+ };
569
+ }
570
+
571
+ module.exports = { chooseLook, readBrand, readFontsCss, parseColor, contrast, luminance, highlightMode, brandHighlight, variableFace, machineFont, fallbackFont, sameName, DEFAULT_HIGHLIGHT };