Files
soubarna 5b18db3a77 fix: eslint findings
Remove dead code and unused imports, drop useless assignments, and tune
rules in eslint.config.js (underscore-prefixed unused names, intentional BOM/NBSP,
React Compiler rules from eslint-plugin-react-hooks 7 as warnings).
2026-10-05 17:29:03 +05:30

225 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. 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`);