Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 566af0096a78e231d6867e6c5d5b397b16e09daf
parent a0026d3f077aafd69dd7fe1c97611a8252bde013
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 03:50:42 -0400

Merge r11/umtool-fonts — release 11 slice O5: under the Archilyzer Media preset the report video's rail, ledger, scroll and chart are set in IBM Plex Sans, fitted by the vendored face's own advances; IBM Plex Mono Bold vendored for the chapter kickers; a render without the preset stays byte-identical (334 files, 0 different)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mplans/release-11.md | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/README.md | 56++++++++++++++++++++++++++++++++++++++++++++++----------
Mumtool/report-to-video/brand.mjs | 28+++++++++++++++++++++++++---
Mumtool/report-to-video/brand.test.mjs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Aumtool/report-to-video/face-metrics.mjs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/fonts/IBMPlexMono-Bold.ttf | 0
Aumtool/report-to-video/fonts/gen-face-metrics.py | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/render-cards.mjs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Aumtool/report-to-video/svg-faces.mjs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/svg-faces.test.mjs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 934 insertions(+), 58 deletions(-)

diff --git a/plans/release-11.md b/plans/release-11.md @@ -1111,6 +1111,177 @@ with the new tests kept: and the hub's fitted hex), the homepage (all four), and the editor on its next restart (the sidebar outline). +### Slice O5, as shipped — umtool brand fonts (2026-09-28) + +Branch `r11/umtool-fonts` off `main` `2162db92`, fast-forwarded to `main` `cb9d02b2` (O4, O3, O6 +checkpoint A) before any change; worktree `/home/user/Projects/r11-umtool-fonts`, port block #8 +(umtool e2e 3851, stub 3852). One Opus implementer; scratch files `o5-*` in the job's +`tmp/overnight`. Everything is under `umtool/report-to-video/**`; nothing in `umtool/app/**`. + +**Why.** S4 left two things for later ([`brand-and-themes.md`](brand-and-themes.md), "Slice S4, as +shipped"): the rail, `ledger`, `scroll` and `chart` SVG text stayed Fira Sans under the brand, +because `fit()` truncates against Fira's 0.50 em average; and `render.fontBold` stayed whatever the +manifest said, because no bold Plex Mono was vendored. O5 does both, and an unbranded render stays +byte-identical. + +**The measurement, and why the brand's metric is a table rather than an average.** Nothing +recorded how 0.50 was measured, so O5 fixed a method and checked that it gives Fira's own number: +the mean advance per character (fontTools hmtx; Fira from the system files fontconfig resolves, Plex +Sans from the vendored variable font instanced at wdth 100) over the text these assets draw. The +`fit()` corpus is the five S4 manifests' track labels, log and scroll labels, roster lines, column +heads and quotes, read and never written (`o5-corpus.json`, 144 strings, all from +`quartering-employee-count`, the only one with a rail). + +| face | weight | `fit()` strings | p95 of a string's own mean | every SVG text run | capitals | +|---|---|---|---|---|---| +| Fira Sans | 400 | 0.457 em | 0.492 | 0.463 | 0.549 | +| Fira Sans | 700 | 0.462 | 0.497 | 0.525 | 0.550 | +| IBM Plex Sans | 400 | 0.456 | 0.497 | 0.469 | 0.590 | +| IBM Plex Sans | 700 | 0.479 | 0.521 | 0.577 | 0.613 | + +So 0.50 is Fira's mean plus the "hair" its comment describes, just over its p95. By the same method +Plex Sans Regular gets 0.50 too, and Bold 0.53. **But an average cannot keep the promise the slice +makes, that a branded column never overruns** (`o5-stress.log`). Every real string (87, 77 over 20 +characters) was pushed through every call site's size, budget, weight and letter-spacing: +- **Fira's own 0.50 overruns** the scroll's bold, letter-spaced column head in 40 of 87 runs (up + to 11 %) and the rail's track label once; +- Plex at 0.50 / 0.53 overruns the head in 35 of 87 runs; +- uppercased, every site overruns in both faces at every average tried (0.50 to 0.55). + +None of the manifests' labels comes near a budget as written (the tightest is a wrapped quote line +at 95 %), which is why this never showed. So the preset's metric is **the face's own advances**: +- `face-metrics.mjs` is generated by `fonts/gen-face-metrics.py` (fontTools 4.65.0, instancer) + from the vendored `IBMPlexSans[wdth,wght].ttf`. It holds the advance of all 891 characters the + font maps at wght 400 and 700, wdth 100 (11.5 kB, do-not-edit header, the font's sha256). +- `fit()` keeps the longest prefix whose width, ellipsis, weight and letter-spacing included, is + inside the budget. +- Fira keeps its 0.50 average and its exact character counts. It is the system's font, so a table + of its advances could not be pinned anyway. +- Kerning is ignored. Over the proof's 24 Plex runs, the plain sum was at or above the rendered ink + width of every one (by 0.19 px at the least), so the sum is the conservative side. +- A character Plex does not map (emoji, CJK) counts 1.3 em. + +**What shipped.** +- **`svg-faces.mjs`:** `FIRA_SANS` (`{family, em: 0.5}`), `IBM_PLEX_SANS` (`{family, advances: + {regular, bold}}`, built from the table), `textWidth(text, size, face, {weight, ls})`, and + `FALLBACK_EM`. `brand.mjs` gains `brandSvgFace(render)`: null without a brand, the preset's face + with one. +- **`render-cards.mjs`:** + - `fit(text, size, maxPx, face = FIRA_SANS, run)` and `wrapPx(…, face)` are exported. An + average face runs the old code path verbatim. A measured face fits by width, and `wrapPx` + wraps by width and fits every line, so a lone overlong word is cut too. + - `svgTextIn(face)` sets each asset's family. The rail (chrome, log, tally, roster, the QR + tile), the ledger card, the scroll and the chart take `svgFaceOf(render)`. + - The scroll head fits with `{weight: "bold", ls: 0.6}`, and the ledger's scope pill is the + label's measured width + 22 (Fira: `length × 7.6 + 22`, unchanged). + - `rasterize()` hands rsvg-convert `childOpts(render, …)`, which passes the options through + unchanged without a brand. Without it a branded SVG found no Plex (it is not a system font) + and fell back to another sans, silently. +- **IBM Plex Mono Bold, vendored:** + + | | | + |---|---| + | file | `fonts/IBMPlexMono-Bold.ttf`, unmodified (Reserved Font Name "Plex") | + | source | google/fonts `ofl/ibmplexmono` at `0b58fb37` (`0b58fb370093f9a9f4ff785d94405710b79de67c`), the commit S4 took Regular from; Regular there is byte-identical to the vendored one | + | bytes | 137,784 | + | sha256 | `ac27abd6450a64dd94467580a02fe6235156d5b92f2926ebbc8e7489df64e0be` (git blob `17b406d2`, as upstream lists it) | + | licence | `OFL.txt` already holds Plex's OFL verbatim; unchanged | + + `fonts.conf`'s relative `<dir>` picks it up: under `FONTCONFIG_FILE`, `IBM Plex Mono:bold` now + resolves to it (it resolved to Regular, emboldened, before), and without it the system still + answers Noto. `resolveBrandRender` sets `fontBold` to it beside `fontRegular`, so compose-chrome's + HyperFrames band sets its bold in the brand's mono. The fonts are about 1.5 MB in all. +- **README:** the preset's "owns" list; the "Not branded yet" paragraph goes; the fonts paragraph; + "Why a table, not an average". + +**Shown.** +- **`o5-fit-proof.png`** (+ `.tsv`): every `fit()` call site, three cases each (the site's longest + real label; the longest real string anywhere, a 143-character quote; and the site's label in + capitals), fitted in both faces inside a box exactly its budget wide. Each fitted run is then rendered + **alone** through rsvg-convert and its ink measured: + - **Plex: 24 runs, 9 truncated, 0 over budget, widest 98.9 %.** + - Fira (the unchanged path): 1 over, the scroll head at 152 px in 140. +- **`o5-overrun.py`** computes every `<text>` run's extent from its face's real advances (anchor, + weight and letter-spacing aware) over the SVG sources the renderers write. It flags a run that + leaves its canvas or tally lane, or overlaps another run on its line. **0 problems** in all 27 + SVGs of each set: unbranded, branded before, branded after (1,552–1,554 runs). It catches a + synthetic collision. +- **Branded before/after** (`o5-brandshots.mjs`: the five manifests with `render.brand` and + `endCard: false` set in memory, 19 render calls). Every asset differs, as it should. Two ledger + quotes that Fira wrapped onto a stranded last word (L03 "employees", L06 "now") are one line in + Plex, and the pills hug their labels. + - Looked at: the ledger cards, the rail chrome, the log, the tally strip, the QR tile, the scroll + and the chart. No clipped glyph and no overrun. + - "IMPLIED" clears its delta triangle by 21.4 px (Fira 28), and "≈" and "→" are in Plex. + - `o5-compare.png`: a ledger row, the scroll heads and the chart's end labels, before and after. +- **The branded chapter kicker** (Pango, the `label` face in bold) now draws the real Plex Mono Bold, + where it drew an emboldened Regular before. It is the only change on that card: 276 px inside the + kicker's 189 × 24 box, and it looks the same at 24 px. + +**The byte-identity proof** (S4's harness, copied as `o5-identity.mjs` / `o5-idiff.mjs`, paths +adapted; S4's fixture copied to `o5-fixture`): +- **BEFORE** was rendered on `cb9d02b2` before any change, into `o5-before/`. It covers every card, + footer, rail, ledger, scroll and chart asset of the five manifests in both variants (70 render + calls, the resolved `render` blocks included), plus a full offline fixture build. +- **Control** (the same code twice, `o5-before` vs `o5-before2`): 334 files, 201 raw identical, 133 + identical but for ImageMagick's `tEXt date:*` chunks, 0 different. +- **AFTER, on the final code tip `216533ea`** (`o5-after-2`): **334 files, 201 raw identical, 133 + identical but for the date chunks, 0 different, none missing or extra.** The fixture's cut is the + same 3,380,693 bytes, and the mp4s are raw-identical. The same result on the pre-commit tree + (`o5-after-1`). +- The five manifests' sha256 were the same after every run (`o5-manifests-sha-before.txt`). + +| sha | what | +|---|---| +| `b9933d71` | `umtool:` vendor IBM Plex Mono Bold; `resolveBrandRender` sets `fontBold`; brand tests (the file, the value, fontconfig resolves it) | +| `2223ba07` | `umtool:` Plex Sans for the rail / ledger / scroll / chart under the preset — `svg-faces.mjs`, `face-metrics.mjs` + `fonts/gen-face-metrics.py`, `fit()`/`wrapPx()` take the face, the pill, `rasterize()` gets `childOpts`, `brandSvgFace`; `svg-faces.test.mjs` (7) and a scroll-card render test | +| `b555af1a` | `umtool:` test that the preset's rsvg-convert children get `FONTCONFIG_FILE` (an `RSVG_BIN` stub); fc-match's escaped paths unescaped | +| `216533ea` | `umtool:` README — the preset's faces, "Why a table, not an average", Plex Mono Bold; `svg-faces.mjs`'s header says what was rendered | +| _this_ | `plans:` this record | + +**Gates** (from the worktree root; logs `o5-*.log`): +- **tsc clean** before every commit: 66 s, 32 s, 38 s and 30 s. The `.mjs` files are `allowJs` + without `checkJs`, so it resolves them but does not check them. +- **`test:scripts` 185 + 1 skip** (`main` `cb9d02b2`: 175 + 1; +10 = `svg-faces.test.mjs` 7 + + `brand.test.mjs` 3). Common, editor unit and mcp not run: nothing outside umtool changed. +- **umtool build** (`pnpm --filter umtool run build`): ok, 17 s. +- **umtool e2e, full** (`SONG_DIR=~/reports/quartering-uh-song/data node scripts/worktree.mjs run -- + pnpm --filter umtool run e2e`, on `2223ba07`, no queue wait): **175 passed, 2 failed, 45 skipped, + 4.4 min**, which is release 10's count. The two failures are `mix.spec.ts:166` and `:201`, the + known order-dependent pair; the skips are the song-data capabilities this machine lacks. + **`mix.spec.ts` alone: 12 passed** (26 s), both included. The two later commits are a test, the + README and a comment, so it was not re-run. +- **Changelog:** umtool has none (`umtool/CHANGELOG.md` does not exist), so nothing was added. + +**They bite** (`o5-bite.py`: scratch copies of `report-to-video/`, the worktree untouched; `svg-faces` ++ `brand` tests, 21): + +| variant | result | +|---|---| +| the tip, unmutated (control) | 21/21 | +| `cb9d02b2` (the old code) | both files fail at import (`MONO_BOLD_FONT_FILE`, `fit` not exported) | +| `b9933d71` (bold in, measured face not yet) | both fail at import (`brandSvgFace`) | +| `fit()` counts for every face | 4 fail: Plex inside its budget, measured not counted, wrap, the scroll render | +| the scroll head fitted without its face | 1 fails: the scroll render (`THE QUARTERING · MED…` in Plex) | +| `wrapPx()` counts for every face | 1 fails: wrap | +| `rasterize()` without `childOpts` | 1 fails: the rsvg env test | +| `fontBold` not set | 1 fails: the preset's render | +| `IBMPlexMono-Bold.ttf` removed | 2 fail: the vendored faces, `fonts.conf` resolution | + +**Found and left** +- **Fira's unbranded path can overrun its own columns** given long enough text (the scroll head + above; uppercased text at every site). None of the real manifests' labels comes near a budget, + and the byte-identity rule forbids changing it. If it ever matters, the fix is a Fira table like + Plex's, and that has to be pinned to one Fira build. +- **The scroll's first column head** is fitted to the column pitch (140 px) and ignores the `DATE` + head to its left. "THE QUARTERING" clears `DATE` by 8.4 px of advance in Plex (23 in Fira), and a + head near 140 px would reach it in either face. Not hit by any manifest. +- The 1.3 em fallback for unmapped characters is a bound, not a measurement: nothing rendered an + emoji or CJK label through a fallback font. +- **`plans/STATE.md`** ("New lows", l. 154–155) lists both of this slice's items as open. **S4's + record** in `brand-and-themes.md` (l. 1451–1453, 1541) states them as they were. Neither file is + this slice's to edit, so they are left for the integration pass. + ## Rollout Nothing is rolled out tonight. The morning runbook lists what is owed: the :3001 editor restart, diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -821,10 +821,11 @@ What the preset **owns**, whatever the manifest says: - `render.palette` — the slate of the parent mark lit in Signal (`#151b20` / `#e7edf1` / `#8496a2` / `#5fa8a0`, amber `#e3b15c`); -- the faces — Archivo at wdth 118 for display, IBM Plex Sans for body, IBM Plex - Mono for the header and meta lines; `render.fontRegular` becomes the vendored - Plex Mono. (`fontBold` is left alone: only compose-chrome's HyperFrames band - reads it.) +- the faces — Archivo at wdth 118 for display, IBM Plex Sans for body (the + rail, `ledger`, `scroll` and `chart` SVG text included), IBM Plex Mono for the + header and meta lines; `render.fontRegular` becomes the vendored Plex Mono + Regular and `render.fontBold` the vendored Plex Mono Bold (compose-chrome's + HyperFrames band sets its bold in it). What it **adds**: @@ -836,10 +837,10 @@ What it **adds**: | thumbnail | 1280 × 720: a still from a cached clip window (or `thumbnail.still`), the headline in Archivo 800, the mark top-left, the duration-badge corner clear. Made after a full build when `thumbnail` is set, or alone with `--thumbnail` | `out/<slug>.thumbnail.png` | The other card styles (`chapter`, `bullets`, `sources`, `timeline`) keep their -layouts and take the preset's faces and palette. **Not branded yet:** the rail, -`ledger`, `scroll` and `chart` still set their SVG text in Fira Sans — their -truncation (`fit()`) is measured against Fira's average advance, and moving the -face without re-measuring would overrun columns. +layouts and take the preset's faces and palette. So do the rail, `ledger`, +`scroll` and `chart`: their SVG text is set in IBM Plex Sans, and rsvg-convert +finds it through the same `FONTCONFIG_FILE`, measured by its own advances +(below, "Why a table, not an average"). **The drawings are not drawn here.** The mark and the lockup (its letters as outlines, so no font is needed to draw it) are `common/lib/brandMedia.ts`'s, @@ -848,9 +849,9 @@ exported by `common/bin/brand-media.ts --video-kit` into A common test fails when that file is stale; re-run the CLI, never edit it. **Fonts are vendored, not installed.** `fonts/` holds Archivo[wdth,wght], IBM Plex -Sans[wdth,wght] and IBM Plex Mono Regular — unmodified from google/fonts +Sans[wdth,wght] and IBM Plex Mono Regular and Bold — unmodified from google/fonts (`ofl/archivo` at `95f4904f`, `ofl/ibmplexsans` / `ofl/ibmplexmono` at -`0b58fb37`), about 1.3 MB — with `OFL.txt` (Plex carries a Reserved Font Name, so +`0b58fb37`), about 1.5 MB — with `OFL.txt` (Plex carries a Reserved Font Name, so it may not be shipped modified, subset included) and a `fonts.conf`. The preset sets `FONTCONFIG_FILE` to that conf on **its own** `magick` and `rsvg-convert` children only, so Pango sees the variable faces (`Archivo @wght=720,wdth=118`); @@ -869,6 +870,41 @@ Two measurements the code depends on: of the same code never produce the same PNG bytes. A byte-identity check has to drop those three chunks and compare the rest — IHDR and IDAT included. +### Why a table, not an average + +The rail, `ledger`, `scroll` and `chart` assets are **fitted, not flowed**: a +label that would overrun its column is cut short with an ellipsis by `fit()` +(and a ledger quote wrapped by `wrapPx()`) before it is drawn, because an +overflowing row runs under the value column beside it rather than wrapping. So +a face carries the metric it is fitted by (`svg-faces.mjs`), and swapping the +face without it would overrun. + +- **Fira Sans** (no brand) keeps its average: 0.50 em a character, a character + count, exactly as it always cut. Measured over the strings `fit()` actually + truncates in the real manifests, Fira averages 0.457 em (p95 of a string's own + average 0.492), so 0.50 is the hair of margin it was chosen with. It cannot + move — an unbranded render is byte-identical by rule — and it is the system's + font, so a table of its advances could not be pinned anyway. +- **IBM Plex Sans** (the preset) is measured per character: + `face-metrics.mjs` holds the vendored variable font's advance for all 891 + characters it maps, at the two weights these assets draw (400 and 700, wdth + 100). `fit()` keeps the longest prefix whose width — ellipsis, weight and + letter-spacing included — is inside the budget; `wrapPx()` wraps by width and + fits every line; the ledger's scope pill hugs its label. + +An average cannot promise a column. Measured the same way, Plex Sans averages +0.456 em regular (Fira's own number) but 0.479 bold and 0.59–0.61 in capitals; +and pushing the manifests' longest real text through each call site, even +Fira's 0.50 lets the scroll's bold, letter-spaced column head overrun (152 px +rendered in 140). The table does not: over the same runs, every Plex one +renders inside its budget (widest 98.9 %). Kerning is ignored: the plain sum of +advances came out at or above the rendered width of every one of those runs, so +it is the conservative side. A character the face does not map (emoji, CJK, +drawn by fontconfig's fallback) counts 1.3 em. + +`face-metrics.mjs` is generated: `python3 fonts/gen-face-metrics.py` (fontTools) +rewrites it from the vendored font, and a test fails when it is stale. + ## Things that cost time to find out **yt-dlp picks VP9 at `height<=720`, and that is a trap.** `--download-sections` diff --git a/umtool/report-to-video/brand.mjs b/umtool/report-to-video/brand.mjs @@ -8,9 +8,11 @@ // things and adds four: // // OWNS the palette (render.palette) and the faces: Archivo at wdth 118 for -// display, IBM Plex Sans for body, IBM Plex Mono for the header and -// meta lines. A manifest that opts in and also sets `palette` or -// `fontRegular` gets the preset's -- the brand is the point. +// display, IBM Plex Sans for body -- the rail, ledger, scroll and +// chart SVG text included, measured by its own advances +// (svg-faces.mjs) -- and IBM Plex Mono for the header and meta lines. +// A manifest that opts in and also sets `palette`, `fontRegular` or +// `fontBold` gets the preset's -- the brand is the point. // ADDS the title card's lockup and found line, the mark leading the clip // header, an end card, and a thumbnail step. // @@ -38,11 +40,14 @@ import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); import { BRAND_CHOICES, BRAND_IDS } from "./brand-ids.mjs"; +import { IBM_PLEX_SANS } from "./svg-faces.mjs"; export { BRAND_CHOICES, BRAND_IDS }; export const FONTS_DIR = path.join(HERE, "fonts"); export const FONTCONFIG_FILE = path.join(FONTS_DIR, "fonts.conf"); export const MONO_FONT_FILE = path.join(FONTS_DIR, "IBMPlexMono-Regular.ttf"); +/** Plex Mono's static Bold: `render.fontBold`, which compose-chrome's HyperFrames band sets its bold in. */ +export const MONO_BOLD_FONT_FILE = path.join(FONTS_DIR, "IBMPlexMono-Bold.ttf"); /** The end card's default length: YouTube's end screen runs in the last 5–20 s. */ export const END_CARD_DEFAULT_SECONDS = 20; @@ -60,6 +65,12 @@ export const FACES = { }, }; +// The face the rail, `ledger`, `scroll` and `chart` SVG text is set in, with +// the metric fit() measures it by (svg-faces.mjs). The preset's body face. +const SVG_FACES = { + "archilyzer-media": IBM_PLEX_SANS, +}; + const kits = new Map(); /** brands/<id>.json: the palette, mark and lockup common/lib/brandMedia.ts draws. */ @@ -102,6 +113,8 @@ export function resolveBrandRender(render) { palette: { ...kit.palette }, // drawtext's face: the header and the image header read `fontRegular`. fontRegular: MONO_FONT_FILE, + // compose-chrome's HyperFrames band: its bold weight, beside that regular. + fontBold: MONO_BOLD_FONT_FILE, endCard: endCardConfig(render.endCard), }; } @@ -154,6 +167,15 @@ export function brandFaces(render) { } /** + * The SVG assets' face (rail, `ledger`, `scroll`, `chart`), or null for a + * manifest that does not opt in -- render-cards.mjs then sets them in Fira + * Sans, measured by its 0.50 em average, exactly as it always did. + */ +export function brandSvgFace(render) { + return render?.brand ? SVG_FACES[render.brand] : null; +} + +/** * Options for a `magick` / `rsvg-convert` child: `opts` itself when there is no * brand (the unbranded call is the call it always was), else the same with * FONTCONFIG_FILE pointed at the vendored faces. diff --git a/umtool/report-to-video/brand.test.mjs b/umtool/report-to-video/brand.test.mjs @@ -9,18 +9,24 @@ import assert from "node:assert/strict"; import test from "node:test"; import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { BRAND_IDS, END_CARD_DEFAULT_SECONDS, END_CARD_DEFAULT_URL, END_CARD_ID, FACES, FONTCONFIG_FILE, - FONTS_DIR, MONO_FONT_FILE, brandFaces, brandHeaderGeometry, brandKit, brandManifest, childOpts, - endCardConfig, resolveBrandRender, + FONTS_DIR, MONO_BOLD_FONT_FILE, MONO_FONT_FILE, brandFaces, brandHeaderGeometry, brandKit, brandManifest, + brandSvgFace, childOpts, endCardConfig, resolveBrandRender, } from "./brand.mjs"; import { headerFilters, selectVariant } from "./build-video.mjs"; -import { renderCard } from "./render-cards.mjs"; +import { renderCard, renderScrollCard } from "./render-cards.mjs"; import { markPng, renderThumbnail, THUMB } from "./brand-cards.mjs"; +import { IBM_PLEX_SANS } from "./svg-faces.mjs"; + +const mkdirP = (d) => mkdir(d, { recursive: true }); +const HERE = path.dirname(fileURLToPath(import.meta.url)); const PLAIN_RENDER = { width: 1920, height: 1080, fps: 30, headerHeight: 56, @@ -36,6 +42,7 @@ test("no brand: every hook hands back the very object it was given", () => { const m = manifest(PLAIN_RENDER); assert.equal(brandManifest(m), m); assert.equal(brandFaces(PLAIN_RENDER), null); + assert.equal(brandSvgFace(PLAIN_RENDER), null); const opts = { maxBuffer: 1 }; assert.equal(childOpts(PLAIN_RENDER, opts), opts); assert.equal(childOpts(PLAIN_RENDER, undefined), undefined); @@ -66,6 +73,14 @@ test("the preset owns palette and face, and resolves the end card", () => { bg: "#151b20", fg: "#e7edf1", muted: "#8496a2", accent: "#5fa8a0", amber: "#e3b15c", dim: "#586977", }); assert.equal(r.fontRegular, MONO_FONT_FILE); + // The HyperFrames band's bold: the vendored Plex Mono Bold, whatever the manifest said. + assert.equal(r.fontBold, MONO_BOLD_FONT_FILE); + assert.equal( + resolveBrandRender({ ...PLAIN_RENDER, fontBold: "/usr/share/fonts/TTF/FiraSans-Bold.ttf", brand: "archilyzer-media" }).fontBold, + MONO_BOLD_FONT_FILE, + ); + // The rail / ledger / scroll / chart text: Plex Sans, measured per character. + assert.equal(brandSvgFace(r), IBM_PLEX_SANS); assert.deepEqual(r.endCard, { seconds: END_CARD_DEFAULT_SECONDS, url: END_CARD_DEFAULT_URL }); // Everything else is still the manifest's. assert.equal(r.headerHeight, 56); @@ -129,9 +144,13 @@ test("branded header: mono face, no tick, the channel drawn again in the foregro }); test("the vendored faces: every file the preset names is there, under the OFL", () => { - for (const f of ["Archivo[wdth,wght].ttf", "IBMPlexSans[wdth,wght].ttf", "IBMPlexMono-Regular.ttf", "OFL.txt", "fonts.conf"]) { + for (const f of [ + "Archivo[wdth,wght].ttf", "IBMPlexSans[wdth,wght].ttf", "IBMPlexMono-Regular.ttf", "IBMPlexMono-Bold.ttf", + "OFL.txt", "fonts.conf", + ]) { assert.ok(existsSync(path.join(FONTS_DIR, f)), f); } + assert.ok(existsSync(MONO_BOLD_FONT_FILE)); assert.equal(FONTCONFIG_FILE, path.join(FONTS_DIR, "fonts.conf")); const ofl = readFileSync(path.join(FONTS_DIR, "OFL.txt"), "utf8"); assert.match(ofl, /The Archivo Project Authors/); @@ -150,6 +169,21 @@ test("the kit: the lockup and the mark are outlines, not text", () => { assert.ok(kit.lockup.blockTop > 0 && kit.lockup.blockHeight > 0); }); +// fontconfig, pointed at fonts.conf, finds the vendored faces -- the bold mono +// included -- and without it the system's fonts are what it has. +const hasFcMatch = spawnSync("fc-match", ["--version"], { stdio: "ignore" }).status === 0; + +test("fonts.conf: the preset's children resolve the vendored faces, bold mono included", { skip: !hasFcMatch && "fc-match not available" }, () => { + // fc-match escapes its separators in a path (`\,`), so they are unescaped. + const match = (pattern) => + execFileSync("fc-match", [pattern, "file"], { encoding: "utf8", env: { ...process.env, FONTCONFIG_FILE } }) + .trim() + .replace(/\\(.)/g, "$1"); + assert.equal(match("IBM Plex Mono:bold"), `:file=${MONO_BOLD_FONT_FILE}`); + assert.equal(match("IBM Plex Mono"), `:file=${MONO_FONT_FILE}`); + assert.equal(match("IBM Plex Sans:bold"), `:file=${path.join(FONTS_DIR, "IBMPlexSans[wdth,wght].ttf")}`); +}); + // Rendering needs ImageMagick with Pango and rsvg-convert; a machine without // them skips rather than fails. const hasTools = @@ -193,3 +227,66 @@ test("rendered: title and end cards at the frame size, the thumbnail at 1280 x 7 rmSync(dir, { recursive: true, force: true }); } }); + +test("rendered: the scroll's SVG text takes the preset's face and its fit; unbranded stays Fira", { skip: !hasTools && "magick / rsvg-convert not available" }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "rtv-brand-svg-")); + try { + const rail = { + width: 500, + tracks: [{ key: "q", label: "The Quartering · media", shortLabel: "THE QUARTERING · MEDIA", color: "#2bb38a" }], + }; + const ledger = [{ id: "a", date: "2023-06-30", scope: "q", value: 6, display: "6", label: "six employees now", entryId: "c01" }]; + const card = { type: "scroll", id: "z01", hideRail: true }; + const svgOf = async (render, sub) => { + await mkdirP(path.join(dir, sub, "cards")); + const { path: png, width } = await renderScrollCard(card, render, ledger, path.join(dir, sub)); + assert.equal(width, 1920); + assert.ok(existsSync(png)); + return readFileSync(path.join(dir, sub, "cards", "z01.svg"), "utf8"); + }; + const plain = await svgOf({ ...PLAIN_RENDER, rail }, "plain"); + assert.ok(plain.includes('font-family="Fira Sans"')); + assert.ok(!plain.includes("IBM Plex")); + // Fira's count keeps 21 characters of the head. + assert.ok(plain.includes(">THE QUARTERING · MED…<")); + const branded = await svgOf(resolveBrandRender({ ...PLAIN_RENDER, rail, brand: "archilyzer-media" }), "brand"); + assert.ok(branded.includes('font-family="IBM Plex Sans"')); + assert.ok(!branded.includes("Fira Sans")); + // Plex Sans Bold, letter-spaced, measured into the 140 px head. + assert.ok(branded.includes(">THE QUARTERING…<")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("rendered: the preset's rsvg-convert children get the vendored fonts; unbranded ones the system's", { skip: !hasTools && "magick / rsvg-convert not available" }, () => { + // IBM Plex Sans is not a system font, so a branded SVG rasterized without + // FONTCONFIG_FILE would fall back to some other sans -- silently. A stub + // RSVG_BIN records the variable each child is given, then runs the real one. + const dir = mkdtempSync(path.join(tmpdir(), "rtv-brand-env-")); + try { + const log = path.join(dir, "env.log"); + const stub = path.join(dir, "rsvg-stub.sh"); + const real = process.env.RSVG_BIN ?? "rsvg-convert"; + writeFileSync(stub, `#!/bin/sh\nprintf '%s\\n' "\${FONTCONFIG_FILE:-system}" >> '${log}'\nexec ${real} "$@"\n`, { mode: 0o755 }); + const url = (f) => JSON.stringify(pathToFileURL(path.join(HERE, f)).href); + const script = ` + import { mkdirSync } from "node:fs"; + import { renderScrollCard } from ${url("render-cards.mjs")}; + import { resolveBrandRender } from ${url("brand.mjs")}; + const render = ${JSON.stringify({ ...PLAIN_RENDER, rail: { tracks: [{ key: "q", label: "Q", color: "#2bb38a" }] } })}; + const ledger = [{ id: "a", date: "2023-06-30", scope: "q", value: 6, display: "6", label: "six", entryId: "c" }]; + for (const [sub, r] of [["plain", render], ["brand", resolveBrandRender({ ...render, brand: "archilyzer-media" })]]) { + mkdirSync(${JSON.stringify(dir)} + "/" + sub + "/cards", { recursive: true }); + await renderScrollCard({ type: "scroll", id: "z", hideRail: true }, r, ledger, ${JSON.stringify(dir)} + "/" + sub); + } + `; + const env = { ...process.env, RSVG_BIN: stub }; + delete env.FONTCONFIG_FILE; + const r = spawnSync(process.execPath, ["--input-type=module", "-e", script], { env, encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(readFileSync(log, "utf8").trim().split("\n"), ["system", FONTCONFIG_FILE]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/face-metrics.mjs b/umtool/report-to-video/face-metrics.mjs @@ -0,0 +1,131 @@ +// GENERATED by umtool/report-to-video/fonts/gen-face-metrics.py -- do not edit; re-run it. +// +// The advance, in font units, of every character IBMPlexSans[wdth,wght].ttf maps, at the +// weights the rail / ledger / scroll / chart SVG text draws (wght 400 and 700, +// wdth 100): what fit() and wrapPx() measure a branded run by. svg-faces.mjs reads it. +export const IBM_PLEX_SANS_METRICS = { + font: "IBMPlexSans[wdth,wght].ttf", + sha256: "3b031aa4216174205bd8471f88a49b91f093169e9e87bd5262242bc5967fe2e3", + fontTools: "4.65.0", + unitsPerEm: 1000, + wdth: 100, + // [first, last] codepoint ranges; the advances below follow them in order. + ranges: [ + [32, 126], [160, 383], [399, 399], [402, 402], [416, 417], [431, 432], [461, 476], [506, 511], + [536, 539], [567, 567], [601, 601], [699, 700], [710, 711], [728, 733], [768, 772], [774, 780], + [786, 786], [789, 789], [795, 795], [803, 803], [806, 808], [894, 894], [900, 906], [908, 908], + [910, 929], [931, 974], [1024, 1119], [1122, 1123], [1130, 1131], [1138, 1141], [1168, 1218], + [1231, 1241], [1244, 1257], [1262, 1273], [1316, 1317], [3647, 3647], [7808, 7813], + [7838, 7838], [7840, 7929], [8192, 8205], [8208, 8213], [8216, 8218], [8220, 8222], + [8224, 8226], [8230, 8230], [8232, 8233], [8239, 8240], [8242, 8243], [8249, 8250], + [8260, 8260], [8304, 8304], [8308, 8313], [8320, 8329], [8353, 8353], [8356, 8356], + [8358, 8358], [8360, 8366], [8369, 8370], [8372, 8373], [8376, 8378], [8381, 8381], + [8383, 8383], [8467, 8467], [8470, 8470], [8482, 8482], [8486, 8486], [8494, 8494], + [8528, 8529], [8531, 8542], [8592, 8601], [8617, 8618], [8624, 8627], [8630, 8631], + [8634, 8635], [8644, 8644], [8646, 8646], [8706, 8706], [8710, 8710], [8719, 8719], + [8721, 8722], [8725, 8725], [8729, 8730], [8734, 8734], [8747, 8747], [8758, 8758], + [8776, 8776], [8800, 8800], [8804, 8805], [9674, 9674], [10003, 10003], [10060, 10060], + [11022, 11025], [60640, 60640], [61388, 61388], [64257, 64258], [65279, 65279], [65533, 65533], + ], + advances: { + 400: [ + 236, 284, 419, 713, 598, 927, 694, 242, 335, 335, 450, 600, 272, 399, 272, 383, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 292, 292, 600, 600, 600, 477, 891, 641, 653, 621, 671, 583, + 559, 695, 707, 400, 510, 634, 501, 812, 707, 708, 606, 708, 640, 581, 572, 678, 609, 891, 613, + 593, 580, 317, 383, 317, 600, 565, 600, 534, 580, 503, 580, 549, 324, 528, 568, 250, 250, 527, + 272, 873, 568, 560, 580, 580, 367, 487, 351, 568, 492, 768, 507, 499, 464, 343, 314, 343, 600, + 236, 284, 514, 595, 612, 605, 314, 586, 600, 776, 399, 513, 600, 0, 476, 600, 468, 600, 346, + 346, 600, 573, 652, 326, 600, 359, 398, 513, 847, 872, 824, 467, 641, 641, 641, 641, 641, 641, + 907, 621, 583, 583, 583, 583, 400, 400, 400, 400, 674, 707, 708, 708, 708, 708, 708, 600, 708, + 678, 678, 678, 678, 593, 606, 640, 534, 534, 534, 534, 534, 534, 864, 503, 549, 549, 549, 549, + 250, 250, 250, 250, 555, 568, 560, 560, 560, 560, 560, 600, 568, 568, 568, 568, 568, 499, 580, + 499, 641, 534, 641, 534, 641, 534, 621, 503, 621, 503, 621, 503, 621, 503, 671, 603, 674, 583, + 583, 549, 583, 549, 583, 549, 583, 549, 583, 549, 695, 531, 695, 531, 695, 531, 695, 531, 707, + 568, 713, 568, 400, 250, 400, 250, 400, 250, 400, 250, 400, 250, 645, 496, 510, 250, 634, 527, + 527, 501, 272, 501, 272, 501, 274, 501, 358, 508, 278, 707, 568, 707, 568, 707, 568, 811, 707, + 568, 708, 560, 708, 560, 708, 560, 985, 931, 640, 367, 640, 367, 640, 367, 581, 487, 581, 487, + 581, 487, 581, 487, 572, 351, 572, 351, 582, 357, 678, 568, 678, 568, 678, 568, 678, 568, 678, + 568, 678, 568, 891, 768, 593, 499, 593, 580, 464, 580, 464, 580, 464, 278, 708, 485, 708, 560, + 678, 568, 641, 534, 400, 250, 708, 560, 678, 568, 678, 568, 678, 568, 678, 568, 678, 568, 641, + 534, 907, 864, 708, 568, 581, 487, 572, 351, 250, 549, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 292, 600, 600, 641, 292, + 609, 733, 459, 712, 677, 716, 295, 641, 653, 499, 642, 583, 580, 707, 708, 400, 634, 629, 812, + 707, 584, 708, 693, 606, 569, 572, 593, 852, 613, 790, 716, 400, 593, 644, 518, 573, 295, 568, + 644, 584, 502, 560, 518, 428, 573, 562, 295, 527, 509, 578, 511, 456, 560, 622, 552, 483, 583, + 477, 568, 715, 496, 726, 736, 295, 568, 560, 568, 736, 583, 583, 786, 499, 631, 581, 400, 400, + 510, 1014, 1037, 796, 662, 707, 595, 693, 641, 636, 653, 499, 726, 583, 978, 593, 707, 707, + 662, 684, 812, 707, 708, 693, 606, 621, 572, 595, 844, 613, 730, 645, 956, 993, 741, 875, 630, + 631, 960, 640, 534, 564, 558, 435, 597, 549, 770, 501, 588, 588, 550, 565, 696, 588, 560, 573, + 580, 503, 472, 499, 750, 507, 600, 538, 798, 825, 590, 724, 520, 511, 781, 535, 549, 549, 568, + 435, 511, 487, 250, 250, 250, 805, 832, 568, 550, 588, 499, 573, 673, 549, 920, 731, 708, 560, + 685, 537, 499, 435, 519, 453, 635, 538, 986, 775, 593, 501, 670, 556, 692, 580, 705, 550, 773, + 620, 744, 615, 916, 757, 1008, 836, 876, 670, 621, 503, 572, 472, 593, 492, 593, 492, 644, + 531, 871, 711, 682, 565, 645, 538, 645, 568, 798, 645, 798, 645, 400, 978, 770, 250, 641, 534, + 641, 534, 907, 864, 583, 549, 708, 549, 978, 770, 593, 501, 593, 501, 707, 588, 707, 588, 708, + 560, 708, 560, 595, 499, 595, 499, 595, 499, 645, 538, 499, 435, 875, 724, 730, 600, 644, 891, + 768, 891, 768, 891, 768, 700, 641, 534, 641, 534, 641, 534, 641, 534, 641, 534, 641, 534, 641, + 534, 641, 534, 641, 534, 641, 534, 641, 534, 641, 534, 583, 549, 583, 549, 583, 549, 583, 549, + 583, 549, 583, 549, 583, 549, 583, 549, 400, 250, 400, 250, 708, 560, 708, 560, 708, 560, 708, + 560, 708, 560, 708, 560, 708, 560, 708, 560, 708, 560, 708, 560, 708, 560, 708, 560, 678, 568, + 678, 568, 678, 568, 678, 568, 678, 568, 678, 568, 678, 568, 593, 499, 593, 499, 593, 499, 593, + 499, 500, 1000, 500, 1000, 333, 250, 167, 600, 272, 118, 59, 0, 0, 0, 399, 399, 588, 588, 780, + 1000, 273, 273, 274, 475, 474, 475, 552, 552, 396, 803, 0, 0, 118, 1306, 242, 419, 304, 304, + 147, 378, 346, 348, 359, 335, 376, 359, 378, 359, 346, 346, 346, 348, 359, 335, 376, 359, 628, + 595, 696, 713, 952, 770, 580, 625, 617, 608, 628, 676, 628, 628, 608, 605, 606, 623, 643, 488, + 1027, 658, 716, 726, 861, 880, 872, 859, 869, 856, 846, 851, 860, 839, 897, 874, 876, 843, + 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, 820, + 820, 820, 820, 591, 643, 706, 504, 600, 147, 326, 600, 740, 426, 292, 600, 600, 600, 600, 600, + 912, 912, 820, 820, 820, 820, 1177, 1046, 567, 596, 0, 1000, + ], + 700: [ + 236, 320, 493, 632, 601, 974, 721, 268, 338, 338, 601, 600, 310, 403, 310, 460, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 330, 330, 600, 600, 600, 500, 903, 685, 667, 651, 697, 607, + 585, 719, 724, 432, 559, 696, 530, 819, 724, 714, 656, 714, 674, 624, 584, 694, 650, 973, 673, + 649, 607, 334, 460, 334, 600, 556, 600, 569, 608, 517, 608, 562, 361, 552, 596, 286, 286, 577, + 303, 894, 596, 564, 608, 608, 404, 504, 383, 596, 538, 841, 560, 534, 518, 372, 402, 372, 600, + 236, 320, 552, 582, 628, 650, 402, 569, 600, 792, 449, 558, 600, 0, 464, 600, 470, 600, 347, + 345, 600, 602, 677, 358, 600, 344, 433, 558, 837, 859, 828, 494, 685, 685, 685, 685, 685, 685, + 968, 651, 607, 607, 607, 607, 432, 432, 432, 432, 702, 724, 714, 714, 714, 714, 714, 600, 714, + 694, 694, 694, 694, 649, 656, 692, 569, 569, 569, 569, 569, 569, 864, 517, 562, 562, 562, 562, + 286, 286, 286, 286, 603, 596, 564, 564, 564, 564, 564, 600, 572, 596, 596, 596, 596, 534, 608, + 534, 685, 569, 685, 569, 685, 569, 651, 517, 651, 517, 651, 517, 651, 517, 697, 682, 702, 611, + 607, 562, 607, 562, 607, 562, 607, 562, 607, 562, 719, 562, 719, 562, 719, 562, 719, 562, 724, + 596, 734, 596, 432, 286, 432, 286, 432, 286, 432, 286, 432, 286, 656, 566, 559, 286, 696, 577, + 577, 530, 303, 530, 303, 530, 360, 530, 448, 540, 314, 724, 596, 724, 596, 724, 596, 886, 724, + 596, 714, 564, 714, 564, 714, 564, 998, 901, 674, 404, 674, 404, 674, 404, 624, 504, 624, 504, + 624, 504, 624, 504, 584, 383, 584, 388, 594, 391, 694, 596, 694, 596, 694, 596, 694, 596, 694, + 596, 694, 596, 973, 841, 649, 534, 649, 607, 518, 607, 518, 607, 518, 312, 714, 484, 714, 564, + 694, 596, 685, 569, 432, 286, 714, 564, 694, 596, 694, 596, 694, 596, 694, 596, 694, 596, 685, + 569, 968, 864, 714, 572, 624, 504, 584, 383, 286, 562, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 330, 600, 600, 685, 330, + 703, 820, 555, 756, 793, 757, 329, 685, 667, 514, 682, 607, 607, 724, 724, 432, 696, 671, 819, + 724, 605, 714, 709, 656, 611, 584, 649, 848, 673, 840, 714, 432, 649, 667, 528, 610, 329, 582, + 667, 609, 540, 564, 528, 438, 610, 576, 329, 580, 538, 616, 540, 441, 564, 655, 577, 507, 586, + 500, 582, 773, 545, 759, 770, 329, 582, 564, 582, 770, 607, 607, 807, 514, 671, 624, 432, 432, + 559, 1047, 1040, 817, 675, 724, 631, 709, 685, 657, 667, 514, 815, 607, 1006, 608, 724, 724, + 675, 739, 819, 724, 714, 709, 656, 651, 584, 631, 904, 673, 772, 678, 1030, 1093, 758, 933, + 662, 671, 1016, 678, 569, 567, 576, 444, 658, 562, 866, 531, 612, 612, 591, 611, 726, 612, + 564, 597, 608, 517, 492, 534, 804, 560, 633, 578, 844, 880, 639, 809, 561, 535, 822, 585, 562, + 562, 602, 444, 535, 504, 286, 286, 286, 863, 869, 596, 591, 612, 534, 598, 695, 561, 1016, + 766, 714, 564, 665, 548, 514, 444, 545, 461, 668, 574, 1029, 874, 608, 531, 698, 601, 755, + 671, 708, 591, 771, 669, 787, 648, 912, 763, 1035, 859, 909, 733, 651, 517, 584, 492, 649, + 538, 649, 538, 695, 573, 901, 731, 741, 614, 698, 598, 678, 596, 878, 707, 878, 707, 432, + 1006, 866, 286, 685, 569, 685, 569, 968, 864, 607, 562, 714, 562, 1006, 866, 608, 531, 593, + 501, 724, 612, 724, 612, 714, 564, 714, 564, 631, 534, 631, 534, 631, 534, 678, 578, 514, 444, + 933, 809, 772, 633, 665, 973, 841, 973, 841, 973, 841, 731, 685, 569, 685, 569, 685, 569, 685, + 569, 685, 569, 685, 569, 685, 569, 685, 569, 685, 569, 685, 569, 685, 569, 685, 569, 607, 562, + 607, 562, 607, 562, 607, 562, 607, 562, 607, 562, 607, 562, 607, 562, 432, 286, 432, 286, 714, + 564, 714, 564, 714, 564, 714, 564, 714, 564, 714, 564, 714, 564, 714, 564, 714, 564, 714, 564, + 714, 564, 714, 564, 694, 596, 694, 596, 694, 596, 694, 596, 694, 596, 694, 596, 694, 596, 649, + 534, 649, 534, 649, 534, 649, 534, 500, 1000, 500, 1000, 333, 250, 167, 600, 310, 118, 59, 0, + 0, 0, 403, 403, 588, 588, 780, 1000, 300, 300, 304, 535, 535, 539, 522, 542, 430, 897, 0, 0, + 118, 1382, 268, 493, 317, 317, 147, 370, 346, 357, 356, 325, 354, 356, 370, 344, 347, 345, + 346, 357, 356, 325, 354, 356, 617, 582, 776, 893, 1023, 916, 576, 627, 684, 604, 698, 667, + 644, 607, 604, 629, 611, 661, 665, 553, 1060, 614, 724, 714, 851, 867, 861, 859, 868, 866, + 859, 865, 827, 830, 860, 851, 863, 816, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, + 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 900, 587, 684, 726, 558, 600, 147, 358, 600, + 792, 448, 330, 600, 600, 600, 600, 600, 912, 912, 900, 900, 900, 900, 1177, 1046, 647, 664, 0, + 1000, + ], + }, +}; diff --git a/umtool/report-to-video/fonts/IBMPlexMono-Bold.ttf b/umtool/report-to-video/fonts/IBMPlexMono-Bold.ttf Binary files differ. diff --git a/umtool/report-to-video/fonts/gen-face-metrics.py b/umtool/report-to-video/fonts/gen-face-metrics.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Write umtool/report-to-video/face-metrics.mjs: the advance of every character +IBM Plex Sans maps, at the weights the rail / ledger / scroll / chart SVG text +draws (400 and 700, wdth 100), measured from the VENDORED variable font. + +fit() and wrapPx() in render-cards.mjs measure a branded run with this table, so +a column set in Plex Sans is truncated by what the run is, not by an average +(see "Why a table, not an average" in the README). The font itself is read and +never written: Plex carries a Reserved Font Name, so it ships unmodified. + + python3 umtool/report-to-video/fonts/gen-face-metrics.py # rewrite the module + python3 umtool/report-to-video/fonts/gen-face-metrics.py --stdout # print it (the parity test) + +Needs fontTools (pip install fonttools). The module records the fontTools +version it was generated with; the parity test skips under another one, because +the instancer's rounding of variation deltas is fontTools's, not the font's. +""" +import hashlib +import json +import os +import sys + +import fontTools +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + +HERE = os.path.dirname(os.path.abspath(__file__)) +FONT = "IBMPlexSans[wdth,wght].ttf" +OUT = os.path.join(os.path.dirname(HERE), "face-metrics.mjs") +WEIGHTS = (400, 700) +WDTH = 100 + + +def runs(cps): + """Contiguous [first, last] codepoint ranges, in order.""" + out = [] + for cp in cps: + if out and cp == out[-1][1] + 1: + out[-1][1] = cp + else: + out.append([cp, cp]) + return out + + +def wrap(values, indent=" ", width=100): + lines, line = [], "" + for v in values: + s = f"{v}," + if line and len(indent) + len(line) + 1 + len(s) > width: + lines.append(indent + line) + line = s + else: + line = f"{line} {s}" if line else s + if line: + lines.append(indent + line) + return "\n".join(lines) + + +def build(): + path = os.path.join(HERE, FONT) + raw = open(path, "rb").read() + sha = hashlib.sha256(raw).hexdigest() + base = TTFont(path) + upm = base["head"].unitsPerEm + cps = sorted(base.getBestCmap()) + adv = {} + for w in WEIGHTS: + inst = instancer.instantiateVariableFont(TTFont(path), {"wght": w, "wdth": WDTH}) + cmap = inst.getBestCmap() + hmtx = inst["hmtx"].metrics + adv[w] = [hmtx[cmap[cp]][0] for cp in cps] + + parts = [ + "// GENERATED by umtool/report-to-video/fonts/gen-face-metrics.py -- do not edit; re-run it.", + "//", + f"// The advance, in font units, of every character {FONT} maps, at the", + f"// weights the rail / ledger / scroll / chart SVG text draws (wght {' and '.join(map(str, WEIGHTS))},", + f"// wdth {WDTH}): what fit() and wrapPx() measure a branded run by. svg-faces.mjs reads it.", + "export const IBM_PLEX_SANS_METRICS = {", + f" font: {json.dumps(FONT)},", + f" sha256: {json.dumps(sha)},", + f" fontTools: {json.dumps(fontTools.version)},", + f" unitsPerEm: {upm},", + f" wdth: {WDTH},", + " // [first, last] codepoint ranges; the advances below follow them in order.", + " ranges: [", + wrap([f"[{a}, {b}]" for a, b in runs(cps)], indent=" "), + " ],", + " advances: {", + ] + for w in WEIGHTS: + parts.append(f" {w}: [") + parts.append(wrap(adv[w])) + parts.append(" ],") + parts += [" },", "};", ""] + return "\n".join(parts) + + +if __name__ == "__main__": + text = build() + if "--stdout" in sys.argv[1:]: + sys.stdout.write(text) + else: + with open(OUT, "w") as f: + f.write(text) + print(f"wrote {os.path.relpath(OUT)}") diff --git a/umtool/report-to-video/render-cards.mjs b/umtool/report-to-video/render-cards.mjs @@ -34,8 +34,9 @@ import { dateKey, ledgerTotals, rosterLine } from "./ledger-totals.mjs"; // A manifest with `render.brand` draws its title and end cards through the // preset and sets the other card styles in the preset's faces. Without one, // neither module changes a byte of what this file draws -- see brand.mjs. -import { brandFaces, brandManifest, childOpts } from "./brand.mjs"; +import { brandFaces, brandManifest, brandSvgFace, childOpts } from "./brand.mjs"; import { BRAND_CARD_STYLES, renderBrandCard } from "./brand-cards.mjs"; +import { FIRA_SANS, textWidth } from "./svg-faces.mjs"; const execFileP = promisify(execFile); @@ -365,14 +366,20 @@ export async function renderFooterAssets(render, nodes, outDir) { // `magick` invocations. Fonts inside the SVG resolve through fontconfig, so the // family name has to MATCH the Pango cards ("Fira Sans"), not the font FILE that // render.fontRegular points at. +// +// Under a brand preset the SVG text is set in the preset's body face (IBM Plex +// Sans), which rsvg-convert finds among the vendored fonts through the +// FONTCONFIG_FILE childOpts gives it. The face travels with its metric -- see +// svg-faces.mjs -- because the columns below are fitted against it. -const RAIL_FONT = "Fira Sans"; +/** The face the SVG assets are set in: the brand's, else Fira Sans. */ +const svgFaceOf = (render) => brandSvgFace(render) ?? FIRA_SANS; // A tiny SVG text run. Everything is placed absolutely — no flow, no wrapping. function svgText(x, y, text, o = {}) { const a = [ `x="${x}"`, `y="${y}"`, - `font-family="${o.family ?? RAIL_FONT}"`, + `font-family="${o.family ?? FIRA_SANS.family}"`, `font-size="${o.size ?? 15}"`, `fill="${o.color}"`, ]; @@ -383,6 +390,9 @@ function svgText(x, y, text, o = {}) { return `<text ${a.join(" ")}>${esc(text)}</text>`; } +/** svgText in one face: what each asset below draws its text with. */ +const svgTextIn = (face) => (x, y, text, o = {}) => svgText(x, y, text, { ...o, family: face.family }); + const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" ` + `viewBox="0 0 ${w} ${h}">${body}</svg>`; @@ -390,21 +400,48 @@ const svgDoc = (w, h, body) => // rsvg-convert is deterministic about output size in a way ImageMagick's RSVG // delegate is not (its -density is ignored for sizing in some builds), so the // pixel dimensions ffmpeg's crop arithmetic depends on are guaranteed here. -async function rasterize(svg, svgPath, pngPath, w, h) { +// +// `render` only matters under a brand: childOpts points the child at the +// vendored faces. Without one it hands back the options it was given. +async function rasterize(svg, svgPath, pngPath, w, h, render = null) { await writeFile(svgPath, svg, "utf8"); - await execFileP(RSVG, ["-w", String(w), "-h", String(h), "-o", pngPath, svgPath], { + await execFileP(RSVG, ["-w", String(w), "-h", String(h), "-o", pngPath, svgPath], childOpts(render, { maxBuffer: 1 << 26, - }); + })); return pngPath; } -// Truncate to a pixel budget. Fira Sans at these sizes averages ~0.50em per -// character; a hair conservative is right, because an overflowing row would run -// under the value column rather than wrap. -function fit(text, size, maxPx) { - const max = Math.max(4, Math.floor(maxPx / (size * 0.5))); +/** + * Truncate to a pixel budget, in the face the run is drawn in. An overflowing + * row would run under the value column rather than wrap, so the answer has to + * be inside the budget, not near it. + * + * Fira Sans (no brand): its average, ~0.50 em per character, a hair + * conservative -- a character count, exactly as it always was. A measured face + * (the brand's IBM Plex Sans): the run's own advances, weight and letter-spacing + * included; the longest prefix that fits with its ellipsis. See svg-faces.mjs. + * + * @param {object} [face] FIRA_SANS or a measured face (svg-faces.mjs) + * @param {{ weight?: string, ls?: number }} [run] how the run is drawn; only a + * measured face reads it + */ +export function fit(text, size, maxPx, face = FIRA_SANS, run = {}) { const t = String(text ?? ""); - return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`; + if (!face.advances) { + const max = Math.max(4, Math.floor(maxPx / (size * face.em))); + return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`; + } + if (textWidth(t, size, face, run) <= maxPx) return t; + const room = maxPx - textWidth("…", size, face, run); + const chars = [...t]; + let used = 0; + let end = 0; + for (; end < chars.length; end += 1) { + const w = textWidth(chars[end], size, face, run); + if (used + w > room) break; + used += w; + } + return `${chars.slice(0, end).join("").trimEnd()}…`; } /** @@ -618,7 +655,8 @@ function svgTri(x, y, up, color) { } /** One rolling tally cell, drawn into a CELLW x TALLYROWH box at (x, y). */ -function tallyCellSvg(cell, x, y, g, pal, h) { +function tallyCellSvg(cell, x, y, g, pal, h, face) { + const svgText = svgTextIn(face); const right = x + g.CELLW; const out = [`<rect x="${x}" y="${y}" width="${g.CELLW}" height="${h}" fill="${pal.bg}"/>`]; if (cell.empty) { @@ -651,12 +689,13 @@ function tallyCellSvg(cell, x, y, g, pal, h) { } /** One roster line, drawn into a ROSTERW x ROSTERH box. */ -function rosterCellSvg(cell, x, y, g, pal) { +function rosterCellSvg(cell, x, y, g, pal, face) { + const svgText = svgTextIn(face); const out = [`<rect x="${x}" y="${y}" width="${g.ROSTERW}" height="${g.ROSTERH}" fill="${pal.bg}"/>`]; out.push( cell.empty ? svgText(x, y + 18, "not yet enumerated", { size: 12.5, color: pal.muted, opacity: 0.55 }) - : svgText(x, y + 18, fit(cell.line, 13, g.ROSTERW), { size: 13, color: pal.muted }), + : svgText(x, y + 18, fit(cell.line, 13, g.ROSTERW, face), { size: 13, color: pal.muted }), ); return out.join(""); } @@ -701,6 +740,7 @@ export function qrUrlFor(entry, provenance) { async function qrTileStrip(entries, provenance, render, g, outDir) { const pal = render.palette; + const svgText = svgTextIn(svgFaceOf(render)); const dir = path.join(outDir, "cards"); const qrDir = path.join(outDir, "qr"); const q = render.qr ?? {}; @@ -751,7 +791,7 @@ async function qrTileStrip(entries, provenance, render, g, outDir) { const svgPath = path.join(dir, "_rail_qr.svg"); const basePng = path.join(dir, "_rail_qr.base.png"); - await rasterize(svgDoc(g.TILEW, stripH, body.join("")), svgPath, basePng, g.TILEW, stripH); + await rasterize(svgDoc(g.TILEW, stripH, body.join("")), svgPath, basePng, g.TILEW, stripH, render); // The codes are composited rather than inlined: an <image href> in the SVG // would be resampled by rsvg, and a resampled QR does not scan. @@ -779,6 +819,8 @@ export async function renderRailAssets(render, ledger, outDir, entries = null, p const dir = path.join(outDir, "cards"); const rule = rail.rule ?? "#2A322F"; const P = (n) => path.join(dir, n); + const face = svgFaceOf(render); + const svgText = svgTextIn(face); // ---- chrome: opaque, full rail column, never gated ------------------- // It runs for the whole video rather than being switched on with enable=, @@ -800,7 +842,7 @@ export async function renderRailAssets(render, ledger, outDir, entries = null, p const ry = g.TALLYTOP_REL + j * g.TALLYROWH; return ( `<rect x="${g.PAD}" y="${ry + 13}" width="10" height="10" fill="${tr.color}"/>` + - svgText(g.PAD + 20, ry + 22, fit(tr.label, 14, g.CELLX - g.PAD - 24), { + svgText(g.PAD + 20, ry + 22, fit(tr.label, 14, g.CELLX - g.PAD - 24, face), { size: 14, color: pal.fg, }) ); @@ -815,7 +857,7 @@ export async function renderRailAssets(render, ledger, outDir, entries = null, p }), ].join(""); const chrome = await rasterize( - svgDoc(g.RW, g.RHGT, chromeBody), P("_rail_chrome.svg"), P("_rail_chrome.png"), g.RW, g.RHGT, + svgDoc(g.RW, g.RHGT, chromeBody), P("_rail_chrome.svg"), P("_rail_chrome.png"), g.RW, g.RHGT, render, ); // ---- log strip: every claim, stacked, no padding --------------------- @@ -842,14 +884,14 @@ export async function renderRailAssets(render, ledger, outDir, entries = null, p size: 18, color: live ? (tr?.color ?? pal.fg) : pal.muted, weight: "bold", anchor: "end", opacity: live ? 1 : 0.65, }), - svgText(textX, y + 33, fit(c.label ?? c.quote ?? "", 13, textBudget + 74), { + svgText(textX, y + 33, fit(c.label ?? c.quote ?? "", 13, textBudget + 74, face), { size: 13, color: ink, opacity: live ? 1 : 0.6, }), `<rect x="${g.PAD}" y="${y + g.ROWH - 1}" width="${g.RW - 2 * g.PAD}" height="1" fill="${rule}"/>`, ].join(""); }).join(""); const log = await rasterize( - svgDoc(g.RW, g.logStripH, rows), P("_rail_log.svg"), P("_rail_log.png"), g.RW, g.logStripH, + svgDoc(g.RW, g.logStripH, rows), P("_rail_log.svg"), P("_rail_log.png"), g.RW, g.logStripH, render, ); // ---- curtain --------------------------------------------------------- @@ -868,7 +910,7 @@ export async function renderRailAssets(render, ledger, outDir, entries = null, p `<rect x="0" y="0" width="${g.RW}" height="${g.ROWH}" fill="${pal.amber}" opacity="0.10"/>`, `<rect x="${g.PAD - 12}" y="4" width="3" height="${g.ROWH - 8}" fill="${pal.amber}"/>`, ].join("")), - P("_rail_hl.svg"), P("_rail_hl.png"), g.RW, g.ROWH, + P("_rail_hl.svg"), P("_rail_hl.png"), g.RW, g.ROWH, render, ); // ---- tally strip: one COLUMN per lane, side by side ------------------- @@ -892,13 +934,13 @@ export async function renderRailAssets(render, ledger, outDir, entries = null, p lane.rows.forEach((cell, ri) => { strip.push( lane.kind === "roster" - ? rosterCellSvg(cell, x, ri * cellH, g, pal) - : tallyCellSvg(cell, x, ri * cellH, g, pal, cellH), + ? rosterCellSvg(cell, x, ri * cellH, g, pal, face) + : tallyCellSvg(cell, x, ri * cellH, g, pal, cellH, face), ); }); }); const tally = await rasterize( - svgDoc(stripW, stripH, strip.join("")), P("_rail_tally.svg"), P("_rail_tally.png"), stripW, stripH, + svgDoc(stripW, stripH, strip.join("")), P("_rail_tally.svg"), P("_rail_tally.png"), stripW, stripH, render, ); // ---- the provenance tile --------------------------------------------- @@ -958,15 +1000,27 @@ export const SOURCE_TAG = { maybe_missing: "source unreachable", }; -/** Greedy wrap to a pixel budget, at most `maxLines`, last line elided. */ -function wrapPx(text, size, maxPx, maxLines) { - const perChar = size * 0.5; - const cols = Math.max(8, Math.floor(maxPx / perChar)); +/** + * Greedy wrap to a pixel budget, at most `maxLines`, last line elided. + * + * Fira Sans (no brand) wraps at a column count from its average advance, as it + * always did. A measured face wraps at the line's own width, and a lone word + * wider than the budget is fitted too, so no line it returns overruns. + */ +export function wrapPx(text, size, maxPx, maxLines, face = FIRA_SANS) { + let tooLong; + if (face.advances) { + tooLong = (s) => textWidth(s, size, face) > maxPx; + } else { + const perChar = size * face.em; + const cols = Math.max(8, Math.floor(maxPx / perChar)); + tooLong = (s) => s.length > cols; + } const words = String(text ?? "").split(/\s+/).filter(Boolean); const lines = []; let line = ""; for (const w of words) { - if (line && (line + " " + w).length > cols) { + if (line && tooLong(line + " " + w)) { lines.push(line); line = w; if (lines.length === maxLines) break; @@ -977,9 +1031,9 @@ function wrapPx(text, size, maxPx, maxLines) { if (lines.length < maxLines && line) lines.push(line); if (lines.length === maxLines) { const used = lines.join(" ").split(/\s+/).length; - if (used < words.length) lines[maxLines - 1] = fit(lines[maxLines - 1] + " …", size, maxPx); + if (used < words.length) lines[maxLines - 1] = fit(lines[maxLines - 1] + " …", size, maxPx, face); } - return lines; + return face.advances ? lines.map((l) => fit(l, size, maxPx, face)) : lines; } /** @@ -997,6 +1051,14 @@ export async function renderLedgerCard(card, render, ledger, outDir, avail = nul const dir = path.join(outDir, "cards"); const rule = render.rail?.rule ?? "#2A322F"; const RESERVED = reservedFooterHeight(render); + const face = svgFaceOf(render); + const svgText = svgTextIn(face); + // The scope pill's width. Fira: 7.6 px a character at 14.5 px, as it always + // was. A measured face: the label's own width, the same 11 px either side. + const pillW = (tr) => + face.advances + ? Math.max(120, Math.ceil(textWidth(tr?.label ?? "", 14.5, face)) + 22) + : Math.max(120, (tr?.label?.length ?? 8) * 7.6 + 22); const ids = card.claims ?? []; const rows = ids.map((id) => ledger.find((c) => c.id === id)).filter(Boolean); @@ -1048,17 +1110,17 @@ export async function renderLedgerCard(card, render, ledger, outDir, avail = nul svgText(M, y + 32, c.date, { size: 20, color: pal.muted }), // The scope, as a bordered pill in its own colour. Which payroll a number // is about is the whole argument, so it is never left to the ink alone. - `<rect x="${M + 148}" y="${y + 12}" width="${Math.max(120, (tr?.label?.length ?? 8) * 7.6 + 22)}" ` + + `<rect x="${M + 148}" y="${y + 12}" width="${pillW(tr)}" ` + `height="26" rx="13" fill="none" stroke="${tr?.color ?? pal.muted}" stroke-width="1.2"/>`, svgText(M + 159, y + 30, tr?.label ?? c.scope ?? "", { size: 14.5, color: tr?.color ?? pal.muted }), - svgText(M + 148 + Math.max(120, (tr?.label?.length ?? 8) * 7.6 + 22) + 16, y + 30, tag, { + svgText(M + 148 + pillW(tr) + 16, y + 30, tag, { size: 14.5, color: pal.muted, opacity: 0.85, }), svgText(arithX - 70, y + 40, c.display ?? "—", { size: 34, color: tr?.color ?? pal.fg, weight: "bold", anchor: "end", }), ); - wrapPx(`“${c.quote ?? c.label ?? ""}”`, 25, quoteW, 2).forEach((line, li) => { + wrapPx(`“${c.quote ?? c.label ?? ""}”`, 25, quoteW, 2, face).forEach((line, li) => { body.push(svgText(M, y + 76 + li * 33, line, { size: 25, color: pal.fg })); }); @@ -1092,7 +1154,7 @@ export async function renderLedgerCard(card, render, ledger, outDir, avail = nul const moved = (c.scope ?? c.company) === t.key; const ty = y + 48 + k * 24; body.push( - svgText(arithX, ty, fit(t.shortLabel ?? t.label, 13, ARITHW - 90), { + svgText(arithX, ty, fit(t.shortLabel ?? t.label, 13, ARITHW - 90, face), { size: 13, color: moved ? t.color : pal.muted, opacity: moved ? 1 : 0.55, }), svgText(arithX + ARITHW, ty, b ? String(b.value) : "—", { @@ -1127,7 +1189,7 @@ export async function renderLedgerCard(card, render, ledger, outDir, avail = nul }); const outPath = path.join(dir, `${card.id}.png`); - await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H); + await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H, render); return { path: outPath, width: VW, rowsTop, rowHeight: ROWH, rows: rows.length }; } @@ -1167,6 +1229,8 @@ export async function renderScrollCard(card, render, ledger, outDir) { const M = 96; const ROWH = 42; const body = []; + const face = svgFaceOf(render); + const svgText = svgTextIn(face); // Columns. The value columns are right-aligned on their own gridline, so a // number's horizontal position IS its company even before the colour reads. @@ -1197,7 +1261,7 @@ export async function renderScrollCard(card, render, ledger, outDir) { // here to leave room for one. tracks.forEach((tr, i) => { body.push( - svgText(colX[i], y + 26, fit(tr.shortLabel ?? tr.label, 13, COLW - 12), { + svgText(colX[i], y + 26, fit(tr.shortLabel ?? tr.label, 13, COLW - 12, face, { weight: "bold", ls: 0.6 }), { size: 13, color: tr.color, weight: "bold", anchor: "end", ls: 0.6, }), ); @@ -1231,7 +1295,7 @@ export async function renderScrollCard(card, render, ledger, outDir) { svgText(popX, y + 26, POP_WORD[c.population] ?? c.population ?? "", { size: 15, color: pal.muted, opacity: live ? 0.9 : 0.6, }), - svgText(labelX, y + 26, fit(c.label ?? "", 18, labelW), { + svgText(labelX, y + 26, fit(c.label ?? "", 18, labelW, face), { size: 18, color: live ? pal.fg : pal.muted, opacity: live ? 1 : 0.6, }), `<rect x="${M}" y="${y + ROWH - 1}" width="${VW - 2 * M}" height="1" fill="${rule}" opacity="0.5"/>`, @@ -1244,7 +1308,7 @@ export async function renderScrollCard(card, render, ledger, outDir) { const outPath = path.join(dir, `${card.id}.png`); await rasterize( svgDoc(VW, contentHeight, `<rect x="0" y="0" width="${VW}" height="${contentHeight}" fill="${pal.bg}"/>${body.join("")}`), - path.join(dir, `${card.id}.svg`), outPath, VW, contentHeight, + path.join(dir, `${card.id}.svg`), outPath, VW, contentHeight, render, ); return { path: outPath, contentHeight, width: VW }; } @@ -1267,6 +1331,7 @@ export async function renderChartCard(card, render, ledger, outDir) { const H = render.height; const dir = path.join(outDir, "cards"); const rule = render.rail?.rule ?? "#2A322F"; + const svgText = svgTextIn(svgFaceOf(render)); // The series come from ledger-totals, not from the legacy `plotted` flag. // `plotted` was set under the OLD reading, in which a sum we performed sat in @@ -1452,7 +1517,7 @@ export async function renderChartCard(card, render, ledger, outDir) { ); const outPath = path.join(dir, `${card.id}.png`); - await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H); + await rasterize(svgDoc(VW, H, body.join("")), path.join(dir, `${card.id}.svg`), outPath, VW, H, render); return { path: outPath, plotX: box.l, plotY: box.t, plotW, plotH: py(0) - box.t + 2, diff --git a/umtool/report-to-video/svg-faces.mjs b/umtool/report-to-video/svg-faces.mjs @@ -0,0 +1,80 @@ +// svg-faces.mjs — the faces the SVG assets are set in, and how a run of text is +// measured in each. +// +// The rail, `ledger`, `scroll` and `chart` assets are SVG rasterized by +// rsvg-convert, and their columns are FITTED, not flowed: a run that would +// overrun its column is cut short with an ellipsis by fit() (render-cards.mjs) +// before it is drawn, because an overflowing row runs under the value column +// beside it rather than wrapping. So a face carries the metric fit() measures +// it by, and swapping the face without its metric would overrun columns. +// +// Fira Sans Every manifest that does not opt in to a brand. Its metric +// is an AVERAGE: 0.50 em per character. Over the strings fit() +// truncates in the real manifests, Fira averages 0.457 em (a +// string's own average: median 0.457, p95 0.492), so 0.50 is +// the hair of margin it was chosen with. It stays exactly +// that: an unbranded render is byte-identical by rule, and Fira +// is the system's font, not a vendored one, so a table of its +// advances could not be pinned anyway. +// +// IBM Plex Sans render.brand "archilyzer-media". Its metric is the vendored +// variable font's own advance for every character it maps, at +// the two weights these assets draw (400 and 700, wdth 100) -- +// face-metrics.mjs, generated by fonts/gen-face-metrics.py. An +// average cannot promise a run fits: measured the same way, +// Plex averages 0.456 em regular (Fira's own number) but 0.479 +// em bold, 0.59-0.61 em in capitals, and even Fira's 0.50, +// given the manifests' longest real text, lets the scroll's +// bold, letter-spaced column head overrun (152 px rendered in +// 140). A table does not: fit() keeps the longest prefix whose +// measured width, ellipsis included, is inside the budget. +// +// The measurement sums advances and ignores kerning: over the manifests' +// longest real text fitted at every call site, the rendered ink width of every +// Plex run came out at or under the sum, so the sum is the conservative side. +// A character the face does not map is drawn by fontconfig's fallback (emoji, +// CJK) and counted at FALLBACK_EM, wider than either. + +import { IBM_PLEX_SANS_METRICS } from "./face-metrics.mjs"; + +/** An unmapped character's advance, in em: wider than a CJK ideograph (1 em) or a colour emoji (~1.25). */ +export const FALLBACK_EM = 1.3; + +/** The unbranded face, and its average advance per character. */ +export const FIRA_SANS = Object.freeze({ family: "Fira Sans", em: 0.5 }); + +function measuredFace(family, metrics) { + const cps = metrics.ranges.flatMap(([a, b]) => Array.from({ length: b - a + 1 }, (_, i) => a + i)); + const table = (w) => { + const adv = metrics.advances[w]; + if (adv.length !== cps.length) throw new Error(`${metrics.font}: ${adv.length} advances for ${cps.length} characters`); + return new Map(cps.map((cp, i) => [cp, adv[i] / metrics.unitsPerEm])); + }; + return Object.freeze({ + family, + advances: Object.freeze({ regular: table(400), bold: table(700) }), + }); +} + +/** The Archilyzer Media preset's body face, measured per character. */ +export const IBM_PLEX_SANS = measuredFace("IBM Plex Sans", IBM_PLEX_SANS_METRICS); + +/** svgText's `weight` as the tables name it: "bold" (or 600 and up) is the bold table. */ +const tableOf = (weight) => + weight === "bold" || weight === "bolder" || Number(weight) >= 600 ? "bold" : "regular"; + +/** + * The advance of `text` set at `size` px in `face`, letter-spacing included, in + * px. An average face counts characters; a measured face sums its table. + * + * @param {{ weight?: string|number, ls?: number }} [run] the run's weight and + * letter-spacing (px after every character, as SVG adds it) + */ +export function textWidth(text, size, face, { weight = "regular", ls = 0 } = {}) { + const chars = [...String(text ?? "")]; + if (!face.advances) return chars.length * (size * face.em + ls); + const adv = face.advances[tableOf(weight)]; + let em = 0; + for (const ch of chars) em += adv.get(ch.codePointAt(0)) ?? FALLBACK_EM; + return em * size + ls * chars.length; +} diff --git a/umtool/report-to-video/svg-faces.test.mjs b/umtool/report-to-video/svg-faces.test.mjs @@ -0,0 +1,168 @@ +// Tests for the SVG assets' faces (svg-faces.mjs) and what truncates by them: +// fit() and wrapPx() in render-cards.mjs. +// +// Fira Sans -- every manifest without a brand -- keeps its 0.50 em average, +// pinned here to the character counts it always produced. IBM Plex Sans -- the +// Archilyzer Media preset -- is measured per character from the vendored font, +// and a fitted run must come out INSIDE its budget whatever the text is. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { fit, wrapPx } from "./render-cards.mjs"; +import { FALLBACK_EM, FIRA_SANS, IBM_PLEX_SANS, textWidth } from "./svg-faces.mjs"; +import { IBM_PLEX_SANS_METRICS } from "./face-metrics.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PLEX = IBM_PLEX_SANS; + +// What these assets actually draw: the manifests' labels, a quote, the +// capitals of a column head, and the worst cases an average cannot see. +const TEXTS = [ + "two meme makers and the thumbnail guy — he gives no total", + "“I have 10 employees, my coffee company employees… my editors, my designer”", + "2 editors · 1 designer · 1 office · 5 contractors", + "The Quartering · media", + "THE QUARTERING · MEDIA", + "COFFEE BRAND COFFEE CO", + "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", + "MMMM@@@@%%%%mmmmwwww————", + "illicit little lilies fill it, still ill", + "≈10 → 3, full-time; “salaried… pensions”", + "mixed 中文 and 🙂 fall back", + " leading and trailing spaces ", +]; +// [size, budget, run] of the fit() call sites (render-cards.mjs), plus tight ones. +const SITES = [ + [14, 262, {}], // rail: track label + [13, 436, {}], // rail: log label + [13, 394, {}], // rail: roster + [13, 230, {}], // ledger: arithmetic label + [13, 140, { weight: "bold", ls: 0.6 }], // scroll: column head + [18, 796, {}], // scroll: what he said + [25, 60, {}], + [10.5, 33, { weight: "bold", ls: 1.3 }], +]; + +test("Fira Sans: the 0.50 em average, exactly the character counts it always cut at", () => { + // Default face is Fira, and passing it is the same call. + const t = "x".repeat(100); + assert.equal(fit(t, 13, 436), fit(t, 13, 436, FIRA_SANS)); + // floor(436 / 6.5) = 67 characters: 66 of the text and the ellipsis. + assert.equal(fit(t, 13, 436), `${"x".repeat(66)}…`); + assert.equal(fit("x".repeat(67), 13, 436), "x".repeat(67)); + // Never fewer than 4, and the cut is trimmed before the ellipsis. + assert.equal(fit("abcdefghij", 20, 10), "abc…"); + assert.equal(fit("ab cdefgh", 10, 30), "ab…"); + // A run's weight and letter-spacing do not move an average face. + assert.equal(fit("THE QUARTERING · MEDIA", 13, 140, FIRA_SANS, { weight: "bold", ls: 0.6 }), "THE QUARTERING · MED…"); + // It counts: the same length for wide and narrow letters alike. + assert.equal(fit("W".repeat(40), 13, 140).length, fit("i".repeat(40), 13, 140).length); + assert.equal(fit(null, 13, 140), ""); +}); + +test("IBM Plex Sans: a fitted run is inside its budget, whatever the text", () => { + for (const [size, budget, run] of SITES) { + for (const t of TEXTS) { + const out = fit(t, size, budget, PLEX, run); + const w = textWidth(out, size, PLEX, run); + assert.ok(w <= budget + 1e-9, `${JSON.stringify(out)} is ${w.toFixed(2)} px in a ${budget} px column (${size} px ${JSON.stringify(run)})`); + if (out === t) continue; + // Otherwise: a prefix of the text and the ellipsis... + assert.ok(out.endsWith("…")); + const kept = out.slice(0, -1); + assert.ok(t.startsWith(kept), `${JSON.stringify(out)} is not a prefix of ${JSON.stringify(t)}`); + // ...and the LONGEST that fits: one more character would not. + const next = [...t][[...kept].length]; + if (next && next.trim()) { + assert.ok(textWidth(`${kept}${next}…`, size, PLEX, run) > budget, `${JSON.stringify(out)} could keep ${JSON.stringify(next)}`); + } + } + } +}); + +test("IBM Plex Sans: measured, not counted -- the head Fira's count would overrun, and the letters it would cut", () => { + const head = { weight: "bold", ls: 0.6 }; + // The scroll's column head, bold and letter-spaced in 140 px. Fira's count + // keeps 21 characters, which set in Plex Sans Bold are 175 px. + const byCount = fit("THE QUARTERING · MEDIA", 13, 140); + assert.ok(textWidth(byCount, 13, PLEX, head) > 170); + assert.equal(fit("THE QUARTERING · MEDIA", 13, 140, PLEX, head), "THE QUARTERING…"); + assert.equal(fit("W".repeat(25), 13, 140, PLEX, head), `${"W".repeat(9)}…`); + // Narrow letters: the count cuts at 21, the measure keeps what fits. + const narrow = "illicit little lilies fill it, still ill"; + assert.equal(fit(narrow, 13, 140), "illicit little lilie…"); + assert.equal(fit(narrow, 13, 140, PLEX), "illicit little lilies fill it, s…"); + // Bold is its own table, and letter-spacing is paid per character. + assert.ok(textWidth("employees", 13, PLEX, { weight: "bold" }) > textWidth("employees", 13, PLEX)); + assert.equal(textWidth("ab", 10, PLEX, { ls: 2 }), textWidth("ab", 10, PLEX) + 4); +}); + +test("wrapPx: Fira wraps by count as it did; Plex by width, and no line overruns", () => { + const q = "“I built an entire fulfillment center, we have like six employees now”"; + assert.deepEqual(wrapPx(q, 25, 838, 2), [ + "“I built an entire fulfillment center, we have like six employees", + "now”", + ]); + // 765 px of Plex Sans in an 838 px column: one line, no stranded word. + assert.deepEqual(wrapPx(q, 25, 838, 2, PLEX), [q]); + for (const t of TEXTS) { + for (const [size, budget] of [[25, 838], [25, 300], [13, 120]]) { + const lines = wrapPx(t, size, budget, 2, PLEX); + assert.ok(lines.length <= 2); + for (const l of lines) assert.ok(textWidth(l, size, PLEX) <= budget + 1e-9, `${JSON.stringify(l)} overruns ${budget}`); + } + } + // Elided on the last line when the text runs on. + const long = wrapPx(TEXTS[1].repeat(3), 25, 400, 2, PLEX); + assert.equal(long.length, 2); + assert.ok(long[1].endsWith("…")); +}); + +test("textWidth: the table's advances, the fallback for what the face does not map", () => { + assert.equal(textWidth("a", 1000, PLEX), 534); + assert.equal(textWidth("a", 1000, PLEX, { weight: "bold" }), 569); + assert.equal(textWidth("a", 1000, PLEX, { weight: 700 }), 569); + assert.equal(textWidth("中", 10, PLEX), 10 * FALLBACK_EM); + // An astral character is one character, not two UTF-16 halves. + assert.equal(textWidth("🙂", 10, PLEX), 10 * FALLBACK_EM); + // An average face counts. + assert.equal(textWidth("abcd", 10, FIRA_SANS), 20); + assert.equal(textWidth("abcd", 10, FIRA_SANS, { ls: 1 }), 24); +}); + +test("the table is the vendored font's: same file, every mapped character, both weights", () => { + const font = readFileSync(path.join(HERE, "fonts", IBM_PLEX_SANS_METRICS.font)); + assert.equal(createHash("sha256").update(font).digest("hex"), IBM_PLEX_SANS_METRICS.sha256); + const n = IBM_PLEX_SANS_METRICS.ranges.reduce((a, [f, l]) => a + l - f + 1, 0); + assert.equal(n, 891); + assert.equal(IBM_PLEX_SANS_METRICS.advances[400].length, n); + assert.equal(IBM_PLEX_SANS_METRICS.advances[700].length, n); + assert.equal(PLEX.advances.regular.size, n); + assert.equal(PLEX.family, "IBM Plex Sans"); + assert.equal(FIRA_SANS.family, "Fira Sans"); +}); + +// The generator needs python3 + fontTools, and the instancer's rounding is +// fontTools's own: under another version the parity check says so and skips. +const py = spawnSync("python3", ["-c", "import fontTools; print(fontTools.version)"], { encoding: "utf8" }); +const pyFontTools = py.status === 0 ? py.stdout.trim() : null; +const paritySkip = !pyFontTools + ? "python3 with fontTools is not available" + : pyFontTools !== IBM_PLEX_SANS_METRICS.fontTools + ? `fontTools ${pyFontTools} is installed; face-metrics.mjs was generated with ${IBM_PLEX_SANS_METRICS.fontTools}` + : false; + +test("face-metrics.mjs is what gen-face-metrics.py writes today", { skip: paritySkip }, () => { + const r = spawnSync("python3", [path.join(HERE, "fonts", "gen-face-metrics.py"), "--stdout"], { + encoding: "utf8", maxBuffer: 1 << 24, + }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout, readFileSync(path.join(HERE, "face-metrics.mjs"), "utf8")); +});