Add bundled TTF fonts, page geometry, column allocator and render model
Static TTF fonts (IBM Plex Sans/Mono, Noto Devanagari/Kannada) with a fontkit-generated manifest, a verifier and OFL licences. Every family registers a normal and italic source so fallback stacks cannot throw. Adds A4/Letter geometry with margin presets, an exact column allocator, deterministic Indian-grouping money and date formatting, text sanitising and a pure buildRenderModel.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
// Regenerates public/fonts/manifest.json from the TTFs under public/fonts/<folder>/.
|
||||
// Run: node scripts/fonts/build-manifest.mjs
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { FONTS_DIR, MANIFEST_PATH, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
|
||||
|
||||
const families = [];
|
||||
for (const folder of familyFolders()) {
|
||||
const files = ttfFiles(folder);
|
||||
if (files.length === 0) continue;
|
||||
const inspected = files.map((file) => inspectFont(folder, file));
|
||||
const names = new Set(inspected.map((f) => f.familyName));
|
||||
if (names.size !== 1) {
|
||||
throw new Error(`${folder}: mixed family names ${[...names].join(", ")}`);
|
||||
}
|
||||
const licenseFile = "OFL.txt";
|
||||
if (!existsSync(path.join(FONTS_DIR, folder, licenseFile))) {
|
||||
throw new Error(`${folder}: missing ${licenseFile}`);
|
||||
}
|
||||
const fonts = inspected
|
||||
.map((f) => f.info)
|
||||
.sort((a, b) => a.weight - b.weight || a.style.localeCompare(b.style) || a.file.localeCompare(b.file));
|
||||
families.push({ family: [...names][0], folder, license: "OFL-1.1", licenseFile, fonts });
|
||||
}
|
||||
|
||||
writeFileSync(MANIFEST_PATH, `${JSON.stringify({ version: 1, families }, null, 2)}\n`);
|
||||
console.log(
|
||||
`manifest.json: ${families.length} families, ${families.reduce((n, f) => n + f.fonts.length, 0)} fonts`,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
// Shared helpers for the font manifest builder and verifier (Node only).
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as fontkit from "fontkit";
|
||||
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
export const FONTS_DIR = path.join(ROOT, "public/fonts");
|
||||
export const MANIFEST_PATH = path.join(FONTS_DIR, "manifest.json");
|
||||
|
||||
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** Folders under public/fonts that hold bundled families (everything that is a directory). */
|
||||
export function familyFolders() {
|
||||
return readdirSync(FONTS_DIR)
|
||||
.filter((name) => statSync(path.join(FONTS_DIR, name)).isDirectory())
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function ttfFiles(folder) {
|
||||
return readdirSync(path.join(FONTS_DIR, folder))
|
||||
.filter((name) => /\.ttf$/i.test(name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** First four bytes as a tag: "\0\1\0\0" is reported as "0x00010000". */
|
||||
export function sfntTag(bytes) {
|
||||
const b = bytes.subarray(0, 4);
|
||||
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "0x00010000";
|
||||
return String.fromCharCode(...b);
|
||||
}
|
||||
|
||||
/** Raw OS/2 fsType (fontkit only exposes decoded flags). */
|
||||
function rawFsType(bytes) {
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const numTables = dv.getUint16(4);
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const rec = 12 + i * 16;
|
||||
const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
|
||||
if (tag === "OS/2") return dv.getUint16(dv.getUint32(rec + 8) + 8);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reads a TTF with fontkit and returns the facts the manifest records. */
|
||||
export function inspectFont(folder, file) {
|
||||
const bytes = new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
|
||||
const font = fontkit.create(Buffer.from(bytes));
|
||||
if (font.fonts) throw new Error(`${folder}/${file}: font collections are not supported`);
|
||||
const os2 = font["OS/2"];
|
||||
return {
|
||||
bytes,
|
||||
tag: sfntTag(bytes),
|
||||
variable: Boolean(font.directory?.tables?.fvar),
|
||||
// Name ID 16 (typographic family) groups SemiBold etc. under the base family.
|
||||
familyName: font.name?.records?.preferredFamily?.en ?? font.familyName,
|
||||
info: {
|
||||
file,
|
||||
weight: os2.usWeightClass,
|
||||
style: os2.fsSelection.italic || os2.fsSelection.oblique ? "italic" : "normal",
|
||||
sha256: sha256(bytes),
|
||||
fsType: rawFsType(bytes),
|
||||
upm: font.unitsPerEm,
|
||||
ascent: font.ascent,
|
||||
descent: font.descent,
|
||||
lineGap: font.lineGap,
|
||||
capHeight: font.capHeight,
|
||||
xHeight: font.xHeight,
|
||||
hasRupee: font.hasGlyphForCodePoint(0x20b9),
|
||||
hasTabularNums: font.availableFeatures.includes("tnum"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const WEIGHT_WORDS = {
|
||||
Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Italic: 400, Text: 450,
|
||||
Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// Checks public/fonts against manifest.json. Exits non-zero with readable messages on any problem.
|
||||
// Run: npm run fonts:verify
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { FONTS_DIR, MANIFEST_PATH, WEIGHT_WORDS, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
|
||||
|
||||
const problems = [];
|
||||
const fail = (where, msg) => problems.push(`${where}: ${msg}`);
|
||||
|
||||
if (!existsSync(MANIFEST_PATH)) {
|
||||
console.error("public/fonts/manifest.json is missing; run node scripts/fonts/build-manifest.mjs");
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
|
||||
if (manifest.version !== 1) fail("manifest", `unsupported version ${manifest.version}`);
|
||||
|
||||
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
const listed = new Set();
|
||||
let count = 0;
|
||||
|
||||
for (const fam of manifest.families) {
|
||||
const dir = `public/fonts/${fam.folder}`;
|
||||
if (slug(fam.family) !== fam.folder) fail(dir, `folder does not match family "${fam.family}"`);
|
||||
if (!existsSync(path.join(FONTS_DIR, fam.folder, fam.licenseFile))) {
|
||||
fail(dir, `licence file ${fam.licenseFile} is missing`);
|
||||
}
|
||||
for (const entry of fam.fonts) {
|
||||
const where = `${fam.folder}/${entry.file}`;
|
||||
listed.add(where);
|
||||
count++;
|
||||
if (!existsSync(path.join(FONTS_DIR, fam.folder, entry.file))) {
|
||||
fail(where, "listed in the manifest but not on disk");
|
||||
continue;
|
||||
}
|
||||
let f;
|
||||
try {
|
||||
f = inspectFont(fam.folder, entry.file);
|
||||
} catch (err) {
|
||||
fail(where, `cannot be parsed: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
if (f.tag !== "0x00010000" && f.tag !== "true") {
|
||||
fail(where, `not a TrueType font (magic "${f.tag}"); WOFF, WOFF2 and OpenType/CFF must not be bundled for PDFs`);
|
||||
}
|
||||
if (f.variable) fail(where, "variable font (has fvar); react-pdf would embed only the default instance");
|
||||
if (f.info.sha256 !== entry.sha256) fail(where, "sha256 differs from the manifest; rebuild it if the change is intended");
|
||||
if (f.info.fsType !== 0 && f.info.fsType !== 8) {
|
||||
fail(where, `fsType ${f.info.fsType} does not allow embedding (need 0 or 8)`);
|
||||
}
|
||||
if (f.familyName !== fam.family) fail(where, `family is "${f.familyName}", manifest says "${fam.family}"`);
|
||||
if (f.info.weight !== entry.weight || f.info.style !== entry.style) {
|
||||
fail(where, `weight/style ${f.info.weight}/${f.info.style} differs from the manifest ${entry.weight}/${entry.style}`);
|
||||
}
|
||||
const word = entry.file.replace(/\.ttf$/i, "").split("-").pop();
|
||||
const expected = WEIGHT_WORDS[word];
|
||||
if (expected !== undefined && word !== "Text" && expected !== f.info.weight) {
|
||||
fail(where, `file name says ${word} (${expected}) but OS/2 weight is ${f.info.weight}`);
|
||||
}
|
||||
if (/Italic$/.test(word) !== (f.info.style === "italic")) {
|
||||
fail(where, `file name and OS/2 disagree about italic (${f.info.style})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const folder of familyFolders()) {
|
||||
for (const file of ttfFiles(folder)) {
|
||||
if (!listed.has(`${folder}/${file}`)) fail(`${folder}/${file}`, "TTF on disk but not in the manifest");
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`fonts:verify found ${problems.length} problem(s):`);
|
||||
for (const p of problems) console.error(` - ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`fonts:verify OK: ${manifest.families.length} families, ${count} fonts`);
|
||||
Reference in New Issue
Block a user