Linea (the vendor's original format.pdf design), Monogram and Serenity are rebuilt from the Canva PDFs as one parametric family on the engine, with the user's logo in the measured slot, a home for every GST field and A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and a generated Ms Madi outline for Serenity's vertical word. Text-only logo placeholders (Monolith, Tangerine) use a doc-derived allowance box. A Carbon TemplatePicker with committed thumbnails opens from the page setup controls.
226 lines
9.4 KiB
JavaScript
226 lines
9.4 KiB
JavaScript
// 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. Canva'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 (Canva's word runs from y 780 to y 410). */
|
|
const INK_LENGTH = 362;
|
|
/**
|
|
* Ms Madi's strokes are heavier than Canva'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 ys = pts.filter((_, i) => i % 2 === 1);
|
|
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 Canva'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 Canva'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`);
|