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
+10
View File
@@ -0,0 +1,10 @@
import { expect } from "vitest";
import { describeBox, findOverlaps, type PageText } from "./pdfText";
/** Fails (listing every colliding pair) when any two text boxes on any page overlap. */
export function assertNoOverlap(pages: PageText[]): void {
pages.forEach((page, idx) => {
const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
expect(overlaps, `page ${idx + 1} overlaps`).toEqual([]);
});
}
+103
View File
@@ -0,0 +1,103 @@
import ts from "typescript";
export interface LintViolation {
file: string;
line: number;
rule: string;
message: string;
}
/** Paths (relative to the repo root, posix) where each otherwise-banned construct may appear. */
export const LINT_ALLOW = {
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts"],
absolute: ["src/pdf/decor/", "src/pdf/blocks/PageChrome.tsx", "src/pdf/blocks/FirstPageHeader.tsx"],
rawFlags: ["src/pdf/blocks/pdfProps.ts"],
};
const FLAG_PROPS = new Set(["fixed", "wrap", "minPresenceAhead"]);
const propName = (n: ts.PropertyName): string | undefined =>
ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : undefined;
const stringValue = (e: ts.Expression): string | undefined =>
ts.isStringLiteralLike(e) ? e.text : undefined;
const allowed = (file: string, list: string[]) => list.some((p) => (p.endsWith("/") ? file.startsWith(p) : file === p));
/**
* Static layout-rule checks for react-pdf code. `file` is the repo-relative posix path.
*
* Not checked: a pinned numeric `height` on an element that holds text. That cannot be decided from
* the syntax (the text may be several components away), so it stays a review rule: textkit cuts
* lines at the box height and appends "…" even without maxLines. Use minHeight for text containers.
*/
export function lintSource(file: string, text: string): LintViolation[] {
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
const out: LintViolation[] = [];
const lineOf = (n: ts.Node) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
const add = (n: ts.Node, rule: string, message: string) => out.push({ file, line: lineOf(n), rule, message });
const usesPdfFlags = /\bpdfFlags\b/.test(text);
const visit = (node: ts.Node) => {
if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) {
const name = ts.isPropertyAssignment(node) ? propName(node.name) : node.name.text;
if (name === "lineHeight" && !allowed(file, LINT_ALLOW.leadingProp)) {
add(node, "line-height", "lineHeight is set only by Txt and the roles; use a Txt role (an ancestor lineHeight breaks render-prop text)");
}
if (ts.isPropertyAssignment(node)) {
const v = stringValue(node.initializer);
if (name === "position" && v === "absolute" && !allowed(file, LINT_ALLOW.absolute)) {
add(node, "absolute", "position: 'absolute' is allowed only in src/pdf/decor/**, PageChrome and FirstPageHeader");
}
if (name === "alignItems" && v === "flex-end") {
add(node, "align-flex-end", "alignItems: 'flex-end' misplaces multi-line text; use flex-start or align inside the cell");
}
}
}
if (ts.isObjectLiteralExpression(node)) {
const props = new Map<string, ts.PropertyAssignment>();
for (const p of node.properties) {
if (ts.isPropertyAssignment(p)) {
const n = propName(p.name);
if (n) props.set(n, p);
}
}
const grow = props.get("flexGrow");
if (grow) {
const init = grow.initializer;
// A non-literal value may be positive, so it counts.
const positive = ts.isNumericLiteral(init) ? Number(init.text) > 0 : true;
if (positive && !props.has("flexBasis") && !props.has("flex")) {
add(grow, "flex-grow", "flexGrow > 0 needs flexBasis: 0 (or flex:); use FILL from engine/columns");
}
}
}
if (ts.isJsxAttribute(node)) {
const name = ts.isIdentifier(node.name) ? node.name.text : undefined;
if (name === "lineHeight" && !allowed(file, LINT_ALLOW.leadingProp)) {
add(node, "line-height", "lineHeight is set only by Txt and the roles");
}
if (name && FLAG_PROPS.has(name) && !allowed(file, LINT_ALLOW.rawFlags)) {
const init = node.initializer;
const expr = init && ts.isJsxExpression(init) ? init.expression : undefined;
let ok: boolean;
if (!init) ok = name === "fixed"; // bare `fixed`
else if (!expr) ok = false;
else if (name === "fixed") ok = expr.kind === ts.SyntaxKind.TrueKeyword;
else if (name === "wrap") ok = expr.kind === ts.SyntaxKind.FalseKeyword;
else ok = ts.isNumericLiteral(expr) && Number(expr.text) > 0;
if (!ok && !usesPdfFlags) {
add(node, "pdf-flag", `${name} is checked by key presence in react-pdf; pass only a literal (${name === "fixed" ? "true" : name === "wrap" ? "false" : "a positive number"}) or spread pdfFlags()`);
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return out;
}
export const formatViolation = (v: LintViolation) => `${v.file}:${v.line} [${v.rule}] ${v.message}`;
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { createMeasurer, setMeasurer, type Measurer } from "../engine/measure";
import { registerBundledFonts } from "../fonts/register";
export const FONTS_DIR = path.resolve(process.cwd(), "public/fonts");
/** Registers the bundled fonts for react-pdf and installs a Node-backed measurer. */
export function setupPdfTest(): Measurer {
registerBundledFonts(FONTS_DIR);
const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file))));
setMeasurer(m);
return m;
}