diff --git a/scripts/hang/scenario.tsx b/scripts/hang/scenario.tsx new file mode 100644 index 0000000..3cf40d7 --- /dev/null +++ b/scripts/hang/scenario.tsx @@ -0,0 +1,55 @@ +// Child-process scenarios for src/pdf/hang.test.ts. Bundled with esbuild and run with node, because +// react-pdf's paginate() is a synchronous loop: an in-process timer never fires while it spins. +import { createElement as h } from "react"; +import { Document, Page, Path, Svg, Text, View } from "@react-pdf/renderer"; +import path from "node:path"; +import { FirstPageHeader } from "../../src/pdf/blocks/FirstPageHeader"; +import { find } from "../../src/pdf/testing/docs"; +import { registerBundledFonts, stack } from "../../src/pdf/fonts/register"; +import { renderCore } from "../../src/pdf/render/core"; + +registerBundledFonts(path.resolve(process.cwd(), "public/fonts")); + +const [scenario, rowsArg] = process.argv.slice(2); +const rows = Number(rowsArg); +const W = 595.28; +const H = 841.89; +const PAD = 40; +const text = { fontFamily: stack("IBM Plex Sans"), fontSize: 10, lineHeight: "12pt" }; + +const flow = Array.from({ length: rows }, (_, i) => + h(View, { key: i, style: { height: 18 } }, h(Text, { style: text }, `Row ${i + 1}`)), +); + +const svg = (extra: Record) => + h( + Svg, + { viewBox: "0 0 100 40", style: { width: W, height: 40, ...extra } }, + h(Path, { d: "M0 0 L100 0 L100 40 L0 40 Z", fill: "#cccccc" }), + ); + +function pageWith(children: unknown[]) { + return h(Document, null, h(Page, { size: { width: W, height: H }, style: { padding: PAD } }, ...(children as never[]))); +} + +const element = + scenario === "page-level-svg" + ? // Non-fixed, absolute, full width, straddling the bottom margin line (H - PAD = 801.89). + pageWith([...flow, svg({ position: "absolute", left: 0, top: H - PAD - 20 })]) + : pageWith([ + h( + FirstPageHeader, + { + key: "head", + pad: { top: PAD, right: PAD, bottom: PAD, left: PAD }, + decor: [{ x: 0, y: 10, width: W, height: 40, children: svg({}) }], + style: { height: 60 }, + }, + h(Text, { style: text }, "Header"), + ), + ...flow, + ]); + +const r = await renderCore(element); +const svgPages = r.layout.map((p) => find(p, (n) => n.type === "svg").length); +console.log(JSON.stringify({ pages: r.pages, svgPages })); diff --git a/src/pdf/blocks/BottomSpacer.tsx b/src/pdf/blocks/BottomSpacer.tsx new file mode 100644 index 0000000..24334e4 --- /dev/null +++ b/src/pdf/blocks/BottomSpacer.tsx @@ -0,0 +1,13 @@ +import { View } from "@react-pdf/renderer"; +import { FILL } from "../engine/columns"; +import { pdfFlags } from "./pdfProps"; + +/** + * Elastic gap before the closing block (amount in words, bank, signatory). It grows to push the closing + * block to the foot of whichever page it ends on. `closingH` is the closing block's height: as + * minPresenceAhead it keeps the spacer from staying on the previous page while the block starts the + * next one (without it the spacer stays behind and the block sits at the top of the new page). + */ +export function BottomSpacer({ closingH }: { closingH: number }) { + return ; +} diff --git a/src/pdf/blocks/FirstPageHeader.tsx b/src/pdf/blocks/FirstPageHeader.tsx new file mode 100644 index 0000000..e042552 --- /dev/null +++ b/src/pdf/blocks/FirstPageHeader.tsx @@ -0,0 +1,52 @@ +import { View } from "@react-pdf/renderer"; +import type { ReactNode } from "react"; +import { DECOR_ID } from "../engine/ids"; +import type { Sides } from "../engine/geometry"; +import { pdfFlags } from "./pdfProps"; + +/** A decorative box placed in PAGE space (pt from the page's top-left corner). */ +export interface DecorItem { + x: number; + y: number; + width: number; + height: number; + children?: ReactNode; +} + +export interface FirstPageHeaderProps { + /** The page padding from the frame: a header that is first in the flow starts at (pad.left, pad.top). */ + pad: Sides; + /** Page-space position of the header's top-left corner; defaults to (pad.left, pad.top). */ + pageOrigin?: { x: number; y: number }; + /** First-page-only decor, in page space. Converted here to offsets from the header's own corner. */ + decor?: DecorItem[]; + style?: Record; + children?: ReactNode; +} + +/** + * First thing in the flow, `wrap={false}`. First-page-only decor (Svg, Image, bands) lives here as + * absolute children instead of at page level: a non-fixed absolute Svg at page level that straddles the + * bottom margin makes react-pdf's paginate() loop forever, or drags all later content to the next page. + * Inside this unbreakable block it is laid out once with the header, on page 1. + * + * Absolute offsets are measured from the header's own corner; the header's padding does not shift them. + */ +export function FirstPageHeader({ pad, pageOrigin, decor = [], style, children }: FirstPageHeaderProps) { + const ox = pageOrigin?.x ?? pad.left; + const oy = pageOrigin?.y ?? pad.top; + return ( + + {decor.map((d, i) => ( + + {d.children} + + ))} + {children} + + ); +} diff --git a/src/pdf/blocks/ItemsTable.tsx b/src/pdf/blocks/ItemsTable.tsx new file mode 100644 index 0000000..4fe5f46 --- /dev/null +++ b/src/pdf/blocks/ItemsTable.tsx @@ -0,0 +1,173 @@ +import { View } from "@react-pdf/renderer"; +import type { ReactNode } from "react"; +import { colsDetailed, type ColSpec } from "../engine/columns"; +import { PlanError } from "../engine/errors"; +import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids"; +import { drawnText, type Measurer } from "../engine/measure"; +import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; +import { AvailableWidthProvider } from "./AvailableWidth"; +import { pdfFlags } from "./pdfProps"; +import { Cell, Row } from "./Row"; +import { Txt } from "./Txt"; + +export interface TableRoles { + head: RoleName | TypeToken; + body: RoleName | TypeToken; +} + +export interface TableLook { + /** Horizontal cell padding, pt per side. Default 0. */ + padX?: number; + /** Vertical cell padding, pt per side. Default 4. */ + padY?: number; + /** Bottom border of each row in pt (counted in the row height). Default 0. */ + border?: number; + faceOverrides?: Readonly>; +} + +export interface ItemsTablePlanInput extends TableLook { + columns: ColSpec[]; + gap: number; + /** Table width in pt (the content width of the page, or of the cell holding the table). */ + width: number; + header: string[]; + rows: string[][]; + measurer: Measurer; + roles: TableRoles; + /** Height of whatever follows the last row and must stay with it (the totals). */ + tailH?: number; + /** Wrap-area height of one page. With `tailH` it lets the planner prove the tail can ever fit. */ + contentH?: number; +} + +export interface ItemsTablePlan { + widths: number[]; + headH: number; + rowHeights: number[]; + firstRowH: number; + /** Predicted line count per row (the tallest cell). */ + rowLines: number[]; +} + +function cellHeight(m: Measurer, role: RoleName | TypeToken, text: string, inner: number, look: TableLook): { lines: number; height: number } { + const t = roleToken(role); + if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan"); + if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan"); + const lines = Math.max( + 1, + m.wrapLines(drawnText(t, text), inner, { + face: resolveFace(t.face, look.faceOverrides), + weight: t.weight, + italic: t.italic ?? false, + size: t.size, + letterSpacing: (t.tracking ?? 0) * t.size, + }).length, + ); + return { lines, height: lines * t.leading }; +} + +/** + * Plans an items table without rendering: column widths, the wrapped height of the header and of + * every row. Heights are estimates from the measurer (see Measurer.wrapLines) and feed + * TableGuard/BottomSpacer presence values. + * + * Throws PlanError("tail-too-tall") when the totals tail could never share a page with the header and + * the last row: `tailH <= contentH - headH - lastRowH`. + */ +export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan { + const { columns, gap, width, header, rows, measurer, roles, tailH, contentH } = input; + const padX = input.padX ?? 0; + const padY = input.padY ?? 4; + const border = input.border ?? 0; + if (header.length !== columns.length) throw new PlanError(`${header.length} header cells for ${columns.length} columns`, "invalid-plan"); + if (rows.length === 0) throw new PlanError("an items table needs at least one row", "invalid-plan"); + for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan"); + + const { widths } = colsDetailed(width, columns, gap); + const inner = widths.map((w) => Math.max(0, w - 2 * padX)); + const heightOf = (role: RoleName | TypeToken, cells: string[]) => { + const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input)); + return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border }; + }; + + const headH = heightOf(roles.head, header).height; + const measured = rows.map((r) => heightOf(roles.body, r)); + const rowHeights = measured.map((r) => r.height); + + if (tailH !== undefined && contentH !== undefined) { + const lastRowH = rowHeights[rowHeights.length - 1]; + if (tailH > contentH - headH - lastRowH + 1e-6) { + throw new PlanError( + `totals tail ${tailH} pt cannot share a ${contentH} pt page with the header (${headH}) and the last row (${lastRowH})`, + "tail-too-tall", + ); + } + } + return { widths, headH, rowHeights, firstRowH: rowHeights[0], rowLines: measured.map((r) => r.lines) }; +} + +export interface TableRowsProps extends TableLook { + columns: ColSpec[]; + gap: number; + width: number; + header: string[]; + rows: string[][]; + roles: TableRoles; + /** Per column; default left. */ + align?: Array<"left" | "right" | "center">; + /** Passed as minPresenceAhead on the last row so the totals never start a page without an item row. */ + tailH?: number; + /** Content after the last row, inside the table container (the totals). */ + tail?: ReactNode; + headerStyle?: Record; + rowStyle?: Record; + borderColor?: string; +} + +/** + * Header (a `fixed` first child, so it repeats on every page the table spans) followed by one + * `wrap={false}` row per item. The container has no padding or border; those go on the rows, because + * the repeated header is positioned against the container's top. + */ +export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, ...look }: TableRowsProps) { + const padX = look.padX ?? 0; + const padY = look.padY ?? 4; + const border = look.border ?? 0; + const { widths } = colsDetailed(width, columns, gap); + const cellStyle = (extra?: Record) => ({ + ...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}), + ...extra, + }); + const cells = (role: RoleName | TypeToken, values: string[]) => + values.map((v, i) => ( + + + {v} + + + )); + + return ( + + + + {cells(roles.head, header)} + + {rows.map((r, i) => ( + + {cells(roles.body, r)} + + ))} + {tail} + + + ); +} diff --git a/src/pdf/blocks/PageChrome.tsx b/src/pdf/blocks/PageChrome.tsx new file mode 100644 index 0000000..a5be520 --- /dev/null +++ b/src/pdf/blocks/PageChrome.tsx @@ -0,0 +1,123 @@ +import { View } from "@react-pdf/renderer"; +import { chromeHeight } from "../engine/metrics"; +import { getMeasurer, measureToken } from "../engine/measure"; +import type { PageFrame } from "../engine/geometry"; +import { BAND_ID, DECOR_ID, PAGE_LABEL_ID } from "../engine/ids"; +import { roleToken, type RoleName, type TypeToken } from "../fonts/roles"; +import { PlanError } from "../engine/errors"; +import type { DecorItem } from "./FirstPageHeader"; +import { pdfFlags } from "./pdfProps"; +import { RenderTxt } from "./RenderTxt"; + +export interface PageChromeProps { + frame: PageFrame; + /** Left footer text (e.g. the invoice number); omit for none. */ + footerLeft?: string; + /** Show "Page n of N" at the right of the footer. Default true. */ + pageLabel?: boolean; + /** Footer and band type; render-prop roles (no leading). Default "footer". */ + role?: RoleName | TypeToken; + color?: string; + /** Continuation band for pages 2+ (shown only when pageNumber > 1). */ + band?: { left?: string; right?: string }; + /** Page-space top of the band. Default: the top of the reserve, pad.top - continuationReserve (>= 0). */ + bandTop?: number; + /** Every-page decor in page space. */ + decor?: DecorItem[]; +} + +/** "Page 3 of 5"; before the total is known (first layout pass) just "Page 3". */ +export function pageLabelText(pageNumber: number, totalPages: number | undefined): string { + return typeof totalPages === "number" && totalPages > 0 ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`; +} + +/** + * Fixed chrome for every page. Put it AFTER the flow as the last child of the Page: with the legacy + * paginator a fixed node placed after the flow still repeats on every page. + * + * Everything is a page-level absolute anchored to the page edges, so none of it takes part in the flow: + * - footer: a row in the bottom margin, between the content bottom (page height - pad.bottom) and the + * physical margin. `frame.footerReserve` must cover chromeHeight(role); both texts are render-prop + * Text in an AUTO-height box (a fixed height smaller than the line draws nothing). + * - continuation band: only on pages 2+. On page 1 its render prop returns "" (0 lines, expected). + * - decor: fixed absolute boxes in page space, flagged with DECOR_ID. + * + * The page label has a pinned width taken from the widest label ("Page 99 of 99"), never from the + * current pass, because `totalPages` is undefined on the first pass and `subPageNumber` in the split pass. + */ +export function PageChrome({ + frame, + footerLeft, + pageLabel = true, + role = "footer", + color, + band, + bandTop, + decor = [], +}: PageChromeProps) { + const { pad } = frame; + const need = chromeHeight(role); + const showFooter = pageLabel || footerLeft !== undefined; + if (showFooter && frame.footerReserve < need) { + throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve"); + } + const labelWidth = Math.ceil(measureToken(getMeasurer(), role, "Page 99 of 99")) + 2; + const marginBottom = pad.bottom - frame.footerReserve; + const bandRightWidth = band?.right !== undefined ? Math.ceil(measureToken(getMeasurer(), role, band.right)) + 2 : 0; + const top = bandTop ?? Math.max(0, pad.top - frame.continuationReserve); + + return ( + <> + {decor.map((d, i) => ( + + {d.children} + + ))} + + {band && (band.left !== undefined || band.right !== undefined) && ( + + {band.left !== undefined && ( + (pageNumber > 1 ? (band.left ?? "") : "")} /> + )} + {band.right !== undefined && ( + (pageNumber > 1 ? (band.right ?? "") : "")} + /> + )} + + )} + + {showFooter && ( + + {footerLeft !== undefined && footerLeft} />} + {pageLabel && ( + pageLabelText(pageNumber, totalPages)} + /> + )} + + )} + + ); +} diff --git a/src/pdf/blocks/RenderTxt.tsx b/src/pdf/blocks/RenderTxt.tsx index 0e370cd..f87e558 100644 --- a/src/pdf/blocks/RenderTxt.tsx +++ b/src/pdf/blocks/RenderTxt.tsx @@ -8,6 +8,8 @@ export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput { /** Receives { pageNumber, totalPages, subPageNumber, ... } from react-pdf. */ render: (info: { pageNumber: number; totalPages: number; subPageNumber: number; subPageTotalPages: number }) => string; role: RoleName | TypeToken; + /** react-pdf copies the id into the layout tree (the audit finds chrome nodes by it). */ + id?: string; width?: number; grow?: boolean; style?: Record; @@ -20,7 +22,7 @@ export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput { * metrics, never a fixed height. No maxLines: a clamped render-prop Text draws nothing. * `totalPages` is undefined on the first layout pass, so the render function must cope with that. */ -export function RenderTxt({ role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) { +export function RenderTxt({ id, role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) { const token = roleToken(role); assertNoLineHeight(style, "RenderTxt"); const merged = { @@ -29,5 +31,5 @@ export function RenderTxt({ role, render, color, align, weight, italic, width, g ...(grow ? FILL : {}), ...style, }; - return ; + return ; } diff --git a/src/pdf/blocks/Row.tsx b/src/pdf/blocks/Row.tsx index 63a6233..5471de0 100644 --- a/src/pdf/blocks/Row.tsx +++ b/src/pdf/blocks/Row.tsx @@ -5,6 +5,8 @@ import { AvailableWidthProvider, useAvailableWidth } from "./AvailableWidth"; import { pdfFlags, type PdfFlagsInput } from "./pdfProps"; export interface RowProps extends PdfFlagsInput { + /** Copied into the layout tree (audits find rows by it). */ + id?: string; /** One spec per child, in order. */ widths: ColSpec[]; gap?: number; @@ -24,7 +26,7 @@ export interface RowProps extends PdfFlagsInput { * cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is * narrower than `stackBelow`), the children are stacked at full width instead. */ -export function Row({ widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) { +export function Row({ id, widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) { const inherited = useAvailableWidth(); const total = width ?? inherited; const kids = Children.toArray(children); @@ -32,7 +34,7 @@ export function Row({ widths, gap = 0, stackBelow, align = "flex-start", width, const { widths: w, ok } = colsDetailed(total, widths, gap); const stacked = !ok || (stackBelow !== undefined && total < stackBelow); - const flags = pdfFlags({ fixed, wrap, minPresenceAhead }); + const flags = { ...(id !== undefined ? { id } : {}), ...pdfFlags({ fixed, wrap, minPresenceAhead }) }; if (stacked) { return ( diff --git a/src/pdf/blocks/TableGuard.tsx b/src/pdf/blocks/TableGuard.tsx new file mode 100644 index 0000000..50217d2 --- /dev/null +++ b/src/pdf/blocks/TableGuard.tsx @@ -0,0 +1,19 @@ +import { View } from "@react-pdf/renderer"; +import { pdfFlags } from "./pdfProps"; + +/** + * Presence to ask for in front of a table: the header plus the first row, with the first row capped at + * three body lines so an oversize first row cannot push the table to a new page every time. + */ +export function guardPresence(headH: number, firstRowH: number, bodyLeading: number): number { + return headH + Math.min(firstRowH, 3 * bodyLeading); +} + +/** + * Zero-height sentinel placed immediately before a table. react-pdf's `minPresenceAhead` measures from + * the element's bottom, so this moves itself (and with it the whole table) to the next page unless the + * header and the first rows fit below it. Without it the repeated header can stay alone at a page foot. + */ +export function TableGuard({ minPresenceAhead }: { minPresenceAhead: number }) { + return ; +} diff --git a/src/pdf/blocks/pagination.test.tsx b/src/pdf/blocks/pagination.test.tsx new file mode 100644 index 0000000..09a6085 --- /dev/null +++ b/src/pdf/blocks/pagination.test.tsx @@ -0,0 +1,206 @@ +import { createElement as h } from "react"; +import { Path, Svg, View } from "@react-pdf/renderer"; +import { describe, expect, it } from "vitest"; +import { chromeHeight } from "../engine/metrics"; +import { PlanError } from "../engine/errors"; +import { auditLayout } from "../engine/audit"; +import { computeFrame, PAGE_SIZES } from "../engine/geometry"; +import { BAND_ID, DECOR_ID, PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids"; +import { getMeasurer } from "../engine/measure"; +import { TYPE_ROLES } from "../fonts/roles"; +import { renderCore } from "../render/core"; +import { byId, doc, find, frameFor, PAD } from "../testing/docs"; +import { setupPdfTest } from "../testing/setup"; +import { AvailableWidthProvider } from "./AvailableWidth"; +import { FirstPageHeader } from "./FirstPageHeader"; +import { planItemsTable, TableRows } from "./ItemsTable"; +import { PageChrome, pageLabelText } from "./PageChrome"; +import { guardPresence } from "./TableGuard"; + +setupPdfTest(); + +const rows = (n: number) => Array.from({ length: n }, (_, i) => h(View, { key: i, style: { height: 20 } })); + +describe("FirstPageHeader", () => { + const pad = { top: PAD, right: PAD, bottom: PAD, left: PAD }; + const decor = [{ x: 300, y: 8, width: 80, height: 40, children: h(Svg, { viewBox: "0 0 10 10", style: { width: 80, height: 40 } }, h(Path, { d: "M0 0 L10 0 L10 10 Z", fill: "#123456" })) }]; + const page = (n: number) => + doc([h(FirstPageHeader, { key: "h", pad, decor, style: { height: 60 } }, h(View, { style: { height: 10 } })), ...rows(n)]); + + it("places absolute decor at the requested PAGE-space position, flagged as decor", async () => { + const r = await renderCore(page(8)); + const d = byId(r.layout[0], DECOR_ID)[0]; + expect(d.box.x).toBeCloseTo(300, 1); + expect(d.box.y).toBeCloseTo(8, 1); + expect(d.box.w).toBe(80); + expect(d.decor).toBe(true); + expect(find(r.layout[0], (n) => n.type === "svg" && !!n.decor)).toHaveLength(1); + }); + + it("keeps the decor on page 1 only, over a multi-page flow", async () => { + const r = await renderCore(page(30)); + expect(r.pages).toBeGreaterThan(1); + expect(r.layout.map((p) => find(p, (n) => n.type === "svg").length)).toEqual(r.layout.map((_, i) => (i === 0 ? 1 : 0))); + }); + + it("honours an explicit pageOrigin", async () => { + const el = doc([ + h(View, { key: "gap", style: { height: 30 } }), + h(FirstPageHeader, { key: "h", pad, pageOrigin: { x: PAD, y: 50 }, decor: [{ x: 100, y: 60, width: 20, height: 20 }], style: { height: 40 } }), + ]); + const d = byId((await renderCore(el)).layout[0], DECOR_ID)[0]; + expect([d.box.x, d.box.y]).toEqual([100, 60]); + }); +}); + +describe("PageChrome", () => { + const FOOTER = chromeHeight("footer"); + const frame = frameFor({ footerReserve: FOOTER, continuationReserve: 14 }); + const padding = { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + FOOTER }; + const el = () => + doc( + [ + ...rows(40), + h(PageChrome, { key: "chrome", frame, footerLeft: "INV-0001", band: { left: "Invoice INV-0001", right: "continued" }, decor: [{ x: 0, y: 0, width: 400, height: 6 }] }), + ], + { pad: padding }, + ); + + it("draws 'Page k of N' and the footer on every page, the band only from page 2, and audits clean", async () => { + const r = await renderCore(el(), { frame }); + expect(r.pages).toBeGreaterThan(2); + r.layout.forEach((p, i) => { + expect(byId(p, PAGE_LABEL_ID)[0].lines!.map((l) => l.text)).toEqual([`Page ${i + 1} of ${r.pages}`]); + const band = byId(p, BAND_ID).flatMap((n) => n.lines!.map((l) => l.text)); + expect(band).toEqual(i === 0 ? [] : ["Invoice INV-0001", "continued"]); + expect(byId(p, DECOR_ID)).toHaveLength(1); + }); + expect(r.issues).toEqual([]); + }); + + it("puts the footer line inside the reserved strip below the content area", async () => { + const r = await renderCore(el()); + const label = byId(r.layout[0], PAGE_LABEL_ID)[0]; + const contentBottom = 300 - frame.pad.bottom; + expect(label.box.y).toBeGreaterThanOrEqual(contentBottom - 0.01); + expect(label.box.y + label.box.h).toBeLessThanOrEqual(300 - PAD + 0.01); + }); + + it("rejects a footer reserve smaller than the line", () => { + const tight = frameFor({ footerReserve: 4 }); + expect(() => PageChrome({ frame: tight })).toThrow(PlanError); + }); + + it("copes with totalPages being undefined on the first pass", () => { + expect(pageLabelText(2, undefined)).toBe("Page 2"); + expect(pageLabelText(2, 0)).toBe("Page 2"); + expect(pageLabelText(2, 5)).toBe("Page 2 of 5"); + }); +}); + +describe("planItemsTable / TableRows", () => { + const WIDTH = 360; + const columns = [{ fill: true as const }, { fixed: 60 }]; + const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody }; + const body = [ + ["Short", "1"], + ["A much longer item description that has to wrap onto several lines in a narrow description column", "2"], + ["Mid length item", "3"], + ]; + const plan = () => planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 }); + + it("allocates the columns exactly and sizes rows from wrapped lines", () => { + const p = plan(); + expect(p.widths.reduce((a, b) => a + b, 0) + 8).toBeCloseTo(WIDTH, 2); + expect(p.rowLines[0]).toBe(1); + expect(p.rowLines[1]).toBeGreaterThan(1); + expect(p.rowHeights[0]).toBeCloseTo(13.8 + 8 + 0.5, 5); + expect(p.firstRowH).toBe(p.rowHeights[0]); + expect(p.headH).toBeCloseTo(10 + 8 + 0.5, 5); + }); + + it("matches the heights react-pdf lays out", async () => { + const p = plan(); + const el = doc( + h(AvailableWidthProvider, { width: WIDTH }, h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, roles, padY: 4, border: 0.5 })), + ); + const r = await renderCore(el); + const page = r.layout[0]; + expect(byId(page, TABLE_HEAD_ID)[0].box.h).toBeCloseTo(p.headH, 1); + body.forEach((_, i) => expect(byId(page, `${TABLE_ROW_PREFIX}${i}`)[0].box.h).toBeCloseTo(p.rowHeights[i], 1)); + }); + + it("throws a typed PlanError when the totals tail can never share a page with the header and last row", () => { + const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 }; + const p = plan(); + const lastRow = p.rowHeights[p.rowHeights.length - 1]; + const ok = p.headH + lastRow + 100; + expect(() => planItemsTable({ ...base, tailH: 100, contentH: ok })).not.toThrow(); + try { + planItemsTable({ ...base, tailH: 100, contentH: ok - 1 }); + expect.unreachable(); + } catch (e) { + expect(e).toBeInstanceOf(PlanError); + expect((e as PlanError).code).toBe("tail-too-tall"); + } + }); + + it("rejects malformed plans", () => { + const base = { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], measurer: getMeasurer(), roles }; + expect(() => planItemsTable({ ...base, rows: [] })).toThrow(PlanError); + expect(() => planItemsTable({ ...base, rows: [["only one"]] })).toThrow(PlanError); + expect(() => planItemsTable({ ...base, rows: [["a\nb", "1"]] })).toThrow(PlanError); + }); + + it("the last row carries the tail's minPresenceAhead: totals never start a page without an item row", async () => { + const many = Array.from({ length: 24 }, (_, i) => [`Item ${i + 1}`, `${i + 1}`]); + const p = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: many, measurer: getMeasurer(), roles, padY: 4 }); + const TAIL = 60; + // Fill page 1 so the last row would land right at the foot with the tail not fitting after it. + const build = (tailH?: number) => + doc( + h( + AvailableWidthProvider, + { width: WIDTH }, + h( + TableRows, + { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: many, roles, padY: 4, tailH, tail: h(View, { id: "totals", style: { height: TAIL }, wrap: false }) }, + ), + ), + { height: Math.ceil(PAD * 2 + p.headH + p.rowHeights.reduce((a, b) => a + b, 0) + 10) }, + ); + const withTail = await renderCore(build(TAIL)); + const totalsPage = withTail.layout.findIndex((pg) => byId(pg, "totals").length > 0); + const rowsOnTotalsPage = find(withTail.layout[totalsPage], (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX)).length; + expect(rowsOnTotalsPage).toBeGreaterThan(0); + + const without = await renderCore(build(undefined)); + const wp = without.layout.findIndex((pg) => byId(pg, "totals").length > 0); + expect(find(without.layout[wp], (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX))).toHaveLength(0); + }); + + it("guardPresence caps the first row at three body lines", () => { + expect(guardPresence(18, 20, 13.8)).toBeCloseTo(38, 5); + expect(guardPresence(18, 200, 13.8)).toBeCloseTo(18 + 3 * 13.8, 5); + }); +}); + +describe("audit of a full block composition", () => { + it("a framed page with chrome and table is clean", async () => { + const FOOTER = chromeHeight("footer"); + const frame = computeFrame({ page: { ...PAGE_SIZES.A4, width: 400, height: 300 }, margins: { top: PAD, right: PAD, bottom: PAD, left: PAD }, footerReserve: FOOTER, continuationReserve: 14, letterMode: false }); + const WIDTH = frame.contentWidth; + const cols = [{ fill: true as const }, { fixed: 50 }]; + const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody }; + const items = Array.from({ length: 40 }, (_, i) => [`Item number ${i + 1}`, `${i + 1}`]); + const el = doc( + [ + h(AvailableWidthProvider, { key: "t", width: WIDTH }, h(TableRows, { columns: cols, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: items, roles, padY: 3 })), + h(PageChrome, { key: "c", frame, footerLeft: "INV-1", band: { left: "INV-1", right: "continued" } }), + ], + { pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + FOOTER } }, + ); + const r = await renderCore(el); + expect(auditLayout({ pages: r.layout }, frame, {})).toEqual([]); + }); +}); diff --git a/src/pdf/engine/audit.test.tsx b/src/pdf/engine/audit.test.tsx new file mode 100644 index 0000000..2536e0d --- /dev/null +++ b/src/pdf/engine/audit.test.tsx @@ -0,0 +1,276 @@ +import { createElement as h, type ReactNode } from "react"; +import { Image, Svg, Text, View } from "@react-pdf/renderer"; +import { describe, expect, it } from "vitest"; +import { BottomSpacer } from "../blocks/BottomSpacer"; +import { PageChrome } from "../blocks/PageChrome"; +import { RenderTxt } from "../blocks/RenderTxt"; +import { Tracked } from "../blocks/Tracked"; +import { AvailableWidthProvider } from "../blocks/AvailableWidth"; +import { renderCore } from "../render/core"; +import { byId, doc, frameFor, PAD, txt } from "../testing/docs"; +import { setupPdfTest } from "../testing/setup"; +import { auditLayout, type AuditCode, type AuditOptions } from "./audit"; +import { BAND_ID, DECOR_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./ids"; + +setupPdfTest(); + +const FRAME = frameFor(); + +/** Renders `el` and audits it against `frame`; returns the issue codes (and the issues themselves). */ +async function audit(el: ReturnType, opts: AuditOptions = {}, frame = FRAME) { + const r = await renderCore(el); + const issues = auditLayout({ pages: r.layout }, frame, opts); + return { codes: issues.map((i) => i.code) as AuditCode[], issues, r }; +} + +const LONG = "Aurora Industrial Fasteners and Precision Components Private Limited, Plot 14, Phase 2, Peenya Industrial Area, Bengaluru, Karnataka"; +const abs = (style: Record) => ({ position: "absolute" as const, ...style }); + +describe("auditLayout", () => { + describe("overlap", () => { + it("flags two text nodes whose line boxes intersect", async () => { + const el = doc([ + h(Text, { key: "a", style: txt() }, "Vendor address line"), + h(Text, { key: "b", style: txt({ ...abs({ top: 22, left: 40 }) }) }, "INVOICE"), + ]); + const { codes, issues } = await audit(el); + expect(codes).toContain("overlap"); + expect(issues.find((i) => i.code === "overlap")!.box).toBeDefined(); + }); + + it("passes stacked text, and ignores a text overlapping decor", async () => { + expect((await audit(doc([h(Text, { key: "a", style: txt() }, "One"), h(Text, { key: "b", style: txt() }, "Two")]))).codes).toEqual([]); + const withDecor = doc([ + h(Text, { key: "a", style: txt() }, "Vendor address line"), + h(View, { key: "d", id: DECOR_ID, style: abs({ top: 20, left: 20 }) }, h(Text, { style: txt() }, "Vendor address line")), + ]); + expect((await audit(withDecor)).codes).toEqual([]); + }); + }); + + describe("overflow-x", () => { + const row = (fill: Record) => + doc( + h( + View, + { style: { flexDirection: "row", width: 360 } }, + h(Text, { style: txt(fill) }, LONG), + h(View, { style: { width: 180, minWidth: 180, maxWidth: 180 } }, h(Text, { style: txt() }, "side")), + ), + ); + + it("flags text whose stale line breaks run wider than its final box", async () => { + const { codes, issues } = await audit(row({ flexGrow: 1 })); + expect(codes).toContain("overflow-x"); + expect(issues.some((i) => i.code === "overlap")).toBe(true); + }); + + it("passes the same row with flexBasis 0", async () => { + expect((await audit(row({ flexGrow: 1, flexBasis: 0 }))).codes).toEqual([]); + }); + + it("measures ink: tracked right-aligned text may overhang its box by the trailing letterSpacing", async () => { + const el = doc( + h( + AvailableWidthProvider, + { width: 200 }, + h(View, { style: { width: 200 } }, h(Tracked, { role: "title", align: "right" }, "Invoice")), + ), + ); + const { codes, r } = await audit(el); + expect(codes).toEqual([]); + // The box does overhang the page margin by one tracking step; the ink does not. + const t = r.layout[0].nodes[0].children[0]; + expect(t.box.x + t.box.w).toBeGreaterThan(PAD + 200 + 0.5); + }); + + it("flags ink beyond the page margin box", async () => { + const el = doc(h(Text, { style: txt({ width: 500 }) }, "Short"), { pad: PAD }); + expect((await audit(doc(h(Text, { style: txt({ ...abs({ left: 340 }) }) }, "Overruns the right margin")))).codes).toContain("overflow-x"); + expect((await audit(el)).codes).toEqual([]); + }); + }); + + describe("outside-safe-zone", () => { + it("flags a non-decor, non-fixed node outside the margin box", async () => { + const el = doc(h(View, { id: "stray", style: abs({ top: 2, left: 2, width: 30, height: 10 }) })); + const { codes, issues } = await audit(el); + expect(codes).toContain("outside-safe-zone"); + expect(issues[0].message).toContain("#stray"); + }); + + it("allows decor anywhere and fixed chrome inside the page edge, but not beyond it", async () => { + const decor = doc(h(View, { id: DECOR_ID, style: abs({ top: -5, left: -5, width: 30, height: 10 }) })); + expect((await audit(decor)).codes).toEqual([]); + const chrome = (top: number) => doc(h(View, { fixed: true, style: abs({ top, left: 4, width: 30, height: 10 }) })); + expect((await audit(chrome(2))).codes).toEqual([]); + expect((await audit(chrome(-4))).codes).toContain("outside-safe-zone"); + }); + }); + + describe("below-wrap-area", () => { + it("flags non-fixed content that ends under the content bottom", async () => { + // The page really has no bottom padding, but the frame says the wrap area ends 20 pt up. + const el = doc(h(View, { id: "tall", style: { height: 285 } }), { pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: 0 } }); + const { codes, issues } = await audit(el); + expect(codes).toContain("below-wrap-area"); + expect(issues.find((i) => i.code === "below-wrap-area")!.message).toContain("#tall"); + }); + + it("passes content inside the wrap area, fixed chrome below it and decor below it", async () => { + const el = doc([ + h(View, { key: "a", style: { height: 100 } }), + h(View, { key: "b", fixed: true, style: abs({ bottom: 4, left: PAD, width: 40, height: 10 }) }), + h(View, { key: "c", id: DECOR_ID, style: abs({ bottom: 0, left: PAD, width: 40, height: 10 }) }), + ]); + expect((await audit(el)).codes).toEqual([]); + }); + }); + + describe("text-truncated", () => { + const name = "Aurora Industrial Fasteners and Precision Components Private Limited of Peenya"; + const box = (sizing: Record, style: Record = {}) => + doc(h(View, { style: { width: 100, ...sizing } }, h(Text, { style: txt(style) }, name))); + + it("flags lines lost to a pinned height, with the unexpected ellipsis noted", async () => { + const { issues } = await audit(box({ height: 33 })); + const t = issues.find((i) => i.code === "text-truncated")!; + expect(t.severity).toBe("error"); + expect(t.message).toContain("unexpected ellipsis"); + }); + + it("passes a minHeight box, and text that really contains an ellipsis", async () => { + expect((await audit(box({ minHeight: 33 }))).codes).toEqual([]); + expect((await audit(doc(h(Text, { style: txt() }, "Wait… what")))).codes).toEqual([]); + }); + + it("downgrades an authored maxLines clamp to a warning", async () => { + const { issues } = await audit(box({}, { maxLines: 2, textOverflow: "ellipsis" })); + expect(issues.filter((i) => i.code === "text-truncated").map((i) => i.severity)).toEqual(["warn"]); + }); + + it("compares through whitespace and caps: uppercase transform and trailing spaces are not truncation", async () => { + expect((await audit(doc(h(Text, { style: txt({ textTransform: "uppercase", width: 60 }) }, "tax invoice for goods")))).codes).toEqual([]); + }); + }); + + describe("render-text-empty", () => { + const role = { face: "IBM Plex Sans" as const, weight: 400 as const, size: 8 }; + const chrome = (viewStyle: Record) => + doc([ + h(View, { key: "x", style: { height: 400 } }), + h(View, { key: "c", fixed: true, style: abs({ bottom: 4, left: PAD, width: 100, ...viewStyle }) }, h(RenderTxt, { role, render: ({ pageNumber }) => `Page ${pageNumber}` })), + ]); + + it("flags render-prop text that drew 0 lines in a too-short fixed box", async () => { + expect((await audit(chrome({ height: 10 }))).codes).toContain("render-text-empty"); + }); + + it("passes the same text in an auto-height box", async () => { + expect((await audit(chrome({}))).codes).toEqual([]); + }); + + it("lets the continuation band be empty on page 1 only", async () => { + const el = doc( + [ + h(View, { key: "x", style: { height: 600 } }), + h(PageChrome, { key: "chrome", frame: frameFor({ footerReserve: 14 }), pageLabel: false, band: { left: "Invoice INV-1", right: "continued" } }), + ], + { pad: { paddingTop: PAD, paddingLeft: PAD, paddingRight: PAD, paddingBottom: PAD + 14 } }, + ); + const { codes, r } = await audit(el, {}, frameFor({ footerReserve: 14 })); + expect(r.pages).toBeGreaterThan(1); + expect(codes).toEqual([]); + expect(byId(r.layout[0], BAND_ID).every((n) => n.lines!.length === 0)).toBe(true); + expect(byId(r.layout[1], BAND_ID).every((n) => n.lines!.length > 0)).toBe(true); + // The same empty text on page 2 would be an error: a custom id list does not excuse it. + const strict = auditLayout({ pages: r.layout }, frameFor({ footerReserve: 14 }), { optionalFirstPageIds: [] }); + expect(strict.map((i) => i.code)).toContain("render-text-empty"); + }); + }); + + describe("header-only-page", () => { + const table = (fillerH: number) => + doc([ + h(View, { key: "f", style: { height: fillerH } }), + h( + View, + { key: "t" }, + h(View, { key: "h", id: TABLE_HEAD_ID, fixed: true, style: { height: 10 } }), + ...Array.from({ length: 12 }, (_, i) => h(View, { key: i, id: `${TABLE_ROW_PREFIX}${i}`, style: { height: 20 }, wrap: false })), + ), + ]); + + it("flags a page whose only table content is the repeated header", async () => { + const { issues } = await audit(table(250)); + expect(issues.find((i) => i.code === "header-only-page")!.page).toBe(1); + }); + + it("passes when every page with the header has rows", async () => { + expect((await audit(table(100))).codes).toEqual([]); + }); + }); + + describe("keepout", () => { + const el = () => doc([h(Text, { key: "a", style: txt() }, "Near the corner"), h(Text, { key: "b", style: txt() }, "Second")]); + it("flags text lines entering a keep-out rectangle", async () => { + const { issues } = await audit(el(), { keepOuts: [{ x: 0, y: 0, w: 120, h: 30 }] }); + expect(issues.filter((i) => i.code === "keepout").length).toBeGreaterThan(0); + }); + + it("passes content clear of the rectangle, a rectangle on another page, and decor inside it", async () => { + expect((await audit(el(), { keepOuts: [{ x: 200, y: 100, w: 100, h: 100 }] })).codes).toEqual([]); + expect((await audit(el(), { keepOuts: [{ x: 0, y: 0, w: 120, h: 30, page: 2 }] })).codes).toEqual([]); + const decor = doc(h(Svg, { id: DECOR_ID, style: abs({ top: 20, left: 20, width: 50, height: 20 }) })); + expect((await audit(decor, { keepOuts: [{ x: 0, y: 0, w: 120, h: 60 }] })).codes).toEqual([]); + }); + + it("also checks images and Svg", async () => { + const png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const withImage = doc(h(Image, { src: png, style: { width: 40, height: 20 } })); + expect((await audit(withImage, { keepOuts: [{ x: 0, y: 0, w: 120, h: 60 }] })).codes).toContain("keepout"); + }); + }); + + describe("split-block", () => { + // Four 60 pt rows fill the wrap area to 260 of 280; the fifth straddles the foot. + const rows = (fifth: Record) => + doc([0, 1, 2, 3, 4].map((i) => h(View, { key: i, id: `row-${i}`, style: { height: 60 }, ...(i === 4 ? fifth : { wrap: false }) }, h(Text, { style: txt() }, `R${i}`)))); + + it("flags a wrap={false} block that appears on two pages", async () => { + const { issues } = await audit(rows({ wrap: false, fixed: false })); + expect(issues.find((i) => i.code === "split-block")!.message).toContain("#row-4"); + }); + + it("passes a wrap={false} block that moved whole, and ignores fixed chrome repeating on every page", async () => { + expect((await audit(rows({ wrap: false }))).codes).toEqual([]); + const el = doc([ + ...Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } })), + h(View, { key: "f", id: "footer", fixed: true, style: abs({ bottom: 4, left: PAD, width: 20, height: 10 }), wrap: false }), + ]); + expect((await audit(el)).codes).toEqual([]); + }); + }); + + describe("closing-not-at-bottom", () => { + const body = (spacer: ReactNode) => + doc( + [ + ...Array.from({ length: 16 }, (_, i) => h(View, { key: i, style: { height: 20 } })), + spacer, + h(View, { key: "closing", id: "closing", style: { height: 50 }, wrap: false }, h(Text, { style: txt() }, "Amount in words")), + ], + ); + + it("flags a closing block that is not at the wrap-area bottom of the last page", async () => { + const { issues } = await audit(body(null), { closingId: "closing" }); + expect(issues.find((i) => i.code === "closing-not-at-bottom")!.page).toBe(2); + }); + + it("passes with a BottomSpacer, and reports a closing block missing from the last page", async () => { + expect((await audit(body(h(BottomSpacer, { key: "sp", closingH: 50 })), { closingId: "closing" })).codes).toEqual([]); + const { issues } = await audit(body(null), { closingId: "nope" }); + expect(issues.find((i) => i.code === "closing-not-at-bottom")!.message).toContain("not on the last page"); + }); + }); +}); diff --git a/src/pdf/engine/audit.ts b/src/pdf/engine/audit.ts new file mode 100644 index 0000000..820a50a --- /dev/null +++ b/src/pdf/engine/audit.ts @@ -0,0 +1,226 @@ +import type { PageFrame } from "./geometry"; +import { BAND_ID, DECOR_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./ids"; +import { walk, type LayoutLine, type LayoutNode, type LayoutPage, type Rect } from "./layoutTree"; + +export type AuditCode = + | "overlap" + | "overflow-x" + | "outside-safe-zone" + | "below-wrap-area" + | "text-truncated" + | "render-text-empty" + | "header-only-page" + | "keepout" + | "split-block" + | "closing-not-at-bottom"; + +export interface AuditIssue { + code: AuditCode; + severity: "error" | "warn"; + /** 1-based. */ + page: number; + message: string; + box?: Rect; +} + +export interface AuditOptions { + /** Rectangles (page space) that no content may touch; `page` limits one to a single 1-based page. */ + keepOuts?: Array; + /** Id of the closing block; its bottom must sit on the wrap-area bottom of the last page. */ + closingId?: string; + /** Render-prop text ids that may draw nothing on page 1 (the continuation band). */ + optionalFirstPageIds?: string[]; + /** Header row id and body row id prefix for header-only-page. */ + headId?: string; + rowPrefix?: string; +} + +/** Overlaps and overruns smaller than this are rounding noise. */ +const TOL = 0.5; + +const right = (r: Rect) => r.x + r.w; +const bottom = (r: Rect) => r.y + r.h; +const intersects = (a: Rect, b: Rect, tol = TOL) => + Math.min(right(a), right(b)) - Math.max(a.x, b.x) > tol && Math.min(bottom(a), bottom(b)) - Math.max(a.y, b.y) > tol; + +/** Where the glyphs really are: the advance minus the letterSpacing step that follows the last glyph. */ +function inkRect(line: LayoutLine, node: LayoutNode): Rect { + const ls = node.letterSpacing ?? 0; + return { x: line.x, y: line.y, w: Math.max(0, line.w - ls), h: line.h }; +} + +const squash = (s: string) => s.replace(/[\u00ad\u2028\u2029\u2060]/g, "").replace(/\s+/g, ""); + +const fmt = (r: Rect) => `[x ${r.x.toFixed(1)}..${right(r).toFixed(1)}, y ${r.y.toFixed(1)}..${bottom(r).toFixed(1)}]`; +const label = (n: LayoutNode) => `${n.id ? `#${n.id} ` : ""}${n.type}${n.text ? ` "${n.text.slice(0, 40)}"` : ""}`; + +/** + * Checks the laid-out document against the frame. Pure: reads only the normalised tree. Every check + * ignores decor (DECOR_ID subtrees) unless noted. + */ +export function auditLayout(layout: { pages: LayoutPage[] }, frame: PageFrame, opts: AuditOptions = {}): AuditIssue[] { + const issues: AuditIssue[] = []; + const optionalFirst = new Set(opts.optionalFirstPageIds ?? [BAND_ID]); + const headId = opts.headId ?? TABLE_HEAD_ID; + const rowPrefix = opts.rowPrefix ?? TABLE_ROW_PREFIX; + const { pad } = frame; + const marginBottom = pad.bottom - frame.footerReserve; + + layout.pages.forEach((page, idx) => { + const pageNo = idx + 1; + const add = (code: AuditCode, message: string, box?: Rect, severity: "error" | "warn" = "error") => + issues.push({ code, severity, page: pageNo, message, ...(box ? { box } : {}) }); + + const margin: Rect = { x: pad.left, y: pad.top, w: page.width - pad.left - pad.right, h: page.height - pad.top - marginBottom }; + const pageRect: Rect = { x: 0, y: 0, w: page.width, h: page.height }; + const contentBottom = page.height - pad.bottom; + + const all: Array<{ node: LayoutNode; parent: LayoutNode | undefined }> = []; + walk(page.nodes, (node, parent) => all.push({ node, parent })); + const live = all.filter((e) => !e.node.decor); + + // overlap: lines of two different text nodes. + const lines: Array<{ node: LayoutNode; line: LayoutLine }> = []; + for (const { node } of live) { + if (node.type !== "text") continue; + for (const line of node.lines ?? []) if (line.text.trim() !== "") lines.push({ node, line }); + } + for (let i = 0; i < lines.length; i++) { + for (let j = i + 1; j < lines.length; j++) { + if (lines[i].node === lines[j].node) continue; + const a = inkRect(lines[i].line, lines[i].node); + const b = inkRect(lines[j].line, lines[j].node); + if (intersects(a, b)) { + add("overlap", `"${lines[i].line.text}" ${fmt(a)} overlaps "${lines[j].line.text}" ${fmt(b)}`, a); + } + } + } + + for (const { node, parent } of live) { + if (node.type === "text") { + const ink = (node.lines ?? []).map((l) => inkRect(l, node)).filter((r) => r.w > 0); + + // overflow-x: ink past its own box, its container, or the page margin box. + for (const [i, r] of ink.entries()) { + const ln = node.lines![i]; + const text = `"${ln.text}"`; + if (right(r) > right(node.box) + TOL || r.x < node.box.x - TOL) { + add("overflow-x", `${text} ink ${fmt(r)} runs outside its own box ${fmt(node.box)} (stale line breaks?)`, r); + } else if (parent && parent.type === "view" && (right(r) > right(parent.box) + TOL || r.x < parent.box.x - TOL)) { + add("overflow-x", `${text} ink ${fmt(r)} runs outside its container ${fmt(parent.box)}`, r); + } else if (right(r) > right(margin) + TOL || r.x < margin.x - TOL) { + add("overflow-x", `${text} ink ${fmt(r)} runs outside the margin box ${fmt(margin)}`, r); + } + } + + // text-truncated + if (!node.render && node.text !== undefined) { + const want = squash(node.text); + const got = squash((node.lines ?? []).map((l) => l.text).join(" ")); + if (want !== got) { + const ellipsis = got.includes("…") && !want.includes("…"); + add( + "text-truncated", + `${label(node)} drew "${(node.lines ?? []).map((l) => l.text).join(" ")}"${ellipsis ? " (unexpected ellipsis)" : ""}`, + node.box, + node.maxLines ? "warn" : "error", + ); + } + } + + // render-text-empty + if (node.render && ink.length === 0 && !(pageNo === 1 && node.id !== undefined && optionalFirst.has(node.id))) { + add("render-text-empty", `render-prop text ${label(node)} drew 0 lines (a pinned height, or a lineHeight on an ancestor?)`, node.box); + } + } + + // outside-safe-zone: text by its ink, everything else by its box. + const extent: Rect | null = + node.type === "text" + ? (() => { + const rs = (node.lines ?? []).map((l) => inkRect(l, node)).filter((r) => r.w > 0); + if (rs.length === 0) return null; + const x0 = Math.min(...rs.map((r) => r.x)); + const y0 = Math.min(...rs.map((r) => r.y)); + return { x: x0, y: y0, w: Math.max(...rs.map(right)) - x0, h: Math.max(...rs.map(bottom)) - y0 }; + })() + : node.box; + if (extent) { + // Chrome (fixed) may use the page edge; everything else stays inside the margins. + const zone = node.fixed ? pageRect : margin; + if (extent.x < zone.x - TOL || extent.y < zone.y - TOL || right(extent) > right(zone) + TOL || bottom(extent) > bottom(zone) + TOL) { + add("outside-safe-zone", `${label(node)} ${fmt(extent)} is outside ${node.fixed ? "the page" : "the margin box"} ${fmt(zone)}`, extent); + } + } + + // keepout: text lines, images and svg only (containers span their children). + for (const k of opts.keepOuts ?? []) { + if (k.page !== undefined && k.page !== pageNo) continue; + const parts: Rect[] = + node.type === "text" ? (node.lines ?? []).map((l) => inkRect(l, node)) : node.type === "image" || node.type === "svg" ? [node.box] : []; + for (const part of parts) { + if (intersects(part, k)) add("keepout", `${label(node)} ${fmt(part)} enters the keep-out ${fmt(k)}`, part); + } + } + } + + // below-wrap-area: the deepest non-chrome nodes that end under the content bottom. + const below = (n: LayoutNode): boolean => { + if (n.decor || n.fixed) return false; + const childBelow = n.children.map(below).some(Boolean); + const isBelow = bottom(n.box) > contentBottom + TOL; + if (isBelow && !childBelow) { + add("below-wrap-area", `${label(n)} ends at y ${bottom(n.box).toFixed(1)}, under the content bottom ${contentBottom.toFixed(1)}`, n.box); + } + return isBelow; + }; + page.nodes.forEach(below); + + // header-only-page + const hasHead = all.some((e) => e.node.id === headId); + const hasRow = all.some((e) => e.node.id?.startsWith(rowPrefix)); + if (hasHead && !hasRow) add("header-only-page", "the only table content on this page is the repeated header row"); + }); + + // split-block: a non-chrome id on two pages where it was authored wrap={false}. + const seen = new Map; noWrap: boolean; box: Rect }>(); + layout.pages.forEach((page, idx) => { + walk(page.nodes, (n) => { + if (!n.id || n.fixed || n.decor || n.id === DECOR_ID) return; + const e = seen.get(n.id) ?? { pages: new Set(), noWrap: false, box: n.box }; + e.pages.add(idx + 1); + e.noWrap ||= !!n.noWrap; + seen.set(n.id, e); + }); + }); + for (const [id, e] of seen) { + if (e.pages.size > 1 && e.noWrap) { + const pages = [...e.pages]; + issues.push({ code: "split-block", severity: "error", page: pages[0], message: `wrap={false} block #${id} appears on pages ${pages.join(" and ")}`, box: e.box }); + } + } + + // closing-not-at-bottom + if (opts.closingId && layout.pages.length > 0) { + const lastIdx = layout.pages.length - 1; + const last = layout.pages[lastIdx]; + let found: LayoutNode | undefined; + walk(last.nodes, (n) => { + if (n.id === opts.closingId) found = n; + }); + const want = last.height - pad.bottom; + if (!found) { + issues.push({ code: "closing-not-at-bottom", severity: "error", page: lastIdx + 1, message: `closing block #${opts.closingId} is not on the last page` }); + } else if (Math.abs(bottom((found as LayoutNode).box) - want) > TOL) { + issues.push({ + code: "closing-not-at-bottom", + severity: "error", + page: lastIdx + 1, + message: `closing block #${opts.closingId} ends at y ${bottom((found as LayoutNode).box).toFixed(1)}, the wrap area ends at ${want.toFixed(1)}`, + box: (found as LayoutNode).box, + }); + } + } + + return issues; +} diff --git a/src/pdf/engine/errors.ts b/src/pdf/engine/errors.ts new file mode 100644 index 0000000..09703e5 --- /dev/null +++ b/src/pdf/engine/errors.ts @@ -0,0 +1,12 @@ +export type PlanErrorCode = "tail-too-tall" | "chrome-reserve" | "invalid-plan"; + +/** A layout plan that cannot work (content that can never fit), reported before rendering. */ +export class PlanError extends Error { + constructor( + message: string, + readonly code: PlanErrorCode, + ) { + super(message); + this.name = "PlanError"; + } +} diff --git a/src/pdf/engine/fingerprint.ts b/src/pdf/engine/fingerprint.ts new file mode 100644 index 0000000..7b4a59a --- /dev/null +++ b/src/pdf/engine/fingerprint.ts @@ -0,0 +1,73 @@ +import { walk, type LayoutNode, type LayoutPage } from "./layoutTree"; + +const r1 = (n: number) => Math.round(n * 10) / 10; + +/** + * The canonical text that gets hashed: page sizes, every text node's box (rounded to 0.1 pt) and its + * line strings, and image boxes. Never the raw PDF bytes: Creator/Producer feed the PDF /ID, so the + * bytes change with every app update even when the pages do not. + */ +export function canonicalLayout(layout: { pages: LayoutPage[] }): string { + const pages = layout.pages.map((p) => { + const items: unknown[] = []; + walk(p.nodes, (n: LayoutNode) => { + if (n.type === "text") items.push(["t", r1(n.box.x), r1(n.box.y), r1(n.box.w), r1(n.box.h), (n.lines ?? []).map((l) => l.text)]); + else if (n.type === "image") items.push(["i", r1(n.box.x), r1(n.box.y), r1(n.box.w), r1(n.box.h)]); + }); + return [r1(p.width), r1(p.height), items]; + }); + return JSON.stringify(pages); +} + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +/** Plain-JS SHA-256 so the fingerprint is identical where crypto.subtle is missing (insecure contexts). */ +export function sha256HexJs(bytes: Uint8Array): string { + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 8, Math.floor((bytes.length * 8) / 2 ** 32)); + view.setUint32(padded.length - 4, (bytes.length * 8) >>> 0); + const w = new Uint32Array(64); + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + for (let off = 0; off < padded.length; off += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) | 0; + const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0; + hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0; + } + h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh; + } + return [...h].map((x) => x.toString(16).padStart(8, "0")).join(""); +} + +/** SHA-256 hex of a string: crypto.subtle when present, else the JS fallback. Always async. */ +export async function sha256Hex(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const subtle = globalThis.crypto?.subtle; + if (subtle) { + const digest = await subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((x) => x.toString(16).padStart(2, "0")).join(""); + } + return sha256HexJs(bytes); +} + +export async function layoutFingerprint(layout: { pages: LayoutPage[] }): Promise { + return sha256Hex(canonicalLayout(layout)); +} diff --git a/src/pdf/engine/ids.ts b/src/pdf/engine/ids.ts new file mode 100644 index 0000000..9ea79cb --- /dev/null +++ b/src/pdf/engine/ids.ts @@ -0,0 +1,19 @@ +/** + * Ids the audit recognises. react-pdf copies `id` into the layout tree, so these survive to + * auditLayout without any custom prop. + */ + +/** Marks decor (absolute decorative boxes). The audit ignores a decor node and everything under it. */ +export const DECOR_ID = "decor"; + +/** The continuation band text: legitimately draws 0 lines on page 1. */ +export const BAND_ID = "chrome-band"; + +/** The "Page n of N" label. */ +export const PAGE_LABEL_ID = "chrome-page"; + +/** The repeated table header row (fixed). */ +export const TABLE_HEAD_ID = "tbl-head"; + +/** Body rows are `tbl-row-`: unique, so a row on two pages is visible as a split. */ +export const TABLE_ROW_PREFIX = "tbl-row-"; diff --git a/src/pdf/engine/layoutTree.ts b/src/pdf/engine/layoutTree.ts new file mode 100644 index 0000000..3a9192e --- /dev/null +++ b/src/pdf/engine/layoutTree.ts @@ -0,0 +1,204 @@ +/** + * Normalised layout tree. react-pdf 4.9.0 hands `Document onRender` the whole laid-out tree as + * `_INTERNAL__LAYOUT__DATA_`; this module turns it into a small typed structure the audit and the + * fingerprint read, so nothing else depends on react-pdf internals. + * + * The internal tree (verified by dumping real renders, layout 5.2.0 / textkit 7.0.1): + * + * DOCUMENT { props, children: PAGE[] } + * PAGE { box: { width, height, top: 0, left: 0, padding* ... }, children } one per physical page + * VIEW | TEXT | IMAGE | SVG | LINK | ... { type, props, style, box, children, wasSplit, origin } + * TEXT also has `lines: Line[]`; its children are TEXT_INSTANCE { value } (the source strings) + * Line { string, box: { x, y, width, height }, xAdvance, height, ascent, overflowRight, runs } + * + * - `box.top` / `box.left` are relative to the PARENT's border box (Yoga). Absolute page coordinates are + * the sum down the chain; `origin` is the transform centre, not a position. + * - `box.width` / `box.height` are the final sizes after flex. `style.lineHeight` of a static Text with + * an 'Npt' value is rewritten to N*1000 (13.8pt -> 13800): read line boxes, never the style. + * - Line `box.x` is the aligned start of the line inside the text content box; `box.y` stacks lines + * from the top. `box.width` is NOT the ink width (it spans the container plus the overflow). The + * advance is `xAdvance`: glyph advances including the letterSpacing after the last glyph and any + * leading/trailing whitespace, which `overflowLeft`/`overflowRight` account for. Ink width is + * `xAdvance - overflowLeft - overflowRight` (minus the trailing letterSpacing step). + * - `props` keeps `id`, `fixed`, `wrap`, `minPresenceAhead` and `render` as the author wrote them. A node + * that the paginator moved to the next page gets `wrap: true, break: false` written over its props. + * - Fixed nodes are copied onto every page; each page has its own PAGE node with only the pieces on it. + * - SVG children (paths, groups) carry no layout boxes; the SVG node is a leaf here. + * + * Normalised form: every node has an ABSOLUTE page-space box and a lower-case `type`. TEXT_INSTANCE nodes + * disappear into the owning text's `text` (the source string, textTransform applied). Text lines get + * absolute boxes whose width is the advance width without edge whitespace. `decor` is inherited from any DECOR_ID ancestor. + */ +import { DECOR_ID } from "./ids"; + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +export interface LayoutLine extends Rect { + text: string; +} + +export type LayoutNodeType = "view" | "text" | "image" | "svg" | "link" | "other"; + +export interface LayoutNode { + type: LayoutNodeType; + id?: string; + /** Absolute page-space border box. */ + box: Rect; + /** Text nodes: the source string (what should have been drawn). Render-prop text: the drawn string. */ + text?: string; + lines?: LayoutLine[]; + /** `fixed` itself or under a fixed node (page chrome that repeats on every page). */ + fixed?: boolean; + /** Authored `wrap={false}`. */ + noWrap?: boolean; + /** Under a DECOR_ID node (or itself one). */ + decor?: boolean; + /** position: absolute. */ + abs?: boolean; + /** Text with an authored maxLines clamp (truncation is intended). */ + maxLines?: number; + /** Text drawn from a render prop. */ + render?: boolean; + /** letterSpacing in pt (the trailing step after the last glyph is not ink). */ + letterSpacing?: number; + children: LayoutNode[]; +} + +export interface LayoutPage { + width: number; + height: number; + nodes: LayoutNode[]; +} + +/** The subset of react-pdf's internal node this module reads. */ +interface RawNode { + type: string; + value?: string; + style?: Record; + props?: Record; + box?: Record; + lines?: RawLine[]; + children?: RawNode[]; +} +interface RawLine { + string: string; + box: { x: number; y: number; width: number; height: number }; + xAdvance: number; + height: number; + overflowLeft?: number; + overflowRight?: number; +} + +const TYPES: Record = { + VIEW: "view", + TEXT: "text", + TSPAN: "text", + IMAGE: "image", + SVG: "svg", + LINK: "link", +}; + +const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0); + +function collectText(node: RawNode): string { + if (node.type === "TEXT_INSTANCE") return node.value ?? ""; + return (node.children ?? []).map(collectText).join(""); +} + +function convert( + node: RawNode, + parent: { x: number; y: number }, + parentDecor: boolean, + parentIsText: boolean, + parentFixed: boolean, +): LayoutNode | null { + if (node.type === "TEXT_INSTANCE") return null; + const b = node.box ?? {}; + const x = parent.x + num(b.left); + const y = parent.y + num(b.top); + const type = TYPES[node.type] ?? "other"; + // A Text nested in a Text is folded into the parent's attributed string and has no lines of its own. + if (type === "text" && parentIsText && !node.lines) return null; + + const props = node.props ?? {}; + const id = typeof props.id === "string" ? props.id : undefined; + const decor = parentDecor || id === DECOR_ID; + const out: LayoutNode = { + type, + ...(id !== undefined ? { id } : {}), + box: { x, y, w: num(b.width), h: num(b.height) }, + children: [], + }; + // Everything under a fixed node is chrome too (it is the fixed node that repeats). + const fixed = parentFixed || props.fixed === true; + if (fixed) out.fixed = true; + if (props.wrap === false) out.noWrap = true; + if (decor) out.decor = true; + if (node.style?.position === "absolute") out.abs = true; + + if (type === "text") { + const ls = num(node.style?.letterSpacing); + if (ls) out.letterSpacing = ls; + const maxLines = num(node.style?.maxLines); + if (maxLines) out.maxLines = maxLines; + const isRender = typeof props.render === "function"; + if (isRender) out.render = true; + const padL = num(b.paddingLeft) + num(b.borderLeftWidth); + const padT = num(b.paddingTop) + num(b.borderTopWidth); + // `xAdvance` and `box.width` include the line's leading/trailing whitespace (`overflowLeft/Right`); + // the ink excludes it. Line 3 of a wrapped address can be 182.36 pt "wide" in a 180 pt box with + // overflowRight 2.36: that is just the trailing space. + out.lines = (node.lines ?? []).map((l) => ({ + x: x + padL + num(l.box.x) + num(l.overflowLeft), + y: y + padT + num(l.box.y), + w: Math.max(0, num(l.xAdvance) - num(l.overflowLeft) - num(l.overflowRight)), + h: num(l.box.height) || num(l.height), + text: l.string, + })); + if (isRender) out.text = out.lines.map((l) => l.text).join(" "); + else { + const raw = collectText(node); + out.text = node.style?.textTransform === "uppercase" ? raw.toUpperCase() : raw; + } + } + + // Svg internals have no boxes; keep the Svg a leaf. + if (type !== "svg") { + for (const child of node.children ?? []) { + const c = convert(child, { x, y }, decor, type === "text", fixed); + if (c) out.children.push(c); + } + } + return out; +} + +/** Converts react-pdf's internal layout tree (the `_INTERNAL__LAYOUT__DATA_` of onRender). */ +export function normalizeLayout(layoutData: unknown): { pages: LayoutPage[] } { + const doc = layoutData as RawNode | undefined; + if (!doc || !Array.isArray(doc.children)) throw new Error("normalizeLayout: not a react-pdf layout tree"); + const pages: LayoutPage[] = doc.children + .filter((p) => p.type === "PAGE") + .map((p) => { + const b = p.box ?? {}; + const nodes: LayoutNode[] = []; + for (const child of p.children ?? []) { + const c = convert(child, { x: 0, y: 0 }, false, false, false); + if (c) nodes.push(c); + } + return { width: num(b.width), height: num(b.height), nodes }; + }); + return { pages }; +} + +/** Depth-first walk of a page's nodes. `parent` is the enclosing node (undefined for top level). */ +export function walk(nodes: LayoutNode[], visit: (n: LayoutNode, parent: LayoutNode | undefined) => void, parent?: LayoutNode): void { + for (const n of nodes) { + visit(n, parent); + walk(n.children, visit, n); + } +} diff --git a/src/pdf/engine/metrics.test.ts b/src/pdf/engine/metrics.test.ts new file mode 100644 index 0000000..d3bb987 --- /dev/null +++ b/src/pdf/engine/metrics.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { chromeHeight } from "./metrics"; + +describe("chromeHeight", () => { + it("is ceil((ascent - descent + lineGap) / upm * size) + 1 from the manifest", () => { + // Plex: 1025 + 275 = 1300 / 1000 = 1.3 em + expect(chromeHeight({ face: "IBM Plex Sans", weight: 400, size: 8 })).toBe(Math.ceil(10.4) + 1); + expect(chromeHeight("footer")).toBe(Math.ceil(9.75) + 1); + expect(chromeHeight({ face: "IBM Plex Sans", weight: 400, size: 10 })).toBe(14); + }); + + it("covers the line react-pdf draws for render-prop text", () => { + // A 8 pt Plex line is 10.4 pt tall (verified in the layout tree); the reserve must be at least that. + expect(chromeHeight({ face: "IBM Plex Sans", weight: 400, size: 8 })).toBeGreaterThanOrEqual(10.4); + }); + + it("falls back across weights and styles instead of failing", () => { + expect(chromeHeight({ face: "IBM Plex Sans", weight: 600, italic: true, size: 8 })).toBe(12); + }); +}); diff --git a/src/pdf/engine/metrics.ts b/src/pdf/engine/metrics.ts new file mode 100644 index 0000000..6162c5a --- /dev/null +++ b/src/pdf/engine/metrics.ts @@ -0,0 +1,31 @@ +import { FONT_MANIFEST, type FontFileEntry } from "../fonts/manifest"; +import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; + +function metricsFile(family: string, weight: number, italic: boolean): FontFileEntry | undefined { + const fam = FONT_MANIFEST.families.find((f) => f.family === family); + if (!fam) return undefined; + const style = italic ? "italic" : "normal"; + return ( + fam.fonts.find((f) => f.weight === weight && f.style === style) ?? + fam.fonts.find((f) => f.weight === weight) ?? + fam.fonts.find((f) => f.style === style) ?? + fam.fonts[0] + ); +} + +/** + * Height in pt of one auto-height line of render-prop text (page numbers, the continuation band): + * `ceil((ascent - descent + lineGap) / upm * size) + 1`. Render-prop text has no lineHeight, so + * textkit uses the font's own metrics; a pinned box smaller than this draws nothing. The metrics are + * the primary face's: chrome text is Latin. A fallback glyph from a taller face would raise the line + * (see the Indic faces), so keep Indic text out of chrome or add headroom. + */ +export function chromeHeight(role: RoleName | TypeToken, opts: { weight?: number; italic?: boolean; faceOverrides?: Readonly> } = {}): number { + const token = roleToken(role); + const family = resolveFace(token.face, opts.faceOverrides); + const file = metricsFile(family, opts.weight ?? token.weight, opts.italic ?? token.italic ?? false); + if (!file) throw new Error(`chromeHeight: no font metrics for "${family}"`); + // The manifest stores descent as a negative number, as the font's hhea table does. + const lineUnits = file.ascent - file.descent + file.lineGap; + return Math.ceil((lineUnits / file.upm) * token.size) + 1; +} diff --git a/src/pdf/hang.test.ts b/src/pdf/hang.test.ts new file mode 100644 index 0000000..f475cb6 --- /dev/null +++ b/src/pdf/hang.test.ts @@ -0,0 +1,75 @@ +import { spawn } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { build } from "esbuild"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * react-pdf's paginate() is a synchronous loop with no progress guard, so a layout it cannot settle + * spins forever and an in-process timer never fires. These scenarios therefore run in a child process + * under a watchdog. scripts/hang/scenario.tsx is bundled with esbuild and run with plain node. + */ +const ROOT = process.cwd(); +const OUT = path.join(ROOT, "node_modules/.cache/voiced-hang/scenario.mjs"); +const WATCHDOG_MS = 15_000; + +interface ChildResult { + killed: boolean; + stdout: string; + stderr: string; +} + +function runScenario(scenario: string, rows: number): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [OUT, scenario, String(rows)], { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let killed = false; + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + const timer = setTimeout(() => { + killed = true; + child.kill("SIGKILL"); + }, WATCHDOG_MS); + child.on("close", () => { + clearTimeout(timer); + resolve({ killed, stdout, stderr }); + }); + }); +} + +beforeAll(async () => { + mkdirSync(path.dirname(OUT), { recursive: true }); + await build({ + entryPoints: [path.join(ROOT, "scripts/hang/scenario.tsx")], + outfile: OUT, + bundle: true, + platform: "node", + format: "esm", + // Resolve react, react-pdf and fontkit from node_modules at run time, as the app does. + packages: "external", + jsx: "automatic", + logLevel: "error", + }); +}); + +describe("paginate() hang hazard", () => { + it("page-level non-fixed absolute Svg hangs paginate (hazard pinned)", { timeout: 20_000 }, async () => { + const r = await runScenario("page-level-svg", 50); + // The child never finished: only the watchdog ended it. If this fails, react-pdf stopped hanging + // here and the page-level-decor ban (layout rule 6) can be revisited. + expect(r.killed).toBe(true); + expect(r.stdout).toBe(""); + }); + + it("FirstPageHeader decor does not hang with 8, 50 or 120 rows and keeps the Svg on page 1 only", { timeout: 20_000 }, async () => { + const runs = await Promise.all([8, 50, 120].map((rows) => runScenario("first-page-header", rows))); + for (const r of runs) { + expect(r.killed, r.stderr).toBe(false); + expect(r.stderr).toBe(""); + } + const out = runs.map((r) => JSON.parse(r.stdout) as { pages: number; svgPages: number[] }); + expect(out.map((o) => o.pages)).toEqual([1, 2, 3]); + for (const o of out) expect(o.svgPages).toEqual(o.svgPages.map((_, i) => (i === 0 ? 1 : 0))); + }); +}); diff --git a/src/pdf/quirks.test.tsx b/src/pdf/quirks.test.tsx new file mode 100644 index 0000000..dbcc4dd --- /dev/null +++ b/src/pdf/quirks.test.tsx @@ -0,0 +1,304 @@ +/** + * Pins the react-pdf 4.9.0 behaviour the engine is built on (layout 5.2.0, textkit 7.0.1, fontkit 2.0.4, + * pdfkit 0.20.1). Each title states the verified fact; when an upgrade changes one of them the test + * fails and the design rule behind it must be revisited. + */ +import { createElement as h, type ReactElement, type ReactNode } from "react"; +import path from "node:path"; +import { Font, renderToBuffer, Text, View } from "@react-pdf/renderer"; +import { describe, expect, it } from "vitest"; +import { auditLayout } from "./engine/audit"; +import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./engine/ids"; +import type { LayoutPage } from "./engine/layoutTree"; +import { getMeasurer } from "./engine/measure"; +import { AvailableWidthProvider } from "./blocks/AvailableWidth"; +import { BottomSpacer } from "./blocks/BottomSpacer"; +import { planItemsTable, TableRows } from "./blocks/ItemsTable"; +import { TYPE_ROLES } from "./fonts/roles"; +import { RenderTxt } from "./blocks/RenderTxt"; +import { TableGuard, guardPresence } from "./blocks/TableGuard"; +import { renderCore } from "./render/core"; +import { loadCanvas, inkPixels } from "./testing/rasterize"; +import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs"; +import { FONTS_DIR, setupPdfTest } from "./testing/setup"; + +setupPdfTest(); + +const render = (el: ReactElement) => renderCore(el); + +const LONG = + "Aurora Industrial Fasteners and Precision Components Private Limited, Plot 14, Phase 2, Peenya Industrial Area, Bengaluru, Karnataka"; + +describe("react-pdf 4.9.0 quirks the engine relies on", () => { + it("1. flexGrow without flexBasis 0 leaves stale line breaks: the text is wider than its final box; with flexBasis 0 it is not", async () => { + const row = (fill: Record) => + doc( + h( + View, + { style: { flexDirection: "row", width: 360 } }, + h(Text, { id: "fill", style: txt({ ...fill }) }, LONG), + h(View, { style: { width: 180, minWidth: 180, maxWidth: 180 } }, h(Text, { style: txt() }, "side")), + ), + ); + const widest = async (fill: Record) => { + const r = await render(row(fill)); + const t = byId(r.layout[0], "fill")[0]; + return { box: t.box.w, ink: Math.max(...t.lines!.map((l) => l.w)) }; + }; + const stale = await widest({ flexGrow: 1 }); + expect(stale.ink).toBeGreaterThan(stale.box + 0.5); + const fixed = await widest({ flexGrow: 1, flexBasis: 0 }); + expect(fixed.ink).toBeLessThanOrEqual(fixed.box + 0.5); + }); + + it("2. a static Text with lineHeight '13.8pt' ends with style.lineHeight 13800 (after the relayout a dynamic page forces) while its line boxes are 13.8 tall", async () => { + const dynamic = h(RenderTxt, { key: "d", role: { face: "IBM Plex Sans", weight: 400, size: 8 }, fixed: true, render: ({ pageNumber }) => `p${pageNumber}` }); + const statik = () => h(Text, { key: "t", id: "t", style: txt({ lineHeight: "13.8pt" }) }, "Hello"); + const raw = await rawLayout(doc([statik(), dynamic])); + const text = raw.children[0].children[0]; + expect(text.type).toBe("TEXT"); + expect(text.style.lineHeight).toBe(13800); + expect(text.lines[0].box.height).toBeCloseTo(13.8, 5); + const r = await render(doc([statik(), dynamic])); + expect(byId(r.layout[0], "t")[0].lines![0].h).toBeCloseTo(13.8, 5); + + // Without a dynamic node the page is laid out once and the unit is still wrong (13.8pt -> 138). + const single = await rawLayout(doc(statik())); + expect(single.children[0].children[0].style.lineHeight).toBe(138); + }); + + describe("3. fixed, wrap and minPresenceAhead are checked by key presence", () => { + // Four 60 pt rows fill 20..260 of the 20..280 wrap area; the fifth straddles the page foot. + const rows = (fifth: Record) => + doc( + [0, 1, 2, 3, 4].map((i) => + h(View, { key: i, id: `r${i}`, style: { height: 60 }, ...(i === 4 ? fifth : { wrap: false }) }, h(Text, { style: txt() }, `Row ${i}`)), + ), + ); + const place = async (fifth: Record) => { + const r = await render(rows(fifth)); + return r.layout.map((p) => byId(p, "r4").map((n) => ({ y: n.box.y, h: n.box.h }))); + }; + + it("wrap={false} without `fixed` moves the whole row to the next page", async () => { + const where = await place({ wrap: false }); + expect(where[0]).toEqual([]); + expect(where[1]).toHaveLength(1); + expect(where[1][0].h).toBe(60); + }); + + it("wrap={false} with fixed={false} splits the row instead (fixed is key presence)", async () => { + const where = await place({ wrap: false, fixed: false }); + expect(where[0].length).toBeGreaterThan(0); + expect(where[0][0].h).toBeLessThan(60); + }); + + it("wrap={undefined} cannot wrap, as if wrap={false}; omitting `wrap` splits a straddling row", async () => { + const omitted = await place({}); + expect(omitted[0][0].h).toBeLessThan(60); + const undef = await place({ wrap: undefined }); + expect(undef[0]).toEqual([]); + }); + + it("minPresenceAhead={undefined} never triggers; a number moves the sentinel (and what follows) to the next page", async () => { + const sentinel = (extra: Record) => + doc([ + h(View, { key: "fill", id: "fill", style: { height: 200 } }), + h(View, { key: "s", id: "s", style: { height: 0 }, ...extra }), + h(View, { key: "t", id: "t", style: { height: 100 }, wrap: false }, h(Text, { style: txt() }, "tail")), + ]); + const pageOf = async (extra: Record) => { + const r = await render(sentinel(extra)); + return r.layout.findIndex((p) => byId(p, "s").length > 0) + 1; + }; + expect(await pageOf({ minPresenceAhead: 100 })).toBe(2); + expect(await pageOf({ minPresenceAhead: undefined })).toBe(1); + expect(await pageOf({})).toBe(1); + }); + }); + + it("4. a family registered upright-only throws 'Could not resolve font ... italic' in a fallback stack; the aliased registration does not", async () => { + const file = path.join(FONTS_DIR, "ibm-plex-sans/IBMPlexSans-Regular.ttf"); + Font.register({ family: "Quirk Upright Only", fonts: [{ src: file, fontWeight: 400, fontStyle: "normal" }] }); + Font.register({ + family: "Quirk Aliased", + fonts: [ + { src: file, fontWeight: 400, fontStyle: "normal" }, + { src: file, fontWeight: 400, fontStyle: "italic" }, + ], + }); + const italic = (family: string[]) => doc(h(Text, { style: { fontFamily: family, fontStyle: "italic", fontSize: 10 } }, "Italic text")); + await expect(render(italic(["IBM Plex Sans", "Quirk Upright Only"]))).rejects.toThrow(/Could not resolve font.*italic/i); + await expect(render(italic(["IBM Plex Sans", "Quirk Aliased"]))).resolves.toBeTruthy(); + // The bundled stack (Noto faces have no real italic) is aliased by register.ts. + await expect(render(italic(FAMILY))).resolves.toBeTruthy(); + }); + + it("5. a WOFF2 font embeds glyphs with no outlines: the text area stays white, a TTF control has ink", async () => { + const canvasLib = await loadCanvas(); + if (!canvasLib) { + console.warn("quirk 5 skipped: @napi-rs/canvas (optional dependency) is not installed"); + return; + } + Font.register({ family: "Quirk Woff2", src: path.join(FONTS_DIR, "IBMPlexSans-Regular.woff2") }); + Font.register({ family: "Quirk Ttf", src: path.join(FONTS_DIR, "ibm-plex-sans/IBMPlexSans-Regular.ttf") }); + const sample = (family: string) => + renderToBuffer( + doc(h(Text, { style: { fontFamily: family, fontSize: 40, color: "#000000" } }, "HWMN"), { width: 300, height: 100 }) as never, + ); + const area = { x0: 10, y0: 10, x1: 290, y1: 90 }; + const woff = await inkPixels(canvasLib, new Uint8Array(await sample("Quirk Woff2")), area); + const ttf = await inkPixels(canvasLib, new Uint8Array(await sample("Quirk Ttf")), area); + expect(ttf).toBeGreaterThan(500); + expect(woff).toBe(0); + }); + + it("6. a newline or tab inside a Text pulls an unembedded Helvetica into the PDF", async () => { + const baseFonts = async (s: string) => + (await renderToBuffer(doc(h(Text, { style: txt() }, s)) as never)).toString("latin1").match(/\/BaseFont \/[A-Za-z+]*Helvetica[A-Za-z-]*/g) ?? []; + expect(await baseFonts("plain text")).toEqual([]); + expect((await baseFonts("two\nlines")).length).toBeGreaterThan(0); + expect((await baseFonts("a\tb")).length).toBeGreaterThan(0); + }); + + it("7. a pinned height on a text container truncates with an ellipsis (lines lost); minHeight does not", async () => { + const name = "Aurora Industrial Fasteners and Precision Components Private Limited of Peenya"; + const box = (sizing: Record) => + doc(h(View, { style: { width: 100, ...sizing } }, h(Text, { id: "name", style: txt() }, name))); + const pinned = (await render(box({ height: 33 }))).layout[0]; + const t = byId(pinned, "name")[0]; + expect(t.lines!.length).toBe(2); + expect(t.lines!.map((l) => l.text).join(" ").endsWith("…")).toBe(true); + const frame = frameFor(); + expect(auditLayout({ pages: [pinned] }, frame).map((i) => i.code)).toContain("text-truncated"); + + const free = (await render(box({ minHeight: 33 }))).layout[0]; + const f = byId(free, "name")[0]; + expect(f.lines!.length).toBeGreaterThan(3); + // Line strings keep their trailing space, so they concatenate back to the source. + expect(f.lines!.map((l) => l.text).join("")).toBe(name); + expect(auditLayout({ pages: [free] }, frame).map((i) => i.code)).not.toContain("text-truncated"); + }); + + describe("8. table header and TableGuard", () => { + const WIDTH = 360; + const columns = [{ fill: true as const }, { fixed: 60 }]; + const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody }; + const body = Array.from({ length: 80 }, (_, i) => [`Item ${i + 1}`, `${i + 1}`]); + const plan = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 }); + const frame = frameFor(); + const table = () => + h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, roles, padY: 4, border: 0.5 }); + const withFiller = (fillerH: number, guarded: boolean) => + doc( + h( + AvailableWidthProvider, + { width: WIDTH }, + h(View, { id: "filler", style: { height: fillerH } }), + guarded ? h(TableGuard, { minPresenceAhead: guardPresence(plan.headH, plan.firstRowH, TYPE_ROLES.tableBody.leading) }) : null, + table(), + ), + ); + const pageStats = (l: LayoutPage) => ({ + head: find(l, (n) => n.id === TABLE_HEAD_ID).length, + rows: find(l, (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX)).length, + }); + + it("a `fixed` first child repeats the header on every page of an 80-row table", async () => { + const r = await render(withFiller(0, true)); + expect(r.pages).toBeGreaterThan(2); + r.layout.forEach((p) => expect(pageStats(p).head).toBe(1)); + expect(r.layout.reduce((n, p) => n + pageStats(p).rows, 0)).toBe(80); + }); + + it("without a guard, a header alone can sit at the page foot (header-only-page); TableGuard prevents it", async () => { + // Leave room for the header and a few pt: less than header + first row. + const filler = frame.contentHeight - plan.headH - 4; + const bad = await render(withFiller(filler, false)); + expect(pageStats(bad.layout[0])).toEqual({ head: 1, rows: 0 }); + expect(auditLayout({ pages: bad.layout }, frame).map((i) => i.code)).toContain("header-only-page"); + + const good = await render(withFiller(filler, true)); + // The guard moves the whole table: page 1 keeps only the filler. + expect(pageStats(good.layout[0])).toEqual({ head: 0, rows: 0 }); + good.layout.slice(1).forEach((p) => expect(pageStats(p).rows).toBeGreaterThan(0)); + expect(auditLayout({ pages: good.layout }, frame).map((i) => i.code)).not.toContain("header-only-page"); + }); + }); + + describe("9. BottomSpacer", () => { + // 60 rows of 16 pt in a 490 pt wrap area: 30 per page, so pages 1 and 2 are full (10 pt spare, less + // than the closing block) and the closing block must start page 3. + const CLOSING_H = 80; + const build = (spacer: ReactNode) => + doc( + [ + ...Array.from({ length: 60 }, (_, i) => h(View, { key: i, style: { height: 16 } }, h(Text, { style: txt({ fontSize: 8, lineHeight: "10pt" }) }, `Row ${i + 1}`))), + spacer, + h(View, { key: "closing", id: "closing", style: { height: CLOSING_H }, wrap: false }, h(Text, { style: txt() }, "Amount in words")), + ], + { height: 530 }, + ); + const closing = async (spacer: ReactNode) => { + const r = await render(build(spacer)); + const page = r.layout.findIndex((p) => byId(p, "closing").length > 0); + return { pages: r.pages, page: page + 1, node: byId(r.layout[page], "closing")[0] }; + }; + + it("with minPresenceAhead = closing height the closing block sits at the foot of page 3; without it, at the top", async () => { + const wrapBottom = 530 - PAD; + const withGuard = await closing(h(BottomSpacer, { key: "sp", closingH: CLOSING_H })); + expect(withGuard.page).toBe(3); + expect(withGuard.pages).toBe(3); + expect(withGuard.node.box.y + withGuard.node.box.h).toBeCloseTo(wrapBottom, 0); + + const bare = await closing(h(View, { key: "sp", style: { flexGrow: 1, flexBasis: 0 } })); + expect(bare.page).toBe(3); + expect(bare.node.box.y).toBeCloseTo(PAD, 0); + }); + }); + + it("10. fixed chrome placed AFTER the flow repeats on every page (legacy paginator)", async () => { + const rows = Array.from({ length: 40 }, (_, i) => h(View, { key: i, style: { height: 20 } }, h(Text, { style: txt() }, `Row ${i}`))); + const footer = h(View, { key: "footer", id: "footer", fixed: true, style: { position: "absolute", bottom: 4, left: PAD } }, h(Text, { style: txt() }, "Footer")); + const r = await render(doc([...rows, footer])); + expect(r.pages).toBeGreaterThan(2); + r.layout.forEach((p) => expect(byId(p, "footer")).toHaveLength(1)); + }); + + it("11. letterSpacing is added after the last glyph too (the Tracked assumption)", async () => { + const m = getMeasurer(); + const base = m.measureWidth({ text: "AB", face: "IBM Plex Sans", weight: 400, size: 10 }); + const r = await render(doc(h(Text, { id: "t", style: txt({ letterSpacing: 5, textAlign: "right" }) }, "AB"))); + const t = byId(r.layout[0], "t")[0]; + const line = t.lines![0]; + expect(line.w).toBeCloseTo(base + 2 * 5, 2); + // The advance reaches the box edge, so the last glyph's ink ends one step short of it. + expect(line.x + line.w).toBeCloseTo(t.box.x + t.box.w, 2); + }); + + describe("12. render-prop text height", () => { + const label = (pageNumber: number, totalPages: number) => `Page ${pageNumber} of ${totalPages}`; + const flow = Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } }, h(Text, { style: txt() }, `Row ${i}`))); + const chrome = (viewStyle: Record) => + h( + View, + { key: "chrome", id: "chrome", fixed: true, style: { position: "absolute", bottom: 4, left: PAD, width: 200, ...viewStyle } }, + h(RenderTxt, { id: "label", role: { face: "IBM Plex Sans", weight: 400, size: 8 }, render: ({ pageNumber, totalPages }) => label(pageNumber, totalPages) }), + ); + + it("inside an auto-height fixed View it draws 'Page k of N' on every page", async () => { + const r = await render(doc([...flow, chrome({})])); + expect(r.pages).toBeGreaterThan(1); + r.layout.forEach((p, i) => { + const t = byId(p, "label")[0]; + expect(t.lines!.map((l) => l.text)).toEqual([label(i + 1, r.pages)]); + }); + }); + + it("inside a 10 pt fixed-height View at 8 pt Plex (10.4 pt line) it draws 0 lines", async () => { + const r = await render(doc([...flow, chrome({ height: 10 })])); + r.layout.forEach((p) => expect(byId(p, "label")[0].lines).toHaveLength(0)); + }); + }); +}); diff --git a/src/pdf/render/core.test.tsx b/src/pdf/render/core.test.tsx new file mode 100644 index 0000000..dda02e5 --- /dev/null +++ b/src/pdf/render/core.test.tsx @@ -0,0 +1,126 @@ +import { createElement as h, type ReactNode } from "react"; +import { Document, Page, Text, View } from "@react-pdf/renderer"; +import { describe, expect, it } from "vitest"; +import { canonicalLayout, sha256Hex, sha256HexJs } from "../engine/fingerprint"; +import { normalizeLayout } from "../engine/layoutTree"; +import { byId, doc, frameFor, PAD, txt } from "../testing/docs"; +import { setupPdfTest } from "../testing/setup"; +import { renderCore, type RenderBytes } from "./core"; + +setupPdfTest(); + +describe("renderCore", () => { + const simple = (text = "Hello") => doc(h(Text, { id: "t", style: txt() }, text)); + + it("returns bytes, page count, the normalised layout, issues and a fingerprint", async () => { + const r = await renderCore(simple(), { frame: frameFor() }); + expect(Buffer.from(r.bytes.subarray(0, 5)).toString()).toBe("%PDF-"); + expect(r.pages).toBe(1); + expect(r.layout).toHaveLength(1); + expect(r.layout[0]).toMatchObject({ width: 400, height: 300 }); + expect(r.issues).toEqual([]); + expect(r.fingerprint).toMatch(/^[0-9a-f]{64}$/); + const t = byId(r.layout[0], "t")[0]; + expect(t).toMatchObject({ type: "text", text: "Hello" }); + expect(t.box).toMatchObject({ x: PAD, y: PAD }); + expect(t.lines![0].text).toBe("Hello"); + }); + + it("audits only when given a frame, and passes decorBoxes as keep-outs", async () => { + expect((await renderCore(simple())).issues).toEqual([]); + const r = await renderCore(simple(), { frame: frameFor(), decorBoxes: [{ x: 0, y: 0, w: 100, h: 60 }] }); + expect(r.issues.map((i) => i.code)).toEqual(["keepout"]); + }); + + it("fingerprints the layout: stable across renders and metadata, different when the layout differs", async () => { + const a = await renderCore(simple()); + const b = await renderCore(simple()); + expect(b.fingerprint).toBe(a.fingerprint); + const meta = await renderCore(h(Document, { creator: "Other 9.9", producer: "Other", creationDate: new Date(0) }, h(Page, { size: { width: 400, height: 300 }, style: { padding: PAD } }, h(Text, { id: "t", style: txt() }, "Hello")))); + expect(meta.fingerprint).toBe(a.fingerprint); + expect(Buffer.from(meta.bytes).equals(Buffer.from(a.bytes))).toBe(false); + expect((await renderCore(simple("Hello!"))).fingerprint).not.toBe(a.fingerprint); + const shifted = doc(h(Text, { id: "t", style: txt({ marginLeft: 3 }) }, "Hello")); + expect((await renderCore(shifted)).fingerprint).not.toBe(a.fingerprint); + }); + + it("rounds boxes to 0.1 pt for the fingerprint", async () => { + const a = await renderCore(doc(h(Text, { style: txt({ marginLeft: 3.01 }) }, "Hello"))); + const b = await renderCore(doc(h(Text, { style: txt({ marginLeft: 3.02 }) }, "Hello"))); + expect(b.fingerprint).toBe(a.fingerprint); + }); + + it("chains the root's own onRender and accepts a swapped bytes adapter", async () => { + let seen = false; + const el = h(Document, { onRender: () => (seen = true) }, h(Page, { size: { width: 400, height: 300 } }, h(Text, { style: txt() }, "x"))); + const fake: RenderBytes = async (element) => { + // Mimic what the worker adapter does: drive react-pdf and hand back the bytes. + const { renderToBuffer } = await import("@react-pdf/renderer"); + return new Uint8Array(await renderToBuffer(element as never)); + }; + const r = await renderCore(el, {}, fake); + expect(seen).toBe(true); + expect(r.pages).toBe(1); + }); + + it("fails clearly when the root never reaches onRender", async () => { + const NotForwarding = (): ReactNode => doc(h(Text, { style: txt() }, "x")); + await expect(renderCore(h(NotForwarding as never))).rejects.toThrow(/onRender never delivered/); + }); + + it("splits a long flow into pages and keeps fixed chrome flagged", async () => { + const el = doc([ + ...Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } })), + h(View, { key: "f", id: "foot", fixed: true, style: { position: "absolute", bottom: 4, left: PAD } }, h(Text, { id: "ft", style: txt() }, "Footer")), + ]); + const r = await renderCore(el); + expect(r.pages).toBeGreaterThan(1); + for (const p of r.layout) { + expect(byId(p, "foot")[0].fixed).toBe(true); + expect(byId(p, "ft")[0].fixed).toBe(true); + } + }); +}); + +describe("normalizeLayout", () => { + it("rejects something that is not a layout tree", () => { + expect(() => normalizeLayout(undefined)).toThrow(); + expect(() => normalizeLayout({})).toThrow(); + }); + + it("makes boxes absolute and drops the whitespace a line carries at its edges from the ink", async () => { + const el = doc( + h(View, { style: { padding: 4, marginTop: 10 } }, h(Text, { id: "t", style: txt({ width: 70 }) }, "Aurora Industrial Fasteners")), + ); + const r = await renderCore(el); + const view = r.layout[0].nodes[0]; + expect(view.box).toMatchObject({ x: PAD, y: PAD + 10 }); + const t = view.children[0]; + expect(t.box).toMatchObject({ x: PAD + 4, y: PAD + 10 + 4 }); + // Every ink width fits the 70 pt box even though textkit's line.xAdvance counts the trailing space. + for (const l of t.lines!) expect(l.w).toBeLessThanOrEqual(70.01); + expect(t.lines!.map((l) => l.text).join("")).toBe("Aurora Industrial Fasteners"); + }); + + it("folds nested Text into its parent", async () => { + const el = doc(h(Text, { id: "p", style: txt() }, "Total: ", h(Text, { style: txt({ fontWeight: 700 }) }, "1,000"))); + const r = await renderCore(el); + const p = r.layout[0].nodes[0]; + expect(p.children).toEqual([]); + expect(p.text).toBe("Total: 1,000"); + }); + + it("canonicalLayout covers page size, text boxes with line strings and image boxes, not ids or styles", async () => { + const r = await renderCore(doc(h(Text, { id: "t", style: txt() }, "Hi"))); + expect(JSON.parse(canonicalLayout({ pages: r.layout }))).toEqual([[400, 300, [["t", 20, 20, 360, 12, ["Hi"]]]]]); + }); +}); + +describe("sha256", () => { + it("JS fallback equals crypto.subtle", async () => { + for (const s of ["", "abc", "a".repeat(55), "a".repeat(56), "a".repeat(64), "₹1,23,456.78 नमस्ते", "x".repeat(1000)]) { + expect(sha256HexJs(new TextEncoder().encode(s))).toBe(await sha256Hex(s)); + } + expect(sha256HexJs(new TextEncoder().encode("abc"))).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); +}); diff --git a/src/pdf/render/core.ts b/src/pdf/render/core.ts new file mode 100644 index 0000000..fbfd09f --- /dev/null +++ b/src/pdf/render/core.ts @@ -0,0 +1,76 @@ +import { pdf, renderToBuffer } from "@react-pdf/renderer"; +import { cloneElement, type ReactElement } from "react"; +import { auditLayout, type AuditIssue, type AuditOptions } from "../engine/audit"; +import { layoutFingerprint } from "../engine/fingerprint"; +import type { PageFrame } from "../engine/geometry"; +import { normalizeLayout, type LayoutPage, type Rect } from "../engine/layoutTree"; + +export { normalizeLayout, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree"; +export { auditLayout, type AuditIssue } from "../engine/audit"; +export { layoutFingerprint } from "../engine/fingerprint"; + +export interface RenderCoreOptions { + /** With a frame the layout is audited; without one `issues` is empty. */ + frame?: PageFrame; + /** Page-space boxes content must stay out of (audited as `keepout`). */ + decorBoxes?: Rect[]; + /** Further audit options (closing block id, ...). */ + audit?: Omit; +} + +export interface RenderCoreResult { + bytes: Uint8Array; + pages: number; + layout: LayoutPage[]; + issues: AuditIssue[]; + /** sha256 of the normalised layout (not of the bytes; Creator/Producer feed the PDF /ID). */ + fingerprint: string; +} + +/** Turns an element into PDF bytes. Swappable: the render worker supplies its own. */ +export type RenderBytes = (element: ReactElement) => Promise; + +const isNode = typeof process !== "undefined" && !!process.versions?.node; + +/** + * Node uses renderToBuffer; the browser build of react-pdf throws from it, so there (and in a worker) + * pdf().toBlob() is used. Both call the Document's onRender with the layout tree. + */ +export const renderBytes: RenderBytes = async (element) => { + const el = element as Parameters[0]; + if (isNode) return new Uint8Array(await renderToBuffer(el)); + const blob = await pdf(el).toBlob(); + return new Uint8Array(await blob.arrayBuffer()); +}; + +/** + * Renders `element` once and returns the bytes, the normalised layout tree, the audit issues and the + * layout fingerprint. + * + * The layout tree comes from the Document's `onRender` (`_INTERNAL__LAYOUT__DATA_`). For now the root + * element is cloned with an `onRender` added (chained after the one it already has). The root must + * therefore be a `` element, or a component that forwards `onRender` to its `` + * (InvoicePdf will). + */ +export async function renderCore( + element: ReactElement, + opts: RenderCoreOptions = {}, + bytesOf: RenderBytes = renderBytes, +): Promise { + let raw: unknown; + const existing = (element.props as { onRender?: (p: unknown) => void }).onRender; + const wrapped = cloneElement(element as ReactElement<{ onRender?: (p: unknown) => void }>, { + onRender: (params: unknown) => { + raw = (params as { _INTERNAL__LAYOUT__DATA_?: unknown } | undefined)?._INTERNAL__LAYOUT__DATA_; + existing?.(params); + }, + }); + + const bytes = await bytesOf(wrapped); + if (raw === undefined) { + throw new Error("renderCore: onRender never delivered a layout tree; the root element must be a or forward onRender to one"); + } + const { pages } = normalizeLayout(raw); + const issues = opts.frame ? auditLayout({ pages }, opts.frame, { ...opts.audit, keepOuts: opts.decorBoxes }) : []; + return { bytes, pages: pages.length, layout: pages, issues, fingerprint: await layoutFingerprint({ pages }) }; +} diff --git a/src/pdf/testing/docs.ts b/src/pdf/testing/docs.ts new file mode 100644 index 0000000..c924e7d --- /dev/null +++ b/src/pdf/testing/docs.ts @@ -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 = {}) => ({ fontFamily: FAMILY, fontSize: 10, lineHeight: "12pt", ...extra }); + +export function doc(children: ReactNode, o: { height?: number; width?: number; pad?: number | Record } = {}): 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, { onRender: (p: any) => (raw = p._INTERNAL__LAYOUT__DATA_) }); + await renderToBuffer(withHook as never); + return raw; +} diff --git a/src/pdf/testing/layoutLint.ts b/src/pdf/testing/layoutLint.ts index 24e2d91..7d12d76 100644 --- a/src/pdf/testing/layoutLint.ts +++ b/src/pdf/testing/layoutLint.ts @@ -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"], }; diff --git a/src/pdf/testing/rasterize.ts b/src/pdf/testing/rasterize.ts new file mode 100644 index 0000000..e6ceca1 --- /dev/null +++ b/src/pdf/testing/rasterize.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 { + 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 { + 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(); + } +}