Add typography roles, text primitives, fontkit measurer and layout lint

Txt always sets an absolute line height; RenderTxt never does. Row pins
every cell and stacks when the fill column would be too narrow. IdText and
FitText shrink then chunk or truncate. The fontkit measurer matches react-pdf
text widths exactly. A TypeScript-AST lint test enforces the layout rules
(no lineHeight outside Txt, no non-literal fixed/wrap/minPresenceAhead,
flexGrow needs flexBasis, no stray absolute positioning).
This commit is contained in:
2026-10-04 04:48:19 +05:30
parent 92ba3d9590
commit 40aa3e05d9
22 changed files with 1375 additions and 34 deletions
+136
View File
@@ -0,0 +1,136 @@
import { createElement as h } from "react";
import { Document, Page, Text } from "@react-pdf/renderer";
import { describe, expect, it } from "vitest";
import { stack } from "../fonts/register";
import { renderPdfText } from "../testing/pdfText";
import { setupPdfTest } from "../testing/setup";
import { chunkIdentifier } from "../model/text";
const m = setupPdfTest();
const SAMPLES: string[] = [
"Voice-over session 12 - narration for module 3",
"Total ₹1,23,456.78",
"AVATAR WAVE To Ty Yo",
"vendor.name@examplestudios.co.in",
"29ABCDE1234F1Z5",
"office fi fl ffi final",
"BILLED TO",
"Amount: ₹5,00,000.00 (Rupees Five Lakh Only) नमस्ते",
"नमस्ते दुनिया, यह एक परीक्षण है",
"ಕನ್ನಡ ಭಾಷೆಯ ಪರೀಕ್ಷೆ ಇನ್ವಾಯ್ಸ್",
"Invoice क्रमांक INV-0042 ಇನ್ವಾಯ್ಸ್",
"1234567890 0.00 111 000",
];
interface Case {
text: string;
size: number;
weight: number;
italic: boolean;
spacing: number;
caps: boolean;
}
const isIndic = (t: string) => /[\u0900-\u0DFF]/.test(t);
const cases: Case[] = [];
for (const text of SAMPLES) {
for (const size of [7.5, 9.5, 18]) cases.push({ text, size, weight: 400, italic: false, spacing: 0, caps: false });
}
cases.push(
{ text: "Bold semibold text ₹99", size: 9.5, weight: 600, italic: false, spacing: 0, caps: false },
{ text: "Italic amount in words only", size: 9.5, weight: 400, italic: true, spacing: 0, caps: false },
{ text: "Light weight 300", size: 13, weight: 300, italic: false, spacing: 0, caps: false },
{ text: "Heavy 800 falls back to bold", size: 13, weight: 800, italic: false, spacing: 0, caps: false },
{ text: "tax invoice", size: 18, weight: 600, italic: false, spacing: 1.5, caps: true },
{ text: "description", size: 7.5, weight: 400, italic: false, spacing: 0.8, caps: true },
{ text: "billed to नमस्ते", size: 7.5, weight: 400, italic: false, spacing: 1, caps: true },
);
describe("measurer parity with react-pdf", () => {
it("matches the width pdf.js reads from rendered text", async () => {
const doc = h(
Document,
null,
...cases.map((c, i) =>
h(
Page,
{ key: i, size: { width: 900, height: 80 }, style: { padding: 20 } },
h(
Text,
{
style: {
fontFamily: stack("IBM Plex Sans"),
fontSize: c.size,
fontWeight: c.weight,
fontStyle: c.italic ? "italic" : "normal",
letterSpacing: c.spacing,
...(c.caps ? { textTransform: "uppercase" } : {}),
} as never,
},
// pdf.js sizes shaped Indic items from raw glyph widths (it reports 12.9 pt for a run that
// advances 15.8 pt), so those cases end in a Latin "I" and the width is read from where
// that glyph starts instead.
isIndic(c.text) ? `${c.text}I` : c.text,
),
),
),
);
const pdf = await renderPdfText(doc);
expect(pdf.pages.length).toBe(cases.length);
let maxDev = 0;
let worst = "";
pdf.pages.forEach((page, i) => {
const c = cases[i];
const x0 = Math.min(...page.items.map((t) => t.x0));
// pdf.js leaves the trailing letter spacing of the last glyph out of an item's width.
let x1 = Math.max(...page.items.map((t) => t.x1)) + c.spacing;
if (isIndic(c.text)) {
const last = page.items[page.items.length - 1];
expect(last.str, `${c.text}: sentinel item`).toBe("I");
x1 = last.x0;
}
const measured = m.measureWidth({
text: c.caps ? c.text.toUpperCase() : c.text,
face: "IBM Plex Sans",
weight: c.weight,
italic: c.italic,
size: c.size,
letterSpacing: c.spacing,
});
const dev = Math.abs(x1 - x0 - measured);
if (dev > maxDev) {
maxDev = dev;
worst = `${c.text} @${c.size} (pdf ${(x1 - x0).toFixed(3)} vs ${measured.toFixed(3)})`;
}
expect(dev, `${c.text} @${c.size} w${c.weight}: pdf ${(x1 - x0).toFixed(3)} measured ${measured.toFixed(3)}`).toBeLessThan(0.05);
});
console.log(`measurer parity: ${cases.length} cases, max deviation ${maxDev.toFixed(4)} pt (${worst})`);
});
});
describe("measurer helpers", () => {
it("reports uncovered code points", () => {
expect(m.missing("Hello ₹ नमस्ते ಕನ್ನಡ")).toEqual([]);
expect(m.missing("A 中 B")).toEqual([0x4e2d]);
});
it("wraps greedily at spaces and hyphens", () => {
const input = { face: "IBM Plex Sans", weight: 400, size: 9.5 };
const text = "one two three four five six seven eight nine ten";
const full = m.measureWidth({ ...input, text });
const lines = m.wrapLines(text, full * 0.6, input);
expect(lines.length).toBe(2);
expect(lines.join(" ")).toBe(text);
for (const l of lines) expect(m.measureWidth({ ...input, text: l })).toBeLessThanOrEqual(full * 0.6);
expect(m.wrapLines("abcdefghijkl", 5, input)).toEqual(["abcdefghijkl"]);
expect(m.wrapLines("", 100, input)).toEqual([]);
});
it("round-trips chunkIdentifier output", () => {
const id = "vendor.name@examplestudios.co.in";
expect(chunkIdentifier(id, 12).join("")).toBe(id);
});
});