Add pagination blocks, renderCore, layout audit and react-pdf quirk tests
TableGuard, BottomSpacer, FirstPageHeader and PageChrome implement the verified pagination rules; renderCore returns bytes, a normalised layout tree, audit issues and a layout fingerprint. auditLayout detects overlaps, ink overflow, truncation, header-only pages and split blocks. Quirk tests pin the react-pdf behaviours the design depends on, and a child-process test pins the page-level absolute Svg hang.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { cloneElement, createElement as h, type ReactElement, type ReactNode } from "react";
|
||||
import { Document, Page, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
|
||||
import { walk, type LayoutNode, type LayoutPage } from "../engine/layoutTree";
|
||||
import { stack } from "../fonts/register";
|
||||
|
||||
/** Small fixtures for tests that need a tiny react-pdf document. */
|
||||
export const FAMILY = stack("IBM Plex Sans");
|
||||
export const PAGE_W = 400;
|
||||
export const PAD = 20;
|
||||
|
||||
export const txt = (extra: Record<string, unknown> = {}) => ({ fontFamily: FAMILY, fontSize: 10, lineHeight: "12pt", ...extra });
|
||||
|
||||
export function doc(children: ReactNode, o: { height?: number; width?: number; pad?: number | Record<string, number> } = {}): ReactElement {
|
||||
const pad = o.pad ?? PAD;
|
||||
return h(
|
||||
Document,
|
||||
null,
|
||||
h(
|
||||
Page,
|
||||
{ size: { width: o.width ?? PAGE_W, height: o.height ?? 300 }, style: typeof pad === "number" ? { padding: pad } : pad },
|
||||
children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A frame matching `doc()`: uniform padding `PAD`, optional footer reserve inside the bottom padding. */
|
||||
export function frameFor(o: { height?: number; width?: number; footerReserve?: number; continuationReserve?: number } = {}): PageFrame {
|
||||
return computeFrame({
|
||||
page: { ...PAGE_SIZES.A4, width: o.width ?? PAGE_W, height: o.height ?? 300 },
|
||||
margins: { top: PAD, right: PAD, bottom: PAD, left: PAD },
|
||||
footerReserve: o.footerReserve ?? 0,
|
||||
continuationReserve: o.continuationReserve ?? 0,
|
||||
letterMode: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function find(page: LayoutPage, pred: (n: LayoutNode) => boolean): LayoutNode[] {
|
||||
const out: LayoutNode[] = [];
|
||||
walk(page.nodes, (n) => {
|
||||
if (pred(n)) out.push(n);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const byId = (page: LayoutPage, id: string) => find(page, (n) => n.id === id);
|
||||
|
||||
/** react-pdf's own layout tree, for facts the normalised tree hides (style values, raw props). */
|
||||
export async function rawLayout(el: ReactElement): Promise<{ type: string; children: any[] }> {
|
||||
let raw: any;
|
||||
const withHook = cloneElement(el as ReactElement<any>, { onRender: (p: any) => (raw = p._INTERNAL__LAYOUT__DATA_) });
|
||||
await renderToBuffer(withHook as never);
|
||||
return raw;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export interface LintViolation {
|
||||
|
||||
/** 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"],
|
||||
// testing/docs.ts builds throwaway fixture Text styles for tests, not template code.
|
||||
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts", "src/pdf/testing/docs.ts"],
|
||||
absolute: ["src/pdf/decor/", "src/pdf/blocks/PageChrome.tsx", "src/pdf/blocks/FirstPageHeader.tsx"],
|
||||
rawFlags: ["src/pdf/blocks/pdfProps.ts"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
|
||||
/** `@napi-rs/canvas` is an optional dependency; null when its native binary is not installed. */
|
||||
export async function loadCanvas(): Promise<typeof import("@napi-rs/canvas") | null> {
|
||||
try {
|
||||
return await import("@napi-rs/canvas");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of non-white pixels in the box (x0,y0,x1,y1 in pt from the top-left) of page 1 drawn at `scale`. */
|
||||
export async function inkPixels(
|
||||
canvasLib: typeof import("@napi-rs/canvas"),
|
||||
bytes: Uint8Array,
|
||||
box: { x0: number; y0: number; x1: number; y1: number },
|
||||
scale = 2,
|
||||
): Promise<number> {
|
||||
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||
const doc = await task.promise;
|
||||
try {
|
||||
const page = await doc.getPage(1);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = canvasLib.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: ctx as never, canvas: canvas as never, viewport }).promise;
|
||||
const x = Math.floor(box.x0 * scale);
|
||||
const y = Math.floor(box.y0 * scale);
|
||||
const w = Math.ceil((box.x1 - box.x0) * scale);
|
||||
const h = Math.ceil((box.y1 - box.y0) * scale);
|
||||
const data = ctx.getImageData(x, y, w, h).data;
|
||||
let ink = 0;
|
||||
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
|
||||
return ink;
|
||||
} finally {
|
||||
await task.destroy();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user