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,35 @@
|
||||
import manifestJson from "../../../public/fonts/manifest.json";
|
||||
|
||||
export interface FontFileEntry {
|
||||
file: string;
|
||||
weight: number;
|
||||
style: "normal" | "italic";
|
||||
sha256: string;
|
||||
fsType: number;
|
||||
upm: number;
|
||||
ascent: number;
|
||||
descent: number;
|
||||
lineGap: number;
|
||||
capHeight: number;
|
||||
xHeight: number;
|
||||
hasRupee: boolean;
|
||||
hasTabularNums: boolean;
|
||||
}
|
||||
|
||||
export interface FontFamilyEntry {
|
||||
family: string;
|
||||
folder: string;
|
||||
license: string;
|
||||
licenseFile: string;
|
||||
fonts: FontFileEntry[];
|
||||
}
|
||||
|
||||
export interface FontManifest {
|
||||
version: 1;
|
||||
families: FontFamilyEntry[];
|
||||
}
|
||||
|
||||
// Generated by scripts/fonts/build-manifest.mjs.
|
||||
export const FONT_MANIFEST = manifestJson as FontManifest;
|
||||
|
||||
export const FONT_FAMILIES: readonly string[] = FONT_MANIFEST.families.map((f) => f.family);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FONT_FAMILIES, FONT_MANIFEST } from "./manifest";
|
||||
import { FONT_STACK, preflightFonts, stack } from "./register";
|
||||
|
||||
const base = path.resolve(process.cwd(), "public/fonts");
|
||||
const goodFont = () => {
|
||||
const b = new Uint8Array(20_000);
|
||||
b.set([0, 1, 0, 0]);
|
||||
return b;
|
||||
};
|
||||
|
||||
describe("font manifest", () => {
|
||||
it("lists the four bundled families", () => {
|
||||
expect(FONT_FAMILIES).toEqual([
|
||||
"IBM Plex Mono",
|
||||
"IBM Plex Sans",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Kannada",
|
||||
]);
|
||||
for (const fam of FONT_MANIFEST.families) expect(fam.license).toBe("OFL-1.1");
|
||||
});
|
||||
|
||||
it("records the rupee sign in Plex Sans for every weight", () => {
|
||||
const plex = FONT_MANIFEST.families.find((f) => f.family === "IBM Plex Sans")!;
|
||||
expect(plex.fonts.every((f) => f.hasRupee)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stack", () => {
|
||||
it("puts the primary first and never duplicates", () => {
|
||||
expect(stack("Jost")).toEqual(["Jost", ...FONT_STACK]);
|
||||
expect(stack("IBM Plex Sans")).toEqual([...FONT_STACK]);
|
||||
expect(stack("Noto Sans Kannada")).toEqual([
|
||||
"Noto Sans Kannada",
|
||||
"IBM Plex Sans",
|
||||
"Noto Sans Devanagari",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflightFonts", () => {
|
||||
it("passes for the real files on disk", async () => {
|
||||
const problems = await preflightFonts(
|
||||
async (url) => new Uint8Array(readFileSync(path.join(base, url.slice("/fonts/".length)))),
|
||||
"/fonts",
|
||||
);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a fake that serves valid fonts", async () => {
|
||||
expect(await preflightFonts(async () => goodFont(), "/fonts")).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags index.html served with 200, tiny files, WOFF2 and failed fetches", async () => {
|
||||
const html = new TextEncoder().encode(`<!doctype html>${" ".repeat(20_000)}`);
|
||||
const tiny = new Uint8Array([0, 1, 0, 0, 1, 2, 3]);
|
||||
const woff2 = new Uint8Array(20_000);
|
||||
woff2.set([0x77, 0x4f, 0x46, 0x32]);
|
||||
let n = 0;
|
||||
const problems = await preflightFonts(async () => {
|
||||
n++;
|
||||
if (n === 1) return html;
|
||||
if (n === 2) return tiny;
|
||||
if (n === 3) return woff2;
|
||||
if (n === 4) throw new Error("HTTP 404");
|
||||
return goodFont();
|
||||
}, "/fonts");
|
||||
expect(problems).toHaveLength(4);
|
||||
expect(problems[0]).toContain("not a TrueType font");
|
||||
expect(problems[1]).toContain("only 7 bytes");
|
||||
expect(problems[2]).toContain("not a TrueType font");
|
||||
expect(problems[3]).toContain("HTTP 404");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Font } from "@react-pdf/renderer";
|
||||
import { FONT_MANIFEST } from "./manifest";
|
||||
|
||||
/** Every stack ends here: Latin and the rupee sign from Plex, then the Indic scripts. */
|
||||
export const FONT_STACK: readonly string[] = [
|
||||
"IBM Plex Sans",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Kannada",
|
||||
];
|
||||
|
||||
/** `[primary, ...tail]` without duplicates, for a style's fontFamily. */
|
||||
export function stack(primary: string): string[] {
|
||||
return [primary, ...FONT_STACK.filter((f) => f !== primary)];
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers every bundled family from the manifest. Idempotent; never call Font.reset(), the fonts
|
||||
* would not load again.
|
||||
*
|
||||
* `base` is a URL prefix in the app ("/fonts") and a directory under Node (tests). Only static TTF is
|
||||
* used: WOFF2 embeds glyphs without outlines (invisible text) and variable fonts embed only their
|
||||
* default instance.
|
||||
*/
|
||||
export function registerBundledFonts(base: string): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
const sources: { src: string; fontWeight: number; fontStyle: "normal" | "italic" }[] = [];
|
||||
const weights = new Set<number>();
|
||||
for (const font of fam.fonts) {
|
||||
weights.add(font.weight);
|
||||
sources.push({
|
||||
src: `${base}/${fam.folder}/${font.file}`,
|
||||
fontWeight: font.weight,
|
||||
fontStyle: font.style,
|
||||
});
|
||||
}
|
||||
// react-pdf resolves every family in a fallback stack with the node's own style and throws for
|
||||
// a family that has no italic, so any weight without a real italic reuses its upright file.
|
||||
for (const weight of weights) {
|
||||
if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue;
|
||||
const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal");
|
||||
if (upright) sources.push({ ...upright, fontStyle: "italic" });
|
||||
}
|
||||
Font.register({ family: fam.family, fonts: sources });
|
||||
}
|
||||
|
||||
// The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers.
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
}
|
||||
|
||||
const MIN_FONT_BYTES = 10 * 1024;
|
||||
|
||||
function hasTrueTypeMagic(b: Uint8Array): boolean {
|
||||
if (b.length < 4) return false;
|
||||
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return true;
|
||||
return String.fromCharCode(b[0], b[1], b[2], b[3]) === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every bundled font and returns a list of problems (empty when all are fine). Tauri answers
|
||||
* an unknown path with index.html and HTTP 200, so the response bytes must be checked, not the status.
|
||||
*/
|
||||
export async function preflightFonts(
|
||||
fetchBytes: (url: string) => Promise<Uint8Array>,
|
||||
base: string,
|
||||
): Promise<string[]> {
|
||||
const problems: string[] = [];
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const font of fam.fonts) {
|
||||
const url = `${base}/${fam.folder}/${font.file}`;
|
||||
try {
|
||||
const bytes = await fetchBytes(url);
|
||||
if (!hasTrueTypeMagic(bytes)) problems.push(`${url}: not a TrueType font`);
|
||||
else if (bytes.length <= MIN_FONT_BYTES) problems.push(`${url}: only ${bytes.length} bytes`);
|
||||
} catch (err) {
|
||||
problems.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createElement as h } from "react";
|
||||
import path from "node:path";
|
||||
import { renderToBuffer, Document, Page, Text } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerBundledFonts, stack } from "./register";
|
||||
|
||||
registerBundledFonts(path.resolve(process.cwd(), "public/fonts"));
|
||||
|
||||
const render = (page: object, style: object) =>
|
||||
renderToBuffer(
|
||||
h(Document, null, h(Page, page as never, h(Text, { style: style as never }, "Total ₹1,23,456.78 नमस्ते ಕನ್ನಡ"))),
|
||||
);
|
||||
|
||||
describe("bundled fonts render", () => {
|
||||
it("renders every family-stack in italic and bold-italic without throwing", async () => {
|
||||
for (const fontWeight of [300, 400, 500, 600, 700]) {
|
||||
const buf = await render({ size: "A4" }, { fontFamily: stack("IBM Plex Sans"), fontStyle: "italic", fontWeight });
|
||||
expect(buf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
}
|
||||
const mono = await render({ size: "A4" }, { fontFamily: stack("IBM Plex Mono"), fontStyle: "italic", fontWeight: 600 });
|
||||
expect(mono.length).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("accepts mm strings in the Page size object", async () => {
|
||||
const buf = await render({ size: { width: "210mm", height: "297mm" } }, { fontFamily: stack("IBM Plex Sans") });
|
||||
const m = /\/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]/.exec(buf.toString("latin1"));
|
||||
expect(m).not.toBeNull();
|
||||
expect(Number(m![1])).toBeCloseTo(595.28, 1);
|
||||
expect(Number(m![2])).toBeCloseTo(841.89, 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user