Archilyzer · Source

archilyzer

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

commit 7716753f67ea051610c6894826d75d04cfa85939
parent 3abb3691161cdf73f9d4398a911c44b007aa80c2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 03:17:46 -0400

umtool: the rail, ledger, scroll and chart set in IBM Plex Sans under the brand preset, fitted by the face's own advances

S4 left these four in Fira Sans because fit() truncated against Fira's
0.50 em average. Re-measured over the strings fit() actually cuts (the
five S4 manifests' labels, quotes, roster lines and heads): Fira averages
0.457 em (p95 of a string 0.492 -- 0.50 is the hair of margin it was
chosen with); the vendored Plex Sans averages 0.456 regular but 0.479
bold and 0.59-0.61 in capitals. An average cannot promise a column: with
the manifests' longest real labels pushed through each call site, even
Fira's 0.50 overruns the scroll's bold, letter-spaced head in 40 of 87
runs (by up to 11 %), and uppercased text overruns every site in both
faces.

So a face now carries the metric it is fitted by (svg-faces.mjs):
- FIRA_SANS keeps its 0.50 em average, and fit()/wrapPx() keep the exact
  character counts they always cut at -- an unbranded render is
  byte-identical (the S4 harness: 334 files, 0 different).
- IBM_PLEX_SANS is measured: face-metrics.mjs, generated by
  fonts/gen-face-metrics.py (fontTools) from the vendored variable font
  at wght 400 and 700, wdth 100 -- the advance of all 891 characters it
  maps. 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.

brandSvgFace(render) names the face (null without a brand), and
rasterize() hands rsvg-convert childOpts, so the preset's FONTCONFIG_FILE
reaches the SVG text too. The QR tile's text follows the rail.

Tests: svg-faces.test.mjs (7) -- Fira's counts pinned; Plex: every fitted
run inside its budget over 12 texts x 8 sites and the longest prefix that
fits; the head Fira's count would overrun; wrap; the table = the vendored
font (sha256) = a fresh gen-face-metrics.py --stdout. brand.test.mjs +1
(the scroll card's SVG in each face, with its fitted head) and +2 asserts.

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

Diffstat:
Mumtool/report-to-video/brand.mjs | 25+++++++++++++++++++++----
Mumtool/report-to-video/brand.test.mjs | 42++++++++++++++++++++++++++++++++++++++++--
Aumtool/report-to-video/face-metrics.mjs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 651 insertions(+), 46 deletions(-)

diff --git a/umtool/report-to-video/brand.mjs b/umtool/report-to-video/brand.mjs @@ -8,10 +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`, -// `fontRegular` or `fontBold` 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. // @@ -39,6 +40,7 @@ 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"); @@ -63,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. */ @@ -159,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 @@ -10,17 +10,21 @@ 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 { mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { BRAND_IDS, END_CARD_DEFAULT_SECONDS, END_CARD_DEFAULT_URL, END_CARD_ID, FACES, FONTCONFIG_FILE, FONTS_DIR, MONO_BOLD_FONT_FILE, MONO_FONT_FILE, brandFaces, brandHeaderGeometry, brandKit, brandManifest, - childOpts, endCardConfig, resolveBrandRender, + 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 PLAIN_RENDER = { width: 1920, height: 1080, fps: 30, headerHeight: 56, @@ -36,6 +40,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); @@ -72,6 +77,8 @@ test("the preset owns palette and face, and resolves the end card", () => { 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); @@ -215,3 +222,34 @@ 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 }); + } +}); 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/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 lets +// a run of long real labels in bold, letter-spaced capitals +// overrun by 11 %. 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: kerning only tightens a +// run of these faces, so the sum is the conservative side (rendered ink widths +// came in 0.3-1.5 % under it). 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")); +});