// Generates src/pdf/decor/scriptWord.generated.ts: the word "invoice" as SVG outline paths (Serenity's // vertical title) plus a hand-built swash. Run: npm run decor:script-word // // The outlines come from Ms Madi Regular (SIL OFL 1.1), read with fontkit at build time. The font file lives in // scripts/decor/source/ for reproducibility only; it is never copied into public/fonts, never registered with // react-pdf and never embedded as a font: the PDF carries filled paths, so this is art, not text. the reference design's // SimpleSerenityScript is not used or traced. import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ClipperLib from "clipper-lib"; import * as fontkit from "fontkit"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const FONT = path.join(ROOT, "scripts/decor/source/MsMadi-Regular.ttf"); const OUT = path.join(ROOT, "src/pdf/decor/scriptWord.generated.ts"); const WORD = "invoice"; /** Length of the word's ink along its reading direction, pt (the reference design's word runs from y 780 to y 410). */ const INK_LENGTH = 362; /** * Ms Madi's strokes are heavier than the reference design's hairline script at this size. The glyph contours are merged * into one outline and shrunk by this much on every side (pt), which thins the strokes without a * paper-coloured stroke trick at render time. */ const THIN_BY = 0.95; /** Clipper works in integers: coordinates are multiplied by this. */ const K = 1000; const r2 = (v) => Math.round(v * 100) / 100; /** Flattened outline commands of the laid-out word, in font units (y up, origin on the baseline start). */ function wordCommands(font) { const run = font.layout(WORD); const cmds = []; let x = 0; run.glyphs.forEach((g, i) => { const pos = run.positions[i]; for (const c of g.path.commands) { const args = []; for (let k = 0; k < c.args.length; k += 2) args.push(c.args[k] + x + pos.xOffset, c.args[k + 1] + pos.yOffset); cmds.push({ command: c.command, args }); } x += pos.xAdvance; }); return cmds; } /** * Rotates the word 90 degrees counter-clockwise so it reads bottom to top, scales it to INK_LENGTH and moves * its bounding box to the origin (the shrink in `thinned` then re-origins it). In page space (y down) a font point (x, yUp) lands at (-yUp, -x). */ function rotate(cmds) { const pts = cmds.flatMap((c) => c.args); const xs = pts.filter((_, i) => i % 2 === 0); const inkLen = Math.max(...xs) - Math.min(...xs); const s = INK_LENGTH / inkLen; const map = (x, y) => [-y * s, -x * s]; const mapped = cmds.map((c) => { const a = []; for (let k = 0; k < c.args.length; k += 2) a.push(...map(c.args[k], c.args[k + 1])); return { command: c.command, args: a }; }); const mx = mapped.flatMap((c) => c.args.filter((_, i) => i % 2 === 0)); const my = mapped.flatMap((c) => c.args.filter((_, i) => i % 2 === 1)); const [x0, y0] = [Math.min(...mx), Math.min(...my)]; const width = Math.max(...mx) - x0; const height = Math.max(...my) - y0; const shifted = mapped.map((c) => ({ command: c.command, args: c.args.map((v, i) => (i % 2 === 0 ? v - x0 : v - y0)) })); return { cmds: shifted, width, height, scale: s }; } /** Flattens the outline commands into closed polylines (pt). */ function flatten(cmds) { const polys = []; let cur = []; let last = [0, 0]; const close = () => { if (cur.length > 2) polys.push(cur); cur = []; }; for (const c of cmds) { if (c.command === "moveTo") { close(); cur = [c.args]; last = c.args; } else if (c.command === "lineTo") { cur.push(c.args); last = c.args; } else if (c.command === "quadraticCurveTo") { const [cx, cy, x, y] = c.args; for (let i = 1; i <= 10; i++) { const t = i / 10; const u = 1 - t; cur.push([u * u * last[0] + 2 * u * t * cx + t * t * x, u * u * last[1] + 2 * u * t * cy + t * t * y]); } last = [x, y]; } else if (c.command === "bezierCurveTo") { const [c1x, c1y, c2x, c2y, x, y] = c.args; for (let i = 1; i <= 14; i++) { const t = i / 14; const u = 1 - t; cur.push([ u * u * u * last[0] + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x, u * u * u * last[1] + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y, ]); } last = [x, y]; } else if (c.command === "closePath") close(); } close(); return polys; } /** Union of all contours, shrunk by THIN_BY, as one path. Returns { d, width, height, shiftX } with the box moved to the origin. */ function thinned(cmds) { const subj = flatten(cmds).map((poly) => poly.map(([x, y]) => ({ X: Math.round(x * K), Y: Math.round(y * K) }))); const clipper = new ClipperLib.Clipper(); clipper.AddPaths(subj, ClipperLib.PolyType.ptSubject, true); const merged = new ClipperLib.Paths(); clipper.Execute(ClipperLib.ClipType.ctUnion, merged, ClipperLib.PolyFillType.pftNonZero, ClipperLib.PolyFillType.pftNonZero); const offset = new ClipperLib.ClipperOffset(2, 0.05 * K); offset.AddPaths(merged, ClipperLib.JoinType.jtRound, ClipperLib.EndType.etClosedPolygon); const eroded = new ClipperLib.Paths(); offset.Execute(eroded, -THIN_BY * K); const clean = ClipperLib.Clipper.CleanPolygons(eroded, 0.04 * K); const pts = clean.flat(); const x0 = Math.min(...pts.map((p) => p.X)) / K; const y0 = Math.min(...pts.map((p) => p.Y)) / K; const x1 = Math.max(...pts.map((p) => p.X)) / K; const y1 = Math.max(...pts.map((p) => p.Y)) / K; const d = clean .filter((poly) => poly.length > 2) .map((poly) => `M${poly.map((p) => `${r2(p.X / K - x0)} ${r2(p.Y / K - y0)}`).join("L")}Z`) .join(""); // Where the word ends (its topmost point): the swash continues from here. const top = pts.reduce((a, p) => (p.Y < a.Y ? p : a), pts[0]); return { d, width: x1 - x0, height: y1 - y0, anchorX: top.X / K - x0 }; } /** The swash spine is drawn this much wider than first designed, to match the 25 pt width of the reference design's swash. */ const SWASH_X_SCALE = 1.4; const bez = (p0, p1, p2, p3, t) => { const u = 1 - t; return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * p1[k] + 3 * u * t * t * p2[k] + t * t * t * p3[k]); }; /** * A tapered swash: a spine of cubic segments, thickened by a sine envelope into one filled outline. * Own drawing, in a 28 x 270 pt box; it continues upward from the end of the word like the reference design's does. */ function swash() { const segs = [ [[19, 270], [-2, 222], [30, 176], [13, 130]], [[13, 130], [-4, 86], [26, 52], [15, 14]], [[15, 14], [11, 2], [22, -1], [24, 7]], ]; const spine = []; segs.forEach((s, si) => { for (let i = si === 0 ? 0 : 1; i <= 40; i++) { const q = bez(s[0], s[1], s[2], s[3], i / 40); spine.push([q[0] * SWASH_X_SCALE, q[1]]); } }); const n = spine.length; const left = []; const right = []; spine.forEach((p, i) => { const a = spine[Math.max(0, i - 1)]; const b = spine[Math.min(n - 1, i + 1)]; const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; const nx = -(b[1] - a[1]) / len; const ny = (b[0] - a[0]) / len; const u = i / (n - 1); const half = 0.25 + 1.25 * Math.pow(Math.sin(Math.PI * Math.min(1, u * 1.15)), 0.9); left.push([p[0] + nx * half, p[1] + ny * half]); right.push([p[0] - nx * half, p[1] - ny * half]); }); const all = [...left, ...right.reverse()]; const minY = Math.min(...all.map((p) => p[1])); const minX = Math.min(...all.map((p) => p[0])); const maxX = Math.max(...all.map((p) => p[0])); const maxY = Math.max(...all.map((p) => p[1])); const pts = all.map(([x, y]) => [x - minX, y - minY]); // The spine starts at the bottom of the box: that point meets the end of the word. const anchorX = spine[0][0] - minX; const d = `M${pts.map((p) => p.map(r2).join(" ")).join("L")}Z`; return { d, width: r2(maxX - minX), height: r2(maxY - minY), anchorX: r2(anchorX) }; } const font = fontkit.create(readFileSync(FONT)); const rotated = rotate(wordCommands(font)); const thin = thinned(rotated.cmds); const word = { d: thin.d, width: thin.width, height: thin.height, anchorX: thin.anchorX }; const sw = swash(); const ts = `// Generated by \`npm run decor:script-word\` (scripts/decor/script-word.mjs). Do not edit by hand. // // Outline paths of the word "${WORD}" traced from Ms Madi Regular, rotated 90 degrees counter-clockwise (reads // bottom to top), plus a hand-drawn swash. Ms Madi is Copyright 2018 The Ms Madi Project Authors // (https://github.com/googlefonts/ms-madi), licensed under the SIL Open Font License 1.1 (scripts/decor/source/OFL-MsMadi.txt). // The font itself is not bundled or registered: only these vector outlines are drawn, as art. export interface ScriptArt { /** SVG path data in a box of \`width\` x \`height\` pt starting at the origin (page space, y down). */ d: string; width: number; height: number; /** x inside the box where the word ends (word) or the swash starts (swash): they are joined here. */ anchorX: number; } export const SCRIPT_WORD: ScriptArt = { d: ${JSON.stringify(word.d)}, width: ${r2(word.width)}, height: ${r2(word.height)}, anchorX: ${r2(word.anchorX)}, }; export const SCRIPT_SWASH: ScriptArt = { d: ${JSON.stringify(sw.d)}, width: ${sw.width}, height: ${sw.height}, anchorX: ${sw.anchorX}, }; `; writeFileSync(OUT, ts); console.log(`scriptWord.generated.ts: word ${r2(word.width)} x ${r2(word.height)} pt swash ${sw.width} x ${sw.height} pt`);