// 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, and the sum is NOT the // conservative side in general: IBM Plex Sans has positive kern pairs (412 at // wght 400, 348 at 700, up to +55 units -- a closing quote after r, y or f, // TT, AA; bold "(j" +70), so text made of them renders wider than its sum ("T" // x60 at 13 px: 464 px of ink against a sum of 446.2). It holds empirically: // over the manifests' real text (144 strings, and the same in capitals) the // worst net kerning is +0.50 px, so a fitted run can end up to half a pixel // past its budget. If that ever matters, gen-face-metrics.py would emit the // positive kern pairs and textWidth() add them (the branded path only). // // A character the face does not map is drawn by fontconfig's fallback and // counted at FALLBACK_EM. 1.3 em covers emoji (~1.23 em), flags, CJK (at most // 1 em) and the common scripts, but not every fallback glyph: a few wide // symbols exceed it (U+27F9 1.42 em, U+FDFD 1.93, U+2167 1.31), so a label made // mostly of them would overrun by about 9 %. No real label has one; raising it // would only cut text with unmapped characters sooner. 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.23), not every fallback glyph (above). */ 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; }