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:
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"));
+});