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:
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user