easycaptionkit 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -29,7 +29,7 @@ easycaptionkit-demo/
29
29
  ```
30
30
 
31
31
  - Three styles: `karaoke` shows each caption whole and lights the word being spoken, `line` shows each caption whole in one color, `word` shows one word at a time, lit.
32
- - With easyfontkit's `fonts.css`, the captions are drawn with the site's own font files, the ones in its folder, its heading font first. Chrome reports which font drew each letter, and CAPTIONS.md says so: a Japanese or Arabic caption over a Latin-only brand font is drawn partly in a system font, and the file tells you how much.
32
+ - With easyfontkit's `fonts.css`, the captions are drawn with the site's own font files, the ones in its folder, its heading font first. Chrome reports which font drew each letter, and CAPTIONS.md says so: a Japanese or Arabic caption over a Latin-only brand font is drawn partly in a system font, and the file tells you how much. A letter Chrome finds no font for on the machine is drawn as a box, as Japanese is on a server with no Japanese font; on Linux, where fontconfig can tell, CAPTIONS.md names those letters and says to install a font that has them, such as Noto Sans CJK, or to give `--fonts` an easyfontkit folder whose font has them.
33
33
  - With an easybrandkit folder, the spoken word is lit in the brand's button, link or logo color. A color too dark to read as letters on the outline (a deep green, a navy) lights the word on a rounded patch of it instead. Without `--fonts` or `--font`, the brand's heading font is asked for by name, which this machine may not have; CAPTIONS.md says which font drew the letters wherever Chrome or fontconfig can tell.
34
34
  - Captions are cut at sentence ends, commas and pauses, into at most two lines of what fits the safe width, up to 42 characters, by default (`--lines` and `--max-chars` change both), and held a little longer when they would flash by. In Japanese no caption or line starts with a particle or a small kana, or splits a word, when another break fits.
35
35
  - The sound is copied as it is, so its loudness does not change. Sound that MP4 players do not take, like a camera's PCM in a MOV, becomes AAC at the same rate.
@@ -96,7 +96,7 @@ npx easycaptionkit@latest styles [--json]
96
96
 
97
97
  easycaptionkit reads files, not addresses: a video or words given as `https://...` is refused, so download it first. `--brand` and `--fonts` take what easybrandkit and easyfontkit make from a site, so a site's address there is refused with words that name the kit to make it with.
98
98
 
99
- `subtitles` writes captions.srt, captions.vtt and captions.ass from the words alone, with words.json, captions.json and CAPTIONS.md, and changes no video; `--video` gives it a video's size and length. captions.ass is laid out for that frame, its lines cut to what fits across it at the letter size, so it can have more captions than captions.srt and captions.vtt, whose lines take up to 42 characters unless `--max-chars` gives another number. `styles` lists the styles, the options and every place's margins with their sources.
99
+ `subtitles` writes captions.srt, captions.vtt and captions.ass from the words alone, with words.json, captions.json and CAPTIONS.md, and changes no video; `--video` gives it a video's size and length, and without it a demo's captions end where its steps.json says the video does. captions.ass is laid out for that frame, its lines cut to what fits across it at the letter size, so it can have more captions than captions.srt and captions.vtt, whose lines take up to 42 characters unless `--max-chars` gives another number. `styles` lists the styles, the options and every place's margins with their sources.
100
100
 
101
101
  Without `--out`, a run goes to `easycaptionkit-<video name>`, or to `easycaptionkit-<video name>-2` and on when that folder holds the captions of another video by the same name, such as a second `demo.mp4`. A subtitles run goes to `easycaptionkit-subtitles-<video name>`, or `easycaptionkit-subtitles-<words name>` without `--video`, with `-2` and on in the same way. The name is the file's in lower case, its letters and digits in any script with a dash for anything else, and accents taken off Latin letters only; a name longer than 60 letters or 180 bytes is cut and ends in a short hash of the whole name. A run replaces the files an earlier run wrote in its folder, says so when that run was made from another file, and leaves anything else there alone. Each run's captions.json and words.json name the other files it wrote, with their sizes and SHA-256. A run stops before any work when the folder holds a file by one of the names it writes that they do not name, or that has changed since, such as another tool's words.json, a sheet.png of your own or a link, or one of the files the run reads, and says to pick another `--out`. A subtitles run also stops at a folder that holds the captioned video of an earlier run, which its new files would no longer match. The one file read and written over is a words.json from an earlier run, fixed by hand; if the edit breaks the JSON, the run says at which line and column. Once its words are fixed, a run that reads other words, such as the first command again, stops rather than replace them, and says how to keep them or start over. A file that comes in by one of the names while a run is going, or one there whose content changes, stays as it is: the run leaves the folder as it was and puts its own files in `easycaptionkit-new` inside it.
102
102
 
@@ -147,7 +147,7 @@ Only ads have published margins, and apps move their buttons. Each figure carrie
147
147
  4. The PNGs go into one animated PNG, each for exactly as long as its state lasts, to the millisecond. ffmpeg reads it as one stream and lays it over the video in a single encode, H.264 with x264 at CRF 18 where ffmpeg has x264, with every frame at its own time, or at a steady rate where the video has too few frames for the captions, as above. On an SDR video the caption colors are converted with the video's own color matrix, so a brand color lands on the same color. A picture stored as RGB, as PNG or QuickTime Animation in a MOV is, or coded in YCgCo, is converted to the matrix players take for its size first, and captioned.mp4 names the matrix it holds.
148
148
  5. Writes the subtitle files, takes frames of up to 24 captions from the result for sheet.png, draws safe-zones.png, and moves everything into the folder at once. A run that fails, or is stopped, leaves the folder as it was. A run killed outright (`kill -9`, or the system when memory runs out) cannot tidy up: its hidden `.easycaptionkit-work-` folder stays in the out folder, where an ffmpeg it started can go on writing captioned.mp4 to the end, and the Chrome profile it was drawing with stays in the temporary folder. The next run into that folder removes the work folder, and a later run of the same user that starts Chrome removes the profile once no process names it and it has not changed for a day, except on Windows, where it stays in the temporary folder until Windows or you clear it.
149
149
 
150
- With `--libass`, ffmpeg draws the captions itself from captions.ass with its ass filter, and where Chrome is missing or cannot start, ffmpeg makes plainer pictures too, and CAPTIONS.md says why. That needs an ffmpeg built with libass, which Homebrew's default ffmpeg is not. The kit gives the ass filter libass's complex shaping, which joins Arabic letters and forms the conjuncts of Devanagari and Bengali as Chrome does. ffmpeg's subtitles filter shapes them simply and draws Arabic in a font without its presentation forms, such as Noto Kufi Arabic, as boxes, so to burn captions.ass yourself, use `ass=captions.ass:shaping=complex`. When its font comes from fonts.css, give libass the folder of that file too, easyfontkit's `files`, as `ass=captions.ass:fontsdir=../easyfontkit-acme.com/files:shaping=complex` does from the captions' folder, or libass draws another font in its place; CAPTIONS.md gives the folder. captions.ass names the video's color matrix and range in its YCbCr Matrix line, as TV.709 for most HD videos, so players and ffmpeg 8's ass filter convert its colors as the video's own; for a video in BT.2020's matrix, as HDR video is, one stored as RGB, and one in YCgCo or another matrix ASS has no name for, it says None, with which they take the video's matrix. Older ass filters, such as 4.4's, convert every color as BT.601 does, so the kit gives them the colors that come out as the video's own, and a full range picture in the one form they draw full range colors on, so its white is white; a color BT.601 cannot reach that way, such as a pure green on an HD video, is drawn as the nearest one it can, and CAPTIONS.md says so. libass lays every line out from the left unless the style's Encoding is -1, so captions in Arabic, Hebrew or another script written from the right have -1 there, and libass, like Chrome, takes each line's direction from its first letter, so a line of Arabic has its full stop at the left. libass finds a font file in the kit's folder by the family name in the file, which fonts.css can give otherwise (easyfontkit calls InterVariable Inter), so captions.ass names it as the file does. libass draws a variable font at its default weight rather than the bold, and CAPTIONS.md says so; a highlight too dark for letters becomes a thick border of that color instead of a patch. libass draws no color emoji either, so the video shows a box for an emoji this machine has no black and white font for. CAPTIONS.md says which on Linux, where fontconfig can tell, and elsewhere names the emoji as ones that can show as boxes. libass makes a font as tall as its OS/2 heights rather than its em, so captions.ass gives the font the size whose em is the letter size CAPTIONS.md names. libass measures no letters either, so a line whose width, added up from the widths of its letters in the font, puts it past the safe space is drawn smaller, and captions.ass draws it smaller the same way, as capitals often need. The heights and widths come from the font file where the kit knows it: the one fonts.css names, or on Linux the one fontconfig gives for the name. libass draws a letter that font lacks, as a Latin font lacks Japanese, with another font, sized by that font's own heights, so on Linux captions.ass sets such letters in the font fontconfig gives for them, and an Arabic letter in the first Arabic family Chrome is given that this machine has, such as Noto Sans Arabic, at the size that makes them as big as the others. Elsewhere the numbers are Arial Bold's, and a letter the font does not have is guessed, so such a line can still reach past the safe space. Where Chrome drew the captions, captions.ass takes the size Chrome measured. Without `--libass`, a run with no Chrome, or with one that cannot start here (in a container without its sandbox, say), stops before the encode and says so.
150
+ With `--libass`, ffmpeg draws the captions itself from captions.ass with its ass filter, and where Chrome is missing or cannot start, ffmpeg makes plainer pictures too, and CAPTIONS.md says why. That needs an ffmpeg built with libass, which Homebrew's default ffmpeg is not. The kit gives the ass filter libass's complex shaping, which joins Arabic letters and forms the conjuncts of Devanagari and Bengali as Chrome does. ffmpeg's subtitles filter shapes them simply and draws Arabic in a font without its presentation forms, such as Noto Kufi Arabic, as boxes, so to burn captions.ass yourself, use `ass=captions.ass:shaping=complex`. When its font comes from fonts.css, give libass the folder of that file too, easyfontkit's `files`, as `ass=captions.ass:fontsdir=../easyfontkit-acme.com/files:shaping=complex` does from the captions' folder, or libass draws another font in its place; CAPTIONS.md gives the folder. captions.ass names the video's color matrix and range in its YCbCr Matrix line, as TV.709 for most HD videos, so players and ffmpeg 8's ass filter convert its colors as the video's own; for a video in BT.2020's matrix, as HDR video is, one stored as RGB, and one in YCgCo or another matrix ASS has no name for, it says None, with which they take the video's matrix. Older ass filters, such as 4.4's, convert every color as BT.601 does, so the kit gives them the colors that come out as the video's own, and a full range picture in the one form they draw full range colors on, so its white is white; a color BT.601 cannot reach that way, such as a pure green on an HD video, is drawn as the nearest one it can, and CAPTIONS.md says so. libass lays every line out from the left unless the style's Encoding is -1, so captions in Arabic, Hebrew or another script written from the right have -1 there, and libass, like Chrome, takes each line's direction from its first letter, so a line of Arabic has its full stop at the left. libass finds a font file in the kit's folder by the family name in the file, which fonts.css can give otherwise (easyfontkit calls InterVariable Inter), so captions.ass names it as the file does. libass draws a variable font at its default weight rather than the bold, and CAPTIONS.md says so; a highlight too dark for letters becomes a thick border of that color instead of a patch. libass draws no color emoji either, so the video shows a box for an emoji this machine has no black and white font for, and for any letter no font on it has. CAPTIONS.md says which on Linux, where fontconfig can tell, with what to do, and elsewhere names the emoji as ones that can show as boxes. libass makes a font as tall as its OS/2 heights rather than its em, so captions.ass gives the font the size whose em is the letter size CAPTIONS.md names. libass measures no letters either, so a line whose width, added up from the widths of its letters in the font, puts it past the safe space is drawn smaller, and captions.ass draws it smaller the same way, as capitals often need. The heights and widths come from the font file where the kit knows it: the one fonts.css names, or on Linux the one fontconfig gives for the name. libass draws a letter that font lacks, as a Latin font lacks Japanese, with another font, sized by that font's own heights, so on Linux captions.ass sets such letters in the font fontconfig gives for them, and an Arabic letter in the first Arabic family Chrome is given that this machine has, such as Noto Sans Arabic, at the size that makes them as big as the others. Elsewhere the numbers are Arial Bold's, and a letter the font does not have is guessed, so such a line can still reach past the safe space. Where Chrome drew the captions, captions.ass takes the size Chrome measured. Without `--libass`, a run with no Chrome, or with one that cannot start here (in a container without its sandbox, say), stops before the encode and says so.
151
151
 
152
152
  ## Limits
153
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easycaptionkit",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Burn captions into a video in the product's own fonts and colors, word by word or line by line, kept clear of each app's buttons, with the SRT, VTT and ASS files to go with it.",
5
5
  "license": "MIT",
6
6
  "author": "Emin Budak",
@@ -32,7 +32,7 @@ node <skill>/scripts/easycaptionkit.js subtitles transcript.json --video video.m
32
32
 
33
33
  ## Use the result
34
34
 
35
- 1. Read `CAPTIONS.md` first. It says which font drew the letters (and how many letters fell back to another font, as Japanese or Arabic text over a Latin brand font does), where the captions sit and why, and which captions go faster than people read.
35
+ 1. Read `CAPTIONS.md` first. It says which font drew the letters (and how many letters fell back to another font, as Japanese or Arabic text over a Latin brand font does, and which show as boxes because no font on the machine has them), where the captions sit and why, and which captions go faster than people read.
36
36
  2. Look at `sheet.png`: frames of `captioned.mp4` for up to 24 captions, spread over the video. Check that no caption covers text or a face in the picture; if one does, run again with `--position middle` or `top`. A video with more captions than the sheet shows has every caption's time in `captions.json`; take a frame at the times that matter.
37
37
  3. Show `safe-zones.png` to the user: a frame under each app's buttons, the margins tinted, the safe space outlined.
38
38
  4. Hand over `captioned.mp4`. It has every frame of the video at its own time, or, where the video holds a frame too long for the captions or has few frames a second (CAPTIONS.md says so), every frame at a steady rate, and its sound copied, or made AAC when MP4 players do not take it. Give `captions.srt` or `captions.vtt` to places that take a captions file.
@@ -42,7 +42,7 @@ node <skill>/scripts/easycaptionkit.js subtitles transcript.json --video video.m
42
42
  ## When it goes wrong
43
43
 
44
44
  - "reads files, not addresses": the video or the words were given as a link. Download it first and give the file. "--brand takes an easybrandkit folder" or "--fonts takes an easyfontkit folder": a site's address was given for the brand or the fonts. Make the folder from that site with easybrandkit or easyfontkit and give it.
45
- - "Every word starts after the video ends": the words belong to another video, or their times are in milliseconds in a shape the kit does not know. Convert them to seconds in a plain list of `{ "text", "start", "end" }`.
45
+ - "Every word starts after the video ends": the words belong to another video, or their times are in milliseconds in a shape the kit does not know. Convert them to seconds in a plain list of `{ "text", "start", "end" }`. Where the message says the words file gives the video's length, as a demo's steps.json does on a subtitles run with no `--video`, the times in that file or that length are wrong; `--video` goes by the video's own length.
46
46
  - A caption is drawn smaller than the others: one of its lines was wider than the safe space (with `--libass`, by an estimate of its width). Lower `--max-chars` for long lines. A single long word, like a link, fits only at a lower `--size`; or shorten it in `words.json`.
47
47
  - "Pick another --out": the folder holds a file by one of the names the kit writes that it did not write itself (another tool's words.json, or a link, say) or that has changed since it wrote it, or one of the files the run reads, or, for a subtitles run, the captioned video of an earlier run, which new subtitle files would no longer match. Give a new folder with `--out`.
48
48
  - "holds words fixed by hand": the folder's words.json has fixes this run would lose, since it reads another words file. The message gives the command that keeps them, the one refused with `<out>/words.json` as its words; or move `<out>/words.json` away to start again from the words file. A subtitles run into the folder of a captioned video is refused whatever its words, and when they were fixed there, the message gives the command that makes subtitles of them in a folder of their own.
@@ -50,7 +50,8 @@ node <skill>/scripts/easycaptionkit.js subtitles transcript.json --video video.m
50
50
  - Without `--out`, a second video by the same name as an earlier one (every easyscreenshotkit demo is demo.mp4) goes to `easycaptionkit-demo-2`, and the summary says so. A subtitles run goes to `easycaptionkit-subtitles-<video name>`, or `easycaptionkit-subtitles-<words name>` without `--video`, in the same way. With `--out`, a run into the folder of another video's captions replaces them and says whose they were.
51
51
  - CAPTIONS.md says the brand font was asked for by name and this machine does not have it: give easyfontkit's folder with `--fonts`. When it says Chrome drew no letter with the file of a fonts.css font, that file lacks the letters of these words, as a cut for another script does.
52
52
  - The font license in CAPTIONS.md is commercial: tell the user before the video goes out. The file names the closest free font; `--font "<that font>"` uses it once it is installed. When CAPTIONS.md says no letter of the font is in the video, as with Japanese or Arabic words over a Latin font, its license does not reach the video: the fonts that drew the letters came with the machine.
53
- - With `--libass`, CAPTIONS.md can say the video shows boxes for some emoji: libass draws no color emoji. Run again without `--libass` to have Chrome draw them. A letter no font on the machine has shows as a box with Chrome too.
53
+ - With `--libass`, CAPTIONS.md can say the video shows boxes for some emoji: libass draws no color emoji. Run again without `--libass` to have Chrome draw them.
54
+ - CAPTIONS.md says Chrome found no font for some letters, or that no font libass can use has them: the video shows a box for each, as it does for Japanese on a server with no Japanese font. Install a font that has them, such as Noto Sans CJK (`fonts-noto-cjk` on Debian and Ubuntu), or give `--fonts` an easyfontkit folder whose font has them, and run again. Do not hand over a video with boxes in its captions.
54
55
  - No Chrome on the machine: the run stops. `--libass` draws the captions with ffmpeg's libass instead, which needs an ffmpeg built with libass (Homebrew's default ffmpeg is not).
55
56
  - "Chrome could not start its sandbox here", or another reason Chrome stopped as it started (in a container, or on a CI runner without user namespaces): the run stops before the encode. `--libass` draws the captions without Chrome, and ffmpeg makes plainer sheet.png and safe-zones.png, which CAPTIONS.md notes.
56
57
  - A run killed outright (`kill -9`, or the system when memory runs out) cannot tidy up: its hidden `.easycaptionkit-work-` folder stays in the out folder, where an ffmpeg it started can go on writing captioned.mp4, and its Chrome profile stays in the temporary folder. The captioned.mp4 in that folder is no result to hand over: run again. The next run into the folder removes the work folder, and a later run of the same user that starts Chrome removes the profile once no process names it and it has not changed for a day, except on Windows, where it stays in the temporary folder until Windows or the user clears it.
@@ -4,7 +4,7 @@
4
4
  const path = require("node:path");
5
5
  const version = require("./lib/version");
6
6
  const { PLACES, PLACE_NAMES } = require("./lib/places");
7
- const { drewWith, setsAny, setsNone, machineHas, shown, soundText } = require("./lib/report");
7
+ const { drewWith, setsAny, setsNone, boxedIn, machineHas, shown, soundText } = require("./lib/report");
8
8
  const { variableFace, sameName } = require("./lib/look");
9
9
 
10
10
  const USAGE = `Usage: easycaptionkit <video> --words <file> [options]
@@ -297,7 +297,12 @@ function summary({ dir, facts: f }) {
297
297
  // sets them in, which can be all of them, as Japanese over a Latin font.
298
298
  const fallbacks = libass ? f.assFallbacks || [] : [];
299
299
  const none = libass && setsNone(f);
300
- if (!f.look.family) font = libass ? `Arial, asked for by name${m && !sameName(m.family, "Arial") ? ` (fontconfig gives ${m.family})` : ""}` : f.fontFirst ? (drawn.length && !firstDrew ? `${f.fontFirst}, which has none of these letters: ${list(drawn.map((x) => x.family))} drew them` : f.fontFirst) : drawn.length ? `a common sans (${list(drawn.map((x) => x.family))} drew it)` : "a common sans";
300
+ // Where letters show as boxes, which a note names, the fonts named
301
+ // drew the others, and the font lacks more letters than they drew.
302
+ const boxed = boxedIn(f);
303
+ const them = boxed ? "all but the boxes" : "them";
304
+ const the = boxed ? "" : "the ";
305
+ if (!f.look.family) font = libass ? `Arial, asked for by name${m && !sameName(m.family, "Arial") ? ` (fontconfig gives ${m.family})` : ""}` : f.fontFirst ? (drawn.length && !firstDrew ? `${f.fontFirst}, which has none of these letters: ${list(drawn.map((x) => x.family))} drew ${them}` : f.fontFirst) : drawn.length ? `a common sans (${list(drawn.map((x) => x.family))} drew it)` : "a common sans";
301
306
  else if (libass && variableFace(f.look) && setsAny(f)) font = `${f.look.family} from fonts.css, which libass draws at its variable file's default weight, not at ${f.look.weight}`;
302
307
  else if (libass && f.look.from === "machine") font = `${f.look.family} ${f.look.weight}, asked for by name${!m ? "; libass draws whatever this machine gives for that name" : sameName(m.family, f.look.family) ? "" : ` (not on this machine: fontconfig gives ${m.family})`}`;
303
308
  else font = `${f.look.family} ${f.look.weight}${f.look.from === "fonts.css" ? " from fonts.css" : ""}`;
@@ -307,7 +312,7 @@ function summary({ dir, facts: f }) {
307
312
  const families = list(fallbacks.map((x) => x.family));
308
313
  const set = fallbacks.reduce((a, b) => a + b.letters, 0);
309
314
  if (none) font += `, ${renamed ? "and which" : "which"} has none of these letters: captions.ass sets them in ${families}`;
310
- else if (fallbacks.length) font += `; captions.ass sets the ${set} ${set === 1 ? "letter" : "letters"} it lacks in ${families}`;
315
+ else if (fallbacks.length) font += `; captions.ass sets ${the}${set} ${set === 1 ? "letter" : "letters"} it lacks in ${families}`;
311
316
  const missing = drawn.length && f.look.family && !drawn.some((x) => drewWith(x, f.look));
312
317
  const why = f.look.from !== "machine" ? "its file drew nothing" : machineHas(f) ? "it has none of these letters" : "not on this machine";
313
318
  // With Chrome, the fonts that drew the letters the font lacks, where
@@ -315,8 +320,10 @@ function summary({ dir, facts: f }) {
315
320
  const own = libass ? [] : drawn.filter((x) => (f.look.family ? drewWith(x, f.look) : x.family === f.fontFirst));
316
321
  const others = own.length ? drawn.filter((x) => !own.includes(x)) : [];
317
322
  const rest = others.reduce((a, b) => a + b.glyphs, 0);
318
- const filled = others.length ? `; Chrome drew the ${rest} ${rest === 1 ? "letter" : "letters"} it lacks with ${list(others.map((x) => (others.length > 1 ? `${x.family} (${x.glyphs})` : x.family)))}` : "";
319
- lines.push(` Font ${font}${missing ? ` (${why}: ${list(drawn.map((x) => x.family))} drew them)` : filled}`);
323
+ const filled = others.length ? `; Chrome drew ${the}${rest} ${rest === 1 ? "letter" : "letters"} it lacks with ${list(others.map((x) => (others.length > 1 ? `${x.family} (${x.glyphs})` : x.family)))}` : "";
324
+ // Every letter a box: the font asked for by name drew none of them either.
325
+ const gone = !libass && boxed && !drawn.length && f.look.family && f.look.from === "machine" && !machineHas(f);
326
+ lines.push(` Font ${font}${missing ? ` (${why}: ${list(drawn.map((x) => x.family))} drew ${them})` : gone ? " (not on this machine)" : filled}`);
320
327
  if (f.style !== "line") lines.push(` Lit word ${f.look.highlightMode !== "patch" ? f.look.highlight : libass ? `in a thick border of ${f.look.highlight}` : `on a patch of ${f.look.highlight}`}`);
321
328
  lines.push(` Video captioned.mp4, ${o.width}x${o.height}, ${o.fps} fps, ${sec(o.duration)}, ${soundText(o.audio)}`);
322
329
  }
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+
3
+ // The letters a video shows as boxes. Chrome draws a letter it finds no
4
+ // font for with the missing glyph of the first font of the list, a box,
5
+ // and names that font as the one that drew it: on an Ubuntu with no
6
+ // Japanese font, Liberation Sans for every letter of a Japanese caption.
7
+ // So each font Chrome names is asked whether it has each letter, a font
8
+ // of fonts.css through its own files and any other through the letters
9
+ // fontconfig lists for it, and a letter none of them has is a box. libass
10
+ // looks through fontconfig for a font for each letter its own font lacks,
11
+ // and draws a box where no font on the machine has one. Without
12
+ // fontconfig, as on macOS and Windows, nothing here can say, and nothing
13
+ // is said.
14
+
15
+ const { spawnSync } = require("node:child_process");
16
+ const { clusters } = require("./text");
17
+ const { fcName } = require("./look");
18
+
19
+ // What fc-list prints for a pattern in a format, or "" where there is no
20
+ // fc-list or it fails. The letters of every font of a machine with many
21
+ // fonts take some megabytes.
22
+ function fcList(pattern, format) {
23
+ let r;
24
+ try {
25
+ r = spawnSync("fc-list", ["-f", format, ...(pattern ? [pattern] : [])], { encoding: "utf8", timeout: 20_000, maxBuffer: 256 * 1024 * 1024 });
26
+ } catch {
27
+ return "";
28
+ }
29
+ return r.error || r.status !== 0 ? "" : r.stdout || "";
30
+ }
31
+
32
+ // The code points of fontconfig's charsets, as it prints them ("20-7e
33
+ // a0-17f 192"), all of them together, as a lookup: has(code). The ranges
34
+ // are put in order and joined, and a code point is found by halves.
35
+ function letterSet(charsets) {
36
+ const ranges = [];
37
+ for (const charset of charsets) {
38
+ for (const part of charset.split(/\s+/)) {
39
+ const m = /^([0-9a-f]{1,6})(?:-([0-9a-f]{1,6}))?$/i.exec(part);
40
+ if (m) ranges.push([parseInt(m[1], 16), parseInt(m[2] || m[1], 16)]);
41
+ }
42
+ }
43
+ ranges.sort((a, b) => a[0] - b[0]);
44
+ const starts = [];
45
+ const ends = [];
46
+ for (const [from, to] of ranges) {
47
+ const last = ends.length - 1;
48
+ if (last >= 0 && from <= ends[last] + 1) ends[last] = Math.max(ends[last], to);
49
+ else {
50
+ starts.push(from);
51
+ ends.push(to);
52
+ }
53
+ }
54
+ return (code) => {
55
+ let lo = 0;
56
+ let hi = starts.length - 1;
57
+ let found = -1;
58
+ while (lo <= hi) {
59
+ const mid = (lo + hi) >> 1;
60
+ if (starts[mid] <= code) {
61
+ found = mid;
62
+ lo = mid + 1;
63
+ } else hi = mid - 1;
64
+ }
65
+ return found >= 0 && code <= ends[found];
66
+ };
67
+ }
68
+
69
+ // The letters a font Chrome names has, as fontconfig lists them for its
70
+ // PostScript name, else for its family: has(code), or null where
71
+ // fontconfig gives nothing for it or cannot be asked. `list` stands in
72
+ // for fc-list in the tests.
73
+ function fontLetters({ family = "", postScriptName = "" }, { platform = process.platform, list = fcList } = {}) {
74
+ if (platform !== "linux") return null;
75
+ const charsets = (pattern) => list(pattern, "%{charset}\\n").split("\n").filter((line) => line.trim());
76
+ let found = postScriptName ? charsets(`:postscriptname=${fcName(postScriptName)}`) : [];
77
+ if (!found.length && family) found = charsets(`:family=${fcName(family)}`);
78
+ return found.length ? letterSet(found) : null;
79
+ }
80
+
81
+ // The letters of every font on this machine, as fontconfig lists them:
82
+ // has(code), or null where fontconfig cannot be asked. libass finds a
83
+ // font through fontconfig for a letter its own font lacks, among all of
84
+ // them, so a letter none of them has is a box.
85
+ function machineLetters({ platform = process.platform, list = fcList } = {}) {
86
+ if (platform !== "linux") return null;
87
+ const text = list("", "%{charset}\\n");
88
+ return text.trim() ? letterSet(text.split("\n")) : null;
89
+ }
90
+
91
+ // A letter as a reader sees it, one or more code points, is in a font
92
+ // when the font has each of them but those that draw nothing (a joiner, a
93
+ // variation selector, the object mark U+FFFC), as they are, composed or
94
+ // decomposed: HarfBuzz draws é from e and its accent in a font that has
95
+ // only those, and a no-break hyphen with the plain one of a font that has
96
+ // only that, as Liberation Sans is from version 2.1.
97
+ const DRAWS_NOTHING = /^[\p{Default_Ignorable_Code_Point}\ufffc]$/u;
98
+ function hasLetter(has, letter) {
99
+ for (const form of new Set([letter, letter.normalize("NFC"), letter.normalize("NFD"), letter.replace(/\u2011/g, "\u2010")])) {
100
+ if (Array.from(form).every((p) => DRAWS_NOTHING.test(p) || has(p))) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ // The letters of `letters` Chrome drew as boxes: those no font it names
106
+ // for them has, of [{ family, postScriptName, custom, glyphs }]. A font of
107
+ // fonts.css, which Chrome names as custom, is asked through `kit(c)`,
108
+ // kitLetters' answer for a code point. Where a font named cannot be asked,
109
+ // as without fontconfig, or Chrome names none, nothing can say: none.
110
+ // `lookUp` stands in for fontLetters in the tests.
111
+ function chromeBoxes(letters, fonts, { kit = null, lookUp = fontLetters } = {}) {
112
+ const drew = fonts.filter((font) => font.glyphs > 0);
113
+ if (!drew.length) return [];
114
+ const asks = [];
115
+ for (const font of drew) {
116
+ if (font.custom) {
117
+ if (!kit) return [];
118
+ asks.push(kit);
119
+ continue;
120
+ }
121
+ const has = lookUp(font);
122
+ if (!has) return [];
123
+ asks.push((p) => has(p.codePointAt(0)));
124
+ }
125
+ return letters.filter((letter) => !/^\s+$/.test(letter) && !asks.some((has) => hasLetter(has, letter)));
126
+ }
127
+
128
+ // Each letter of the captions that is one of `boxed`, with the number of
129
+ // its caption: [{ letter, cue }], as assLetters gives the ones libass has
130
+ // no font for.
131
+ function boxesIn(cues, boxed) {
132
+ const set = new Set(boxed);
133
+ if (!set.size) return [];
134
+ const out = [];
135
+ for (const cue of cues) for (const line of cue.lines) for (const c of clusters(line)) if (set.has(c)) out.push({ letter: c, cue: cue.n });
136
+ return out;
137
+ }
138
+
139
+ module.exports = { fcList, letterSet, fontLetters, machineLetters, hasLetter, chromeBoxes, boxesIn };
@@ -12,7 +12,8 @@ const crypto = require("node:crypto");
12
12
  const { loadWords, handFixed, wordsSha256, FORMATS, MOST_OPENED, DEEPEST } = require("./words");
13
13
  const { cjkLanguage, arabicIn, scriptsIn } = require("./text");
14
14
  const { buildCues, states: makeStates, readingSpeed } = require("./cues");
15
- const { srt, vtt, ass, assFont, estimatedScales, shrunkOf, assLetters, SRT_SPECIAL } = require("./subtitles");
15
+ const { srt, vtt, ass, assFont, kitLetters, estimatedScales, shrunkOf, assLetters, SRT_SPECIAL } = require("./subtitles");
16
+ const { machineLetters, chromeBoxes, boxesIn } = require("./boxes");
16
17
  const { captionLayout, charsThatFit, defaultPlaces, shapeOf } = require("./places");
17
18
  const { chooseLook, variableFace, machineFont, fallbackFont } = require("./look");
18
19
  const { fontLike } = require("./fontfile");
@@ -20,7 +21,7 @@ const ffmpeg = require("./ffmpeg");
20
21
  const { findChrome, missingChrome, chromeNamedNothing, tempProblem } = require("./chrome");
21
22
  const { renderStates } = require("./render");
22
23
  const { safeZonesPng, sheetPng, plainPictures, shotOf } = require("./draw");
23
- const { captionsMarkdown, captionsJson, subtitlesMarkdown, arg, subtitlesCommand, shown } = require("./report");
24
+ const { captionsMarkdown, captionsJson, subtitlesMarkdown, arg, subtitlesCommand, shown, captionsOf, lettersOf, boxesText, installText } = require("./report");
24
25
  const { jsonShape, jsonProblem, decodeText } = require("./files");
25
26
  const { stopAll } = require("./running");
26
27
  const version = require("./version");
@@ -823,15 +824,18 @@ function notStill(video, name) {
823
824
 
824
825
  // The words, cut to the video: words after its end are left out, a word
825
826
  // that runs past it is cut there.
826
- function fitWords(read, duration) {
827
+ function fitWords(read, duration, givenBy = "") {
827
828
  const notes = [];
828
829
  if (!Number.isFinite(duration)) return { words: read.words, notes };
829
830
  const words = read.words.filter((w) => w.start < duration - 0.05).map((w) => ({ ...w, end: Math.min(w.end, duration) }));
830
831
  const dropped = read.words.length - words.length;
831
- if (dropped) notes.push(`${dropped} ${dropped === 1 ? "word starts" : "words start"} after the video ends (${duration.toFixed(2)} s) and ${dropped === 1 ? "was" : "were"} left out. Check that the words belong to this video.`);
832
- if (!words.length) throw new Error(`Every word starts after the video ends (${duration.toFixed(2)} s). The words file belongs to another video, or its times are in another unit`);
832
+ // `givenBy`: the file that gives the length where no video was given,
833
+ // which says nothing of whether its own words belong to its video.
834
+ const end = `${duration.toFixed(2)} s${givenBy ? `, as ${givenBy} gives its length` : ""}`;
835
+ if (dropped) notes.push(`${dropped} ${dropped === 1 ? "word starts" : "words start"} after the video ends (${end}) and ${dropped === 1 ? "was" : "were"} left out.${givenBy ? "" : " Check that the words belong to this video."}`);
836
+ if (!words.length) throw new Error(`Every word starts after the video ends (${end}). ${givenBy ? "The times in that file or the length it gives are wrong" : "The words file belongs to another video, or its times are in another unit"}`);
833
837
  const last = words[words.length - 1].end;
834
- if (last < duration * 0.5 && duration > 20) notes.push(`The words end at ${last.toFixed(1)} s, before half of the video (${duration.toFixed(1)} s). Check that they belong to this video.`);
838
+ if (!givenBy && last < duration * 0.5 && duration > 20) notes.push(`The words end at ${last.toFixed(1)} s, before half of the video (${duration.toFixed(1)} s). Check that they belong to this video.`);
835
839
  return { words, notes };
836
840
  }
837
841
 
@@ -859,29 +863,25 @@ function specialNote(cues) {
859
863
 
860
864
  // The letters libass drew boxes for, since this machine has no font it
861
865
  // can draw them with, as notes: emoji, which libass draws in no color
862
- // font, and any other letters. `missing` and `unsure` are assLetters':
863
- // where nothing says which font an emoji goes in, as on macOS and Windows,
864
- // the note says it can be a box.
866
+ // font, and any other letters, with what to do. `missing` and `unsure`
867
+ // are assLetters': where nothing says which font an emoji goes in, as on
868
+ // macOS and Windows, the note says it can be a box.
865
869
  const EMOJI = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u;
866
870
  function missingNotes(missing, unsure = []) {
867
- const where = (items) => {
868
- const cues = [...new Set(items.map((m) => m.cue))];
869
- return `${cues.length === 1 ? "caption" : "captions"} ${cues.length > 6 ? `${cues.slice(0, 6).join(", ")} and ${cues.length - 6} more` : list(cues.map(String))}`;
870
- };
871
- const which = (items) => {
872
- const letters = [...new Set(items.map((m) => m.letter))];
873
- return letters.length > 8 ? `${letters.slice(0, 8).join(" ")} and ${letters.length - 8} more` : letters.join(" ");
874
- };
875
871
  const boxes = (n) => (n === 1 ? "a box for it" : "boxes for them");
876
872
  const emoji = missing.filter((m) => EMOJI.test(m.letter));
877
873
  const other = missing.filter((m) => !EMOJI.test(m.letter));
878
874
  const notes = [];
879
- if (emoji.length) notes.push(`libass draws no color emoji, and this machine has no other font that draws ${emoji.length === 1 ? "the emoji" : `the ${emoji.length} emoji`} in ${where(emoji)} (${which(emoji)}) whole, so the video shows ${boxes(emoji.length)}. Leave out --libass to have Chrome draw ${emoji.length === 1 ? "it" : "them"}.`);
880
- if (other.length) notes.push(`No font on this machine that libass can use has ${count(other.length, "letter", "letters")} of ${where(other)} (${which(other)}), so the video shows ${boxes(other.length)}.`);
881
- if (unsure.length) notes.push(`libass draws no color emoji, so ${unsure.length === 1 ? "the emoji" : `the ${unsure.length} emoji`} in ${where(unsure)} (${which(unsure)}) can show as ${unsure.length === 1 ? "a box" : "boxes"}: nothing on this machine says which font libass draws ${unsure.length === 1 ? "it" : "them"} with. Leave out --libass to have Chrome draw ${unsure.length === 1 ? "it" : "them"}.`);
875
+ if (emoji.length) notes.push(`libass draws no color emoji, and this machine has no other font that draws ${emoji.length === 1 ? "the emoji" : `the ${emoji.length} emoji`} in ${captionsOf(emoji)} (${lettersOf(emoji)}) whole, so the video shows ${boxes(emoji.length)}. Leave out --libass to have Chrome draw ${emoji.length === 1 ? "it" : "them"}.`);
876
+ if (other.length) notes.push(`${boxesText(other, "libass")} ${installText(other)}`);
877
+ if (unsure.length) notes.push(`libass draws no color emoji, so ${unsure.length === 1 ? "the emoji" : `the ${unsure.length} emoji`} in ${captionsOf(unsure)} (${lettersOf(unsure)}) can show as ${unsure.length === 1 ? "a box" : "boxes"}: nothing on this machine says which font libass draws ${unsure.length === 1 ? "it" : "them"} with. Leave out --libass to have Chrome draw ${unsure.length === 1 ? "it" : "them"}.`);
882
878
  return notes;
883
879
  }
884
880
 
881
+ // The letters Chrome drew as boxes, [{ letter, cue }], as a note: which,
882
+ // and what to do.
883
+ const boxesNotes = (boxes) => (boxes.length ? [`${boxesText(boxes, "chrome")} ${installText(boxes)}`] : []);
884
+
885
885
  // The folder of a fonts.css font's files, which ffmpeg's ass filter finds
886
886
  // the font of captions.ass in when it is its fontsdir, from the output
887
887
  // folder `dir`, where captions.ass is; else "".
@@ -967,7 +967,7 @@ async function captionVideo(videoFile, options) {
967
967
  notes.push(...place.notes);
968
968
  const fitted = fitWords(read, video.duration);
969
969
  notes.push(...fitted.notes);
970
- if (read.video && Math.abs(read.video.duration - video.duration) > 0.5) notes.push(`steps.json is for a video ${read.video.duration.toFixed(1)} s long, and this one is ${video.duration.toFixed(1)} s. The captions follow the steps' times.`);
970
+ if (read.format === "steps" && read.video && Math.abs(read.video.duration - video.duration) > 0.5) notes.push(`steps.json is for a video ${read.video.duration.toFixed(1)} s long, and this one is ${video.duration.toFixed(1)} s. The captions follow the steps' times.`);
971
971
  const words = fitted.words;
972
972
  log(`Read ${count(words.length, "word", "words")} from ${shown(path.basename(options.words))} (${read.about})`);
973
973
  const cues = buildCues(words, { lines, maxChars, hard: read.hard, duration: video.duration });
@@ -1021,8 +1021,14 @@ async function captionVideo(videoFile, options) {
1021
1021
  // The font captions.ass names, as libass and players that read ASS
1022
1022
  // make it: a font asked for by name is what this machine gives for it.
1023
1023
  const libassFont = look.from === "fonts.css" ? null : machineFont(look.family || "Arial", look.weight);
1024
- const font = assFont(look, libassFont, { fallback: givenFor(look) });
1024
+ // libass finds a font through fontconfig for each letter its own font
1025
+ // lacks: one no font on this machine has, as fontconfig lists them, is
1026
+ // a box, which needs no asking.
1027
+ const every = useLibass ? machineLetters() : null;
1028
+ const font = assFont(look, libassFont, { fallback: givenFor(look), lacks: every ? (code) => !every(code) : null });
1025
1029
  const letters = assLetters(cues, font);
1030
+ // The letters the video shows as boxes, [{ letter, cue }].
1031
+ let boxes = useLibass ? letters.missing : [];
1026
1032
  // captions.ass names the video's own matrix and range for players; the
1027
1033
  // file libass burns can name others, and give other colors (assColors).
1028
1034
  const assFile = (scales, { level = 1, matrix = color.header, tint = null } = {}) => ass(cues, words, { states, style: options.style, look: { ...look, tool: TOOL }, place, title: titled, level, scales, font, matrix, tint });
@@ -1066,7 +1072,7 @@ async function captionVideo(videoFile, options) {
1066
1072
  } else {
1067
1073
  log(`Drawing ${count(states.length, "caption state", "caption states")} in Chrome`);
1068
1074
  try {
1069
- drawn = await renderStates({ cues, words, states, style: options.style, look, place, duration: video.duration, dir: run.work, log });
1075
+ drawn = await renderStates({ cues, words, states, style: options.style, look, place, duration: video.duration, dir: run.work, log, boxed: (all, fonts) => chromeBoxes(all, fonts, { kit: kitLetters(look) }) });
1070
1076
  } catch (err) {
1071
1077
  // A Chrome that cannot start here (no usable sandbox, as in a
1072
1078
  // container) stops the run before the encode; libass needs none. A
@@ -1082,6 +1088,8 @@ async function captionVideo(videoFile, options) {
1082
1088
  notes.push(...made.notes);
1083
1089
  sound = made.tracks;
1084
1090
  frameTags = made.frameTags;
1091
+ boxes = boxesIn(cues, drawn.boxes);
1092
+ notes.push(...boxesNotes(boxes));
1085
1093
  if (drawn.failedFonts.length) notes.push(`Chrome could not load the font ${[...new Set(drawn.failedFonts)].join(", ")}; the next font in the list drew the captions.`);
1086
1094
  if (drawn.shrunk) notes.push(`${drawn.shrunk} ${drawn.shrunk === 1 ? `${unit[0]} was` : `${unit[1]} were`} wider than the safe space and drawn smaller to fit, ${drawn.shrunk === 1 ? "at" : "the smallest at"} ${percentOf(drawn.smallest)}% of the size.`);
1087
1095
  }
@@ -1152,6 +1160,7 @@ async function captionVideo(videoFile, options) {
1152
1160
  states: drawn ? drawn.states : states.length,
1153
1161
  fontsDrawn: drawn ? drawn.fonts : [],
1154
1162
  fontFirst: drawn ? drawn.first : "",
1163
+ boxes,
1155
1164
  libassFont: useLibass ? libassFont : null,
1156
1165
  // The name captions.ass gives the font, from its file for fonts.css,
1157
1166
  // and the fonts it sets the letters in that the font lacks.
@@ -1172,7 +1181,7 @@ async function captionVideo(videoFile, options) {
1172
1181
  fs.writeFileSync(path.join(run.work, "CAPTIONS.md"), captionsMarkdown(facts));
1173
1182
  names.push("CAPTIONS.md");
1174
1183
  const files = writtenFiles(run.work, names);
1175
- fs.writeFileSync(path.join(run.work, "words.json"), JSON.stringify(wordsJson(words, read, files, first), null, 2) + "\n");
1184
+ fs.writeFileSync(path.join(run.work, "words.json"), JSON.stringify(wordsJson(words, read, files, first, video.duration), null, 2) + "\n");
1176
1185
  const json = captionsJson({ ...facts, files: { ...files, ...writtenFiles(run.work, ["words.json"]) } });
1177
1186
  fs.writeFileSync(path.join(run.work, "captions.json"), JSON.stringify(json, null, 2) + "\n");
1178
1187
  names.push("words.json", "captions.json");
@@ -1266,12 +1275,17 @@ function describe(v, rate = null, frames = 0) {
1266
1275
  // firstFormat's), which a run that reads the file again keeps. A word
1267
1276
  // without a time of its own says how it got one: spread over its line by
1268
1277
  // length, or placed between its neighbors, as the file gave it none.
1269
- function wordsJson(words, read, files, first = read.format) {
1278
+ // Words of a demo's steps keep `end`, where this run ended the captions:
1279
+ // the video's length, or the one steps.json gives without a video. That
1280
+ // is `demo`, where a later subtitles run with no video ends them too.
1281
+ function wordsJson(words, read, files, first, end) {
1270
1282
  const items = words.map((w) => ({ text: w.text, start: round(w.start), end: round(w.end), ...(w.seg >= 0 ? { seg: w.seg } : {}), ...(w.timed ? {} : w.placed ? { placed: true } : { spread: true }) }));
1283
+ const demo = read.video && Number.isFinite(end) ? end : 0;
1271
1284
  return {
1272
1285
  tool: TOOL,
1273
1286
  from: FORMATS[first] || read.about,
1274
1287
  hard: read.hard,
1288
+ ...(demo > 0 ? { demo: { duration: round(demo) } } : {}),
1275
1289
  // The other files the run wrote, so a folder whose captions.json went
1276
1290
  // is still known as the run's.
1277
1291
  files,
@@ -1323,11 +1337,14 @@ async function makeSubtitles(wordsFile, options) {
1323
1337
  const maxChars = options.maxChars || 42;
1324
1338
  const { place, lines } = cueLayout({ ...options, maxChars }, width, height, places);
1325
1339
  notes.push(...place.notes);
1326
- const fitted = video ? fitWords(read, video.duration) : { words: read.words, notes: [] };
1340
+ // No caption runs past the video's end: the video's, or with no video
1341
+ // the one steps.json gives the demo's video, as words.json keeps it.
1342
+ const end = video ? video.duration : read.video ? read.video.duration : Infinity;
1343
+ const fitted = fitWords(read, end, video ? "" : shown(path.basename(wordsFile)));
1327
1344
  notes.push(...fitted.notes);
1328
1345
  const words = fitted.words;
1329
1346
  log(`Read ${count(words.length, "word", "words")} from ${shown(path.basename(wordsFile))} (${read.about})`);
1330
- const shape = { lines: options.style === "word" ? 1 : lines, hard: read.hard, duration: video ? video.duration : Infinity };
1347
+ const shape = { lines: options.style === "word" ? 1 : lines, hard: read.hard, duration: end };
1331
1348
  const cues = buildCues(words, { ...shape, maxChars });
1332
1349
  // Words said after the picture ends, while the sound runs on, are heard.
1333
1350
  if (video && video.soundPast && cues.some((cue) => cue.end > video.picture.end + 0.05)) notes.push(`The video's picture ends at ${video.picture.end.toFixed(2)} s, and its sound runs on to ${video.duration.toFixed(2)} s. The captions go on to the end of the sound, over the last frame, which players hold.`);
@@ -1381,7 +1398,7 @@ async function makeSubtitles(wordsFile, options) {
1381
1398
  };
1382
1399
  fs.writeFileSync(path.join(run.work, "CAPTIONS.md"), subtitlesMarkdown(facts));
1383
1400
  const files = writtenFiles(run.work, ["captions.srt", "captions.vtt", "captions.ass", "CAPTIONS.md"]);
1384
- fs.writeFileSync(path.join(run.work, "words.json"), JSON.stringify(wordsJson(words, read, files, first), null, 2) + "\n");
1401
+ fs.writeFileSync(path.join(run.work, "words.json"), JSON.stringify(wordsJson(words, read, files, first, end), null, 2) + "\n");
1385
1402
  const json = captionsJson({ ...facts, files: { ...files, ...writtenFiles(run.work, ["words.json"]) } });
1386
1403
  fs.writeFileSync(path.join(run.work, "captions.json"), JSON.stringify(json, null, 2) + "\n");
1387
1404
  await heard();
@@ -1392,4 +1409,4 @@ async function makeSubtitles(wordsFile, options) {
1392
1409
  }
1393
1410
  }
1394
1411
 
1395
- module.exports = { captionVideo, makeSubtitles, slug, notAddresses, writable, guard, recover, begin, finish, abandon, frameFor, encodingLine, percentOf, missingNotes, signalsOf, stopOnSignals, stopped, began, OUTPUTS, TOOL, shown, named };
1412
+ module.exports = { captionVideo, makeSubtitles, slug, notAddresses, writable, guard, recover, begin, finish, abandon, frameFor, encodingLine, percentOf, missingNotes, boxesNotes, signalsOf, stopOnSignals, stopped, began, OUTPUTS, TOOL, shown, named };
@@ -568,4 +568,4 @@ function chooseLook(options = {}) {
568
568
  };
569
569
  }
570
570
 
571
- module.exports = { chooseLook, readBrand, readFontsCss, parseColor, contrast, luminance, highlightMode, brandHighlight, variableFace, machineFont, fallbackFont, sameName, DEFAULT_HIGHLIGHT };
571
+ module.exports = { chooseLook, readBrand, readFontsCss, parseColor, contrast, luminance, highlightMode, brandHighlight, variableFace, machineFont, fallbackFont, fcName, sameName, DEFAULT_HIGHLIGHT };
@@ -13,6 +13,7 @@ const { launch, openPage, evaluate } = require("./chrome");
13
13
  const { cueLines } = require("./cues");
14
14
  const { inkOf } = require("./places");
15
15
  const { writeApng } = require("./apng");
16
+ const { clusters } = require("./text");
16
17
 
17
18
  // The cue's lines as words, each with whether a space goes before it.
18
19
  const lineWords = cueLines;
@@ -198,11 +199,14 @@ function pageHtml(look, place, style, data) {
198
199
 
199
200
  // Draws every state of the timeline to PNGs in `dir` and puts them in one
200
201
  // animated PNG. Returns { file, y, height, reach, states, fonts, first,
201
- // failedFonts, shrunk, scales }: the band the PNGs cover, the rows the
202
- // captions reach in it, how many states there are, which fonts drew the
203
- // letters and which of them is the first of the list this machine has,
204
- // and how many captions had to be drawn smaller to fit.
205
- async function renderStates({ cues, words, states, style, look, place, duration, dir, log = () => {} }) {
202
+ // failedFonts, shrunk, scales, boxes }: the band the PNGs cover, the rows
203
+ // the captions reach in it, how many states there are, which fonts drew
204
+ // the letters and which of them is the first of the list this machine
205
+ // has, how many captions had to be drawn smaller to fit, and the letters
206
+ // Chrome drew as boxes. `boxed(letters, fonts)` gives those, of the
207
+ // letters of the captions and the fonts Chrome names for them
208
+ // (chromeBoxes); without it, none are.
209
+ async function renderStates({ cues, words, states, style, look, place, duration, dir, log = () => {}, boxed = null }) {
206
210
  const W = place.frame.width;
207
211
  const H = place.frame.height;
208
212
  const ink = place.ink || inkOf(place.fontPx, { style, box: look.box });
@@ -248,7 +252,13 @@ async function renderStates({ cues, words, states, style, look, place, duration,
248
252
  })()`,
249
253
  60_000,
250
254
  );
251
- const fonts = await fontsOf(page, probeText);
255
+ const named = await fontsOf(page, probeText);
256
+ // A letter no font Chrome found has is a box, which Chrome says the
257
+ // first font of the list drew. Those letters are left out when it is
258
+ // asked again, so the fonts it names drew the letters that show.
259
+ const boxes = boxed ? boxed([...new Set(probeText.split("\n").flatMap((word) => clusters(word)))], named) : [];
260
+ const rest = boxes.length ? withoutLetters(probeText, new Set(boxes)) : probeText;
261
+ const fonts = !boxes.length ? named : rest ? await fontsOf(page, rest) : [];
252
262
  // The first font of the list this machine has: the one that draws
253
263
  // plain Latin letters. The others draw only what it lacks.
254
264
  const latin = await fontsOf(page, "Hamburgefonstiv");
@@ -305,12 +315,21 @@ async function renderStates({ cues, words, states, style, look, place, duration,
305
315
  const shrunk = all.filter((s) => s < 1).length;
306
316
  // The rows every state stays in, its outline and shadow included.
307
317
  const reach = Number.isFinite(fit.top) ? { top: Math.max(0, Math.floor(fit.top)), bottom: Math.min(H, Math.ceil(fit.bottom)) } : null;
308
- return { file: animation, y, height, reach, states: made.size + 1, fonts, first: latin.length ? latin[0].family : "", failedFonts: loaded || [], shrunk, smallest: all.reduce((a, b) => Math.min(a, b), 1), scales: fit.scales };
318
+ return { file: animation, y, height, reach, states: made.size + 1, fonts, first: latin.length ? latin[0].family : "", failedFonts: loaded || [], shrunk, smallest: all.reduce((a, b) => Math.min(a, b), 1), scales: fit.scales, boxes };
309
319
  } finally {
310
320
  await browser.close();
311
321
  }
312
322
  }
313
323
 
324
+ // The words of the probe, one a line, without the letters of `left`: a
325
+ // word of those letters alone goes.
326
+ const withoutLetters = (text, left) =>
327
+ text
328
+ .split("\n")
329
+ .map((word) => clusters(word).filter((c) => !left.has(c)).join(""))
330
+ .filter(Boolean)
331
+ .join("\n");
332
+
314
333
  // Which fonts Chrome draws a text with, and how many letters each. Chrome
315
334
  // says so only of text it has laid out, and a text just set is laid out at
316
335
  // the next frame: the probe is laid out in the same step its text is set
@@ -19,6 +19,41 @@ const clock = (t) => {
19
19
  };
20
20
  const list = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
21
21
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
22
+
23
+ // The captions letters are in, of [{ letter, cue }], as a note says them:
24
+ // "caption 3", "captions 1, 2 and 5", or the first six and how many more.
25
+ function captionsOf(items) {
26
+ const cues = [...new Set(items.map((m) => m.cue))];
27
+ return `${cues.length === 1 ? "caption" : "captions"} ${cues.length > 6 ? `${cues.slice(0, 6).join(", ")} and ${cues.length - 6} more` : list(cues.map(String))}`;
28
+ }
29
+
30
+ // The letters themselves, each once: all of them, or the first eight and
31
+ // how many more.
32
+ function lettersOf(items) {
33
+ const letters = [...new Set(items.map((m) => m.letter))];
34
+ return letters.length > 8 ? `${letters.slice(0, 8).join(" ")} and ${letters.length - 8} more` : letters.join(" ");
35
+ }
36
+
37
+ // The letters the video shows as boxes, [{ letter, cue }], as a sentence:
38
+ // Chrome found no font for them, or no font libass can use has them.
39
+ function boxesText(boxes, drawnWith) {
40
+ const n = boxes.length;
41
+ if (drawnWith === "libass") return `No font on this machine that libass can use has ${plural(n, "letter", "letters")} of ${captionsOf(boxes)} (${lettersOf(boxes)}), so the video shows ${n === 1 ? "a box for it" : "boxes for them"}.`;
42
+ return `Chrome found no font on this machine for ${plural(n, "letter", "letters")} of ${captionsOf(boxes)} (${lettersOf(boxes)}), so it drew a box for ${n === 1 ? "it" : "each of them"}.`;
43
+ }
44
+
45
+ // What to do about letters no font here has: install one that has them,
46
+ // named where one font covers them all, as Noto Sans CJK covers Chinese,
47
+ // Japanese and Korean, or give a font kit whose font has them.
48
+ const CJK = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo} -〿＀-￯]/u;
49
+ const PICTURE = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u;
50
+ function installText(items) {
51
+ const letters = items.map((m) => m.letter);
52
+ const them = letters.length === 1 ? "it" : "them";
53
+ if (letters.every((c) => PICTURE.test(c))) return "Install a color emoji font, such as Noto Color Emoji (fonts-noto-color-emoji on Debian and Ubuntu), and run again.";
54
+ const font = letters.every((c) => CJK.test(c)) ? "Noto Sans CJK (fonts-noto-cjk on Debian and Ubuntu)" : `a Noto font for ${letters.length === 1 ? "its" : "their"} script`;
55
+ return `Install a font that has ${them}, such as ${font}, or give --fonts an easyfontkit folder whose font has ${them}, and run again.`;
56
+ }
22
57
  // A name on one line of Markdown or of the terminal: its control
23
58
  // characters become a space, as a line break in it would end a heading or
24
59
  // a list item, or start a line of its own in a message, and an escape
@@ -197,17 +232,24 @@ function fallbackLine(f) {
197
232
  const n = fb.reduce((a, b) => a + b.letters, 0);
198
233
  const where = `in ${list(fb.map((x) => x.family))}, which fontconfig gives for ${n === 1 ? "it" : "them"}, at the size that makes ${n === 1 ? "it a" : "them"} ${f.layout.fontPx} px ${n === 1 ? "letter" : "letters"} too.`;
199
234
  if (setsNone(f)) return `The font has none of the ${plural(n, "letter", "letters")}: captions.ass sets ${n === 1 ? "it" : "them"} ${where}`;
200
- return `captions.ass sets the ${plural(n, "letter", "letters")} the font lacks ${where}`;
235
+ // Where the video shows boxes too, the font lacks more letters than these.
236
+ return `captions.ass sets ${boxedIn(f) ? "" : "the "}${plural(n, "letter", "letters")} the font lacks ${where}`;
201
237
  }
202
238
 
203
239
  // Whether the font captions.ass names sets any letter of the captions,
204
- // and whether it sets none and other fonts set them.
240
+ // and whether it sets none and other fonts set them all, with no letter
241
+ // libass showed as a box.
205
242
  const setsAny = (f) => !f.assLetters || f.assLetters.own > 0;
206
- const setsNone = (f) => !setsAny(f) && (f.assFallbacks || []).length > 0;
243
+ const setsNone = (f) => !setsAny(f) && (f.assFallbacks || []).length > 0 && !boxedIn(f);
244
+
245
+ // Whether the video shows letters as boxes.
246
+ const boxedIn = (f) => (f.boxes || []).length > 0;
207
247
 
208
248
  // The font the captions were drawn with: none named, the first of the
209
249
  // common sans fonts this machine has, which Chrome found for Latin
210
- // letters, and the fonts that drew the letters it lacks.
250
+ // letters, and the fonts that drew the letters it lacks. The letters the
251
+ // video shows as boxes, which no font drew, are said after the font, and
252
+ // the letters counted after them are the others.
211
253
  function fontLines(f) {
212
254
  const lines = [];
213
255
  const look = f.look;
@@ -217,25 +259,36 @@ function fontLines(f) {
217
259
  const m = libass ? f.libassFont || null : null;
218
260
  const fill = (fonts) => list(fonts.map((x) => (fonts.length > 1 ? `${x.family} (${x.glyphs})` : x.family)));
219
261
  const filled = libass && fallbackLine(f) ? [` ${fallbackLine(f)}`] : [];
262
+ const boxed = boxedIn(f) ? [` ${boxesText(f.boxes, f.drawnWith)}`] : [];
263
+ const other = boxed.length ? "other " : "";
264
+ const ofAll = boxed.length ? `of the other ${total}` : `of ${total}`;
265
+ // The letters the fonts drew as a sentence names them, where one is left
266
+ // beside the boxes, or is all there is: "the other letter", which "it"
267
+ // stands for, and never "the other 1 letters".
268
+ const one = total === 1;
269
+ const it = one ? "it" : "them";
270
+ const letters = `the ${other}${one ? "letter" : "letters"}`;
271
+ const counted = one ? letters : `the ${other}${total} letters`;
272
+ const lacksThem = (fonts) => `It ${one ? "does not have" : "has none of"} ${counted}, so Chrome drew ${it} with ${fill(fonts)}.`;
220
273
  if (!look.family) {
221
274
  if (libass) {
222
- lines.push(`- Font: Arial ${look.weight}, as captions.ass asks for it by name. ${m && sameName(m.family, "Arial") ? "This machine has it." : givenFor(m)}`, ...filled);
275
+ lines.push(`- Font: Arial ${look.weight}, as captions.ass asks for it by name. ${m && sameName(m.family, "Arial") ? "This machine has it." : givenFor(m)}`, ...boxed, ...filled);
223
276
  return lines;
224
277
  }
225
278
  // Chrome did not say which font is the first of the list: the fonts
226
279
  // that drew, and none named as that one.
227
280
  const first = f.fontFirst || "";
228
281
  if (!first) {
229
- lines.push(`- Font: a common sans ${look.weight}, the first of the common sans fonts easycaptionkit asks for that this machine has.`);
230
- if (drawn.length) lines.push(` Chrome drew the ${total} letters with ${fill(drawn)}.`);
282
+ lines.push(`- Font: a common sans ${look.weight}, the first of the common sans fonts easycaptionkit asks for that this machine has.`, ...boxed);
283
+ if (drawn.length) lines.push(` Chrome drew ${counted} with ${fill(drawn)}.`);
231
284
  return lines;
232
285
  }
233
- lines.push(`- Font: ${first} ${look.weight}, the first of the common sans fonts easycaptionkit asks for that this machine has.`);
286
+ lines.push(`- Font: ${first} ${look.weight}, the first of the common sans fonts easycaptionkit asks for that this machine has.`, ...boxed);
234
287
  const own = drawn.find((x) => x.family === first);
235
288
  const others = drawn.filter((x) => x.family !== first);
236
289
  if (others.length) {
237
290
  const rest = total - (own ? own.glyphs : 0);
238
- lines.push(own ? ` Chrome drew ${own.glyphs} of ${total} letters with it, and the ${rest} it lacks with ${fill(others)}.` : ` It has none of the ${total} letters, so Chrome drew them with ${fill(others)}.`);
291
+ lines.push(own ? ` Chrome drew ${own.glyphs} ${ofAll} letters with it, and the ${rest} it lacks with ${fill(others)}.` : ` ${lacksThem(others)}`);
239
292
  }
240
293
  lines.push(...lighterLines(look, others));
241
294
  return lines;
@@ -246,12 +299,20 @@ function fontLines(f) {
246
299
  if (m && sameName(m.family, look.family)) lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine.`);
247
300
  else if (m) lines.push(`- Font: ${look.family} ${look.weight} was asked for by name, and this machine does not have it: ${givenFor(m)} Give easyfontkit's folder with --fonts to burn the real font.`);
248
301
  else lines.push(`- Font: ${look.family} ${look.weight}, asked for by name. ${givenFor(m)}`);
249
- lines.push(...filled);
302
+ lines.push(...boxed, ...filled);
303
+ return lines;
304
+ }
305
+ // Not on this machine: Chrome drew the letters with other fonts, or
306
+ // drew every letter as a box, and its first font is another.
307
+ const absent = !machineHas(f) && (drawn.length ? !drawn.some((x) => drewWith(x, look)) : boxed.length > 0);
308
+ if (absent) {
309
+ if (boxed.length) lines.push(`- Font: ${look.family} ${look.weight} was asked for by name, and this machine does not have it. Give easyfontkit's folder with --fonts to burn the real font.`, ...boxed, ...(drawn.length ? [` Chrome drew ${letters} with ${fill(drawn)}.`] : []));
310
+ else lines.push(`- Font: ${look.family} ${look.weight} was asked for by name, and this machine does not have it, so Chrome drew ${letters} with ${fill(drawn)}. Give easyfontkit's folder with --fonts to burn the real font.`);
250
311
  return lines;
251
312
  }
252
313
  if (drawn.length && !drawn.some((x) => drewWith(x, look))) {
253
- if (machineHas(f)) lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine. It has none of the ${total} letters, so Chrome drew them with ${fill(drawn)}.`, ...lighterLines(look, drawn));
254
- else lines.push(`- Font: ${look.family} ${look.weight} was asked for by name, and this machine does not have it, so Chrome drew the letters with ${fill(drawn)}. Give easyfontkit's folder with --fonts to burn the real font.`);
314
+ if (boxed.length) lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine.`, ...boxed, ` ${lacksThem(drawn)}`, ...lighterLines(look, drawn));
315
+ else lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine. ${lacksThem(drawn)}`, ...lighterLines(look, drawn));
255
316
  return lines;
256
317
  }
257
318
  lines.push(`- Font: ${look.family} ${look.weight}, as installed on this machine.`);
@@ -260,18 +321,19 @@ function fontLines(f) {
260
321
  lines.push(`- Font: ${look.family}, from ${files}. That file holds the weights ${face.weight[0]} to ${face.weight[1]}, and libass draws such a file at its default weight, not at ${look.weight}, so the letters are thinner than Chrome draws them.`);
261
322
  } else lines.push(`- Font: ${look.family} ${look.weight}, from ${files}.`);
262
323
  if (f.assFamily && !sameName(f.assFamily, look.family)) lines.push(` captions.ass names it ${f.assFamily}, the family name in its file, which libass and the players that read ASS find a font by.`);
263
- if (libass) lines.push(" libass drew the letters and does not say which font drew each.", ...filled);
324
+ if (libass) lines.push(" libass drew the letters and does not say which font drew each.", ...boxed, ...filled);
325
+ else lines.push(...boxed);
264
326
  if (!drawn.length) return lines;
265
327
  // A font file of fonts.css is the only web font on the page, and Chrome
266
328
  // names it by the family in the file, which fonts.css can call
267
329
  // otherwise.
268
330
  const own = drawn.filter((x) => drewWith(x, look));
269
331
  const others = drawn.filter((x) => !drewWith(x, look));
270
- if (!own.length) lines.push(` Chrome drew no letter with the file of ${look.family}: ${drawn[0].family} drew them.`);
271
- else if (!others.length) lines.push(" Chrome drew every letter with it.");
332
+ if (!own.length) lines.push(` Chrome ${!boxed.length ? "drew no letter" : one ? "did not draw the other letter" : "drew none of the other letters"} with the file of ${look.family}: ${drawn[0].family} drew ${it}.`);
333
+ else if (!others.length) lines.push(boxed.length ? ` Chrome drew ${one ? "the other letter" : "all the other letters"} with it.` : " Chrome drew every letter with it.");
272
334
  else {
273
335
  const mine = own.reduce((a, b) => a + b.glyphs, 0);
274
- lines.push(` Chrome drew ${mine} of ${total} letters with it, and the ${total - mine} it lacks with ${fill(others)}.`);
336
+ lines.push(` Chrome drew ${mine} ${ofAll} letters with it, and the ${total - mine} it lacks with ${fill(others)}.`);
275
337
  }
276
338
  lines.push(...lighterLines(look, others));
277
339
  return lines;
@@ -298,10 +360,11 @@ function lighterLines(look, drawn) {
298
360
  // The fonts that drew every letter of the captions where the font asked
299
361
  // for, by name or from fonts.css, drew none of them, as a Latin font draws
300
362
  // no Japanese: the fonts Chrome drew them with, or with libass the fonts
301
- // captions.ass sets them in. Their names, or [] when the font drew any
302
- // letter, no font was asked for, or nothing says.
363
+ // captions.ass sets them in. Letters that show as boxes, which no font
364
+ // drew, do not count. Their names, or [] when the font drew any letter, no
365
+ // font was asked for, or nothing says.
303
366
  function drewNone(f) {
304
- if (f.drawnWith === "libass") return f.look.family && setsNone(f) ? f.assFallbacks.map((x) => x.family) : [];
367
+ if (f.drawnWith === "libass") return f.look.family && !setsAny(f) && (f.assFallbacks || []).length ? f.assFallbacks.map((x) => x.family) : [];
305
368
  const drawn = (f.fontsDrawn || []).filter((x) => x.glyphs > 0);
306
369
  if (!f.look.family || !drawn.length || drawn.some((x) => drewWith(x, f.look))) return [];
307
370
  return drawn.map((x) => x.family);
@@ -341,7 +404,8 @@ function licenseSection(f) {
341
404
  // ones that drew them are, whatever its license.
342
405
  const instead = drewNone(f);
343
406
  if (instead.length) {
344
- const drew = libass ? "captions.ass sets them all in" : "Chrome drew them all with";
407
+ const which = boxedIn(f) ? "all but the boxes" : "them all";
408
+ const drew = libass ? `captions.ass sets ${which} in` : `Chrome drew ${which} with`;
345
409
  // A font asked for by name that this machine has, or one from fonts.css, lacks the letters.
346
410
  const lacks = look.from === "fonts.css" || (libass ? f.libassFont && sameName(f.libassFont.family, look.family) : machineHas(f));
347
411
  const lead = read ? `${read}, but no letter of it is in the video` : `No letter of ${look.family} is in the video`;
@@ -601,4 +665,4 @@ function captionsJson(f) {
601
665
  };
602
666
  }
603
667
 
604
- module.exports = { captionsMarkdown, subtitlesMarkdown, captionsJson, marginsText, drewWith, setsAny, setsNone, machineHas, arg, quote, code, clock, subtitlesCommand, programOf, shown, soundText };
668
+ module.exports = { captionsMarkdown, subtitlesMarkdown, captionsJson, marginsText, drewWith, setsAny, setsNone, boxedIn, machineHas, captionsOf, lettersOf, boxesText, installText, arg, quote, code, clock, subtitlesCommand, programOf, shown, soundText };
@@ -121,12 +121,10 @@ const styleName = (name) => Boolean(name) && !/[\u0000-\u001f\u007f,]/.test(name
121
121
  // with. libass sizes each font by its own heights, so captions.ass sets
122
122
  // those letters in that font at a size of its own: the font then has
123
123
  // `fallback(c)`, the font a letter is set in, { family, scale, advance },
124
- // or null.
125
- function assFont(look, machine = null, { fallback = null, budget = BUDGET } = {}) {
126
- const upright = (look.faces || []).filter((f) => !f.italic);
127
- const faces = upright.length ? upright : look.faces || [];
128
- const nearest = faces.length ? Math.min(...faces.map((f) => distance(f, look.weight))) : 0;
129
- const files = faces.filter((f) => distance(f, look.weight) === nearest);
124
+ // or null. `lacks(codePoint)` says no font on this machine has a letter,
125
+ // which then needs no asking (boxes.js).
126
+ function assFont(look, machine = null, { fallback = null, budget = BUDGET, lacks = null } = {}) {
127
+ const files = facesAt(look);
130
128
  const kit = files.length ? familyOf(files, budget) : null;
131
129
  const first = kit ? kit.first : machine ? readFont(machine.file, machine.index) : null;
132
130
  const scale = first ? assScale(first) : 0;
@@ -135,10 +133,31 @@ function assFont(look, machine = null, { fallback = null, budget = BUDGET } = {}
135
133
  const has = kit ? kit.has : (c) => Number.isFinite(first.advance(c));
136
134
  const own = kit ? kit.family : "";
137
135
  const font = { scale, advance, has, from: files.length ? files[0].name || files[0].file : machine.file, family: styleName(own) ? own : "" };
138
- if (fallback) font.fallback = fallbacksOf(fallback);
136
+ if (fallback) font.fallback = fallbacksOf(fallback, lacks);
139
137
  return font;
140
138
  }
141
139
 
140
+ // The faces of a fonts.css family the captions are drawn with, by Chrome
141
+ // and by libass: the upright ones nearest the weight.
142
+ function facesAt(look) {
143
+ const upright = (look.faces || []).filter((f) => !f.italic);
144
+ const faces = upright.length ? upright : look.faces || [];
145
+ const nearest = faces.length ? Math.min(...faces.map((f) => distance(f, look.weight))) : 0;
146
+ return faces.filter((f) => distance(f, look.weight) === nearest);
147
+ }
148
+
149
+ // Whether the files of a fonts.css family the captions are drawn with
150
+ // have a letter, one code point: has(c), or null for a look with no
151
+ // files. A letter is taken to be there when a file that could hold it
152
+ // was not read, as one past what is read of a family: only a letter
153
+ // they are known to lack is not.
154
+ function kitLetters(look, budget = BUDGET) {
155
+ const files = facesAt(look);
156
+ if (!files.length) return null;
157
+ const kit = familyOf(files, budget);
158
+ return (c) => kit.has(c) || kit.unread(c);
159
+ }
160
+
142
161
  // What is read of a fonts.css family's files in all, at most, counting
143
162
  // what WOFF and WOFF2 files unpack to: a site splits a CJK family into a
144
163
  // hundred files and more, each small, and a file of a few hundred bytes
@@ -159,10 +178,11 @@ function rangesOf(text) {
159
178
  // The files of a fonts.css family at one weight, each read once a letter
160
179
  // in its unicode-range is asked for, as Chrome loads them, and no more than
161
180
  // `budget` bytes of them in all, files and what they unpack to. { first,
162
- // family, advance, has }: the first file that reads, which gives the size,
163
- // and the family name in it or in the next file that has one. A letter in
164
- // the range of a file that was not read, or is no font this kit reads, is
165
- // the family's still: libass finds that file by its name.
181
+ // family, advance, has, unread }: the first file that reads, which gives
182
+ // the size, and the family name in it or in the next file that has one.
183
+ // A letter in the range of a file that was not read, or is no font this
184
+ // kit reads, is the family's still: libass finds that file by its name.
185
+ // unread(c) says a file that could hold the letter was not read.
166
186
  function familyOf(faces, budget) {
167
187
  const list = faces.map((face) => ({ face, ranges: rangesOf(face.unicodeRange), font: undefined }));
168
188
  let left = budget;
@@ -179,16 +199,17 @@ function familyOf(faces, budget) {
179
199
  const lookUp = (c) => {
180
200
  const code = c.codePointAt(0);
181
201
  if (known.has(code)) return known.get(code);
182
- let found = { advance: NaN, has: false };
202
+ let found = { advance: NaN, has: false, unread: false };
183
203
  for (const item of list) {
184
204
  if (!inRange(item, code)) continue;
185
205
  const font = fontOf(item);
186
206
  const w = font ? font.advance(c) : NaN;
187
207
  if (Number.isFinite(w)) {
188
- found = { advance: w, has: true };
208
+ found = { advance: w, has: true, unread: false };
189
209
  break;
190
210
  }
191
211
  if (!font && item.ranges) found.has = true;
212
+ if (!font) found.unread = true;
192
213
  }
193
214
  known.set(code, found);
194
215
  return found;
@@ -201,7 +222,7 @@ function familyOf(faces, budget) {
201
222
  if (font && font.family) family = font.family;
202
223
  if (first && family) break;
203
224
  }
204
- return { first, family, advance: (c) => lookUp(c).advance, has: (c) => lookUp(c).has };
225
+ return { first, family, advance: (c) => lookUp(c).advance, has: (c) => lookUp(c).has, unread: (c) => lookUp(c).unread };
205
226
  }
206
227
 
207
228
  // A name an override tag can carry: it ends at a backslash or a brace.
@@ -222,13 +243,17 @@ const NONE = Object.freeze({ none: true });
222
243
  // that has them all. libass draws no font of pictures alone, as a color
223
244
  // emoji font is, and fontconfig gives a font that lacks a letter only
224
245
  // when no font has it: `none(c)` says fontconfig gives no font libass can
225
- // draw the letter with, so libass draws a box for it.
226
- function fallbacksOf(give) {
246
+ // draw the letter with, so libass draws a box for it. A letter `lacks`
247
+ // says no font on this machine has is one too, without asking, and asks
248
+ // none of the 64: so every such letter is counted, however many there
249
+ // are, as in Japanese on a machine with no Japanese font.
250
+ function fallbacksOf(give, lacks = null) {
227
251
  const found = [];
228
252
  const asked = new Map();
229
253
  const fontFor = (first, arabic) => {
230
254
  const code = first.codePointAt(0);
231
255
  if (asked.has(code)) return asked.get(code);
256
+ if (lacks && lacks(code)) return NONE;
232
257
  if (asked.size >= 64) return null;
233
258
  const given = give(code);
234
259
  let got = null;
@@ -450,6 +475,10 @@ function ass(cues, words, { states, style, look, place, title = "", level = 1, s
450
475
  // default, one asked for so, or a flag. A © or a ™ is a letter.
451
476
  const SHOWN_AS_EMOJI = /\p{Emoji_Presentation}|\ufe0f|\p{Regional_Indicator}/u;
452
477
 
478
+ // A no-break hyphen no font here has is no box where the font has the
479
+ // plain hyphen: HarfBuzz, which libass shapes with, draws that for it.
480
+ const hyphenFor = (c, font) => c === "\u2011" && font.has("\u2010");
481
+
453
482
  // How captions.ass sets the letters of the captions, the spaces left out:
454
483
  // { total, own, fallbacks, missing, unsure }. own is how many are in the
455
484
  // font it names, or where nothing says otherwise; fallbacks the fonts it
@@ -471,7 +500,7 @@ function assLetters(cues, font) {
471
500
  const lacks = !font.has(Array.from(c)[0]);
472
501
  const other = setIn(c, font, null);
473
502
  if (other) counts.set(other.family, (counts.get(other.family) || 0) + 1);
474
- else if (font.fallback && lacks && font.fallback.none(c)) missing.push({ letter: c, cue: cue.n });
503
+ else if (font.fallback && lacks && font.fallback.none(c) && !hyphenFor(c, font)) missing.push({ letter: c, cue: cue.n });
475
504
  else {
476
505
  own++;
477
506
  if (lacks && SHOWN_AS_EMOJI.test(c)) unsure.push({ letter: c, cue: cue.n });
@@ -486,4 +515,4 @@ function assLetters(cues, font) {
486
515
  // names, by that font: [{ family, letters }].
487
516
  const fallbackLetters = (cues, font) => assLetters(cues, font).fallbacks;
488
517
 
489
- module.exports = { srt, vtt, ass, assFont, estimatedScales, shrunkOf, assLetters, fallbackLetters, clock, assClock, assColor, assTag, dimmed, escapeSrt, SRT_SPECIAL, ARIAL };
518
+ module.exports = { srt, vtt, ass, assFont, kitLetters, estimatedScales, shrunkOf, assLetters, fallbackLetters, clock, assClock, assColor, assTag, dimmed, escapeSrt, SRT_SPECIAL, ARIAL };
@@ -2,4 +2,4 @@
2
2
 
3
3
  // Kept here, not read from package.json: a skill install copies this folder
4
4
  // alone.
5
- module.exports = "0.1.0";
5
+ module.exports = "0.1.2";
@@ -645,7 +645,12 @@ function detectJson(data) {
645
645
  // `from`: a demo's steps stay steps when their words.json is read
646
646
  // again, in its own folder or in another.
647
647
  const first = Object.keys(FORMATS).find((key) => key !== "easycaptionkit" && FORMATS[key] === data.from);
648
- return shape({ format: "easycaptionkit", words, hard: Boolean(data.hard), fixed: handFixed(data), ...(first ? { first } : {}) }, data.words);
648
+ // The length steps.json gave the demo's video, which words.json
649
+ // keeps, so a subtitles run with no video ends the captions there
650
+ // again, as the run from steps.json did.
651
+ const length = entry(data.demo) ? numberOf(data.demo.duration) : NaN;
652
+ const demo = Number.isFinite(length) && length > 0 ? { duration: length, file: "" } : null;
653
+ return shape({ format: "easycaptionkit", words, hard: Boolean(data.hard), fixed: handFixed(data), ...(first ? { first } : {}), ...(demo ? { video: demo } : {}) }, data.words);
649
654
  }
650
655
  if (data.kind === "demo" && Array.isArray(data.steps)) return shape({ format: "steps", ...readSteps(data, listed(data.steps, read)) }, data.steps);
651
656
  if (Array.isArray(data.transcription)) return shape({ format: "whispercpp", words: readWhisperCpp(listed(data.transcription, read), read) }, data.transcription);
@@ -1083,6 +1088,8 @@ function readWords(file, given = null) {
1083
1088
  // words.json a run writes from them keeps saying.
1084
1089
  fixed: Boolean(found.fixed),
1085
1090
  notes,
1091
+ // The demo's video as steps.json gives it, { duration, file }, also
1092
+ // from a words.json made of steps, which keeps its length.
1086
1093
  video: found.video || null,
1087
1094
  };
1088
1095
  }