Add pagination blocks, renderCore, layout audit and react-pdf quirk tests
TableGuard, BottomSpacer, FirstPageHeader and PageChrome implement the verified pagination rules; renderCore returns bytes, a normalised layout tree, audit issues and a layout fingerprint. auditLayout detects overlaps, ink overflow, truncation, header-only pages and split blocks. Quirk tests pin the react-pdf behaviours the design depends on, and a child-process test pins the page-level absolute Svg hang.
This commit is contained in:
@@ -0,0 +1,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<string, unknown>) =>
|
||||
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 }));
|
||||
@@ -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 <View style={FILL} {...pdfFlags({ minPresenceAhead: closingH })} />;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 (
|
||||
<View style={style as never} {...pdfFlags({ wrap: false })}>
|
||||
{decor.map((d, i) => (
|
||||
<View
|
||||
key={i}
|
||||
id={DECOR_ID}
|
||||
style={{ position: "absolute", left: d.x - ox, top: d.y - oy, width: d.width, height: d.height }}
|
||||
>
|
||||
{d.children}
|
||||
</View>
|
||||
))}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string>>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
rowStyle?: Record<string, unknown>;
|
||||
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<string, unknown>) => ({
|
||||
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||
...extra,
|
||||
});
|
||||
const cells = (role: RoleName | TypeToken, values: string[]) =>
|
||||
values.map((v, i) => (
|
||||
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX }}>
|
||||
<Txt role={role} width={Math.max(0, widths[i] - 2 * padX)} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{v}
|
||||
</Txt>
|
||||
</Cell>
|
||||
));
|
||||
|
||||
return (
|
||||
<AvailableWidthProvider width={width}>
|
||||
<View>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header)}
|
||||
</Row>
|
||||
{rows.map((r, i) => (
|
||||
<Row
|
||||
key={i}
|
||||
id={`${TABLE_ROW_PREFIX}${i}`}
|
||||
widths={columns}
|
||||
gap={gap}
|
||||
width={width}
|
||||
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
|
||||
style={cellStyle(rowStyle)}
|
||||
>
|
||||
{cells(roles.body, r)}
|
||||
</Row>
|
||||
))}
|
||||
{tail}
|
||||
</View>
|
||||
</AvailableWidthProvider>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<View
|
||||
key={`decor-${i}`}
|
||||
id={DECOR_ID}
|
||||
style={{ position: "absolute", left: d.x, top: d.y, width: d.width, height: d.height }}
|
||||
{...pdfFlags({ fixed: true })}
|
||||
>
|
||||
{d.children}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{band && (band.left !== undefined || band.right !== undefined) && (
|
||||
<View
|
||||
style={{ position: "absolute", top, left: pad.left, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||
{...pdfFlags({ fixed: true })}
|
||||
>
|
||||
{band.left !== undefined && (
|
||||
<RenderTxt id={BAND_ID} role={role} color={color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
||||
)}
|
||||
{band.right !== undefined && (
|
||||
<RenderTxt
|
||||
id={BAND_ID}
|
||||
role={role}
|
||||
color={color}
|
||||
align="right"
|
||||
width={bandRightWidth}
|
||||
render={({ pageNumber }) => (pageNumber > 1 ? (band.right ?? "") : "")}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showFooter && (
|
||||
<View
|
||||
style={{ position: "absolute", left: pad.left, bottom: marginBottom, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||
{...pdfFlags({ fixed: true })}
|
||||
>
|
||||
{footerLeft !== undefined && <RenderTxt role={role} color={color} grow render={() => footerLeft} />}
|
||||
{pageLabel && (
|
||||
<RenderTxt
|
||||
id={PAGE_LABEL_ID}
|
||||
role={role}
|
||||
color={color}
|
||||
align="right"
|
||||
width={labelWidth}
|
||||
render={({ pageNumber, totalPages }) => pageLabelText(pageNumber, totalPages)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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 <Text style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
|
||||
return <Text {...(id !== undefined ? { id } : {})} style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 <View style={{ height: 0 }} {...pdfFlags({ minPresenceAhead })} />;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof doc>, 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<string, unknown>) => ({ 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<string, unknown>) =>
|
||||
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<string, unknown>, style: Record<string, unknown> = {}) =>
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Rect & { page?: number }>;
|
||||
/** 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<string, { pages: Set<number>; 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<number>(), 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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
return sha256Hex(canonicalLayout(layout));
|
||||
}
|
||||
@@ -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-<index>`: unique, so a row on two pages is visible as a split. */
|
||||
export const TABLE_ROW_PREFIX = "tbl-row-";
|
||||
@@ -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<string, unknown>;
|
||||
props?: Record<string, unknown>;
|
||||
box?: Record<string, number | undefined>;
|
||||
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<string, LayoutNodeType> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> } = {}): 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;
|
||||
}
|
||||
@@ -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<ChildResult> {
|
||||
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)));
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) =>
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<AuditOptions, "keepOuts">;
|
||||
}
|
||||
|
||||
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<Uint8Array>;
|
||||
|
||||
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<typeof renderToBuffer>[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 `<Document>` element, or a component that forwards `onRender` to its `<Document>`
|
||||
* (InvoicePdf will).
|
||||
*/
|
||||
export async function renderCore(
|
||||
element: ReactElement,
|
||||
opts: RenderCoreOptions = {},
|
||||
bytesOf: RenderBytes = renderBytes,
|
||||
): Promise<RenderCoreResult> {
|
||||
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 <Document> 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 }) };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cloneElement, createElement as h, type ReactElement, type ReactNode } from "react";
|
||||
import { Document, Page, renderToBuffer } from "@react-pdf/renderer";
|
||||
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
|
||||
import { walk, type LayoutNode, type LayoutPage } from "../engine/layoutTree";
|
||||
import { stack } from "../fonts/register";
|
||||
|
||||
/** Small fixtures for tests that need a tiny react-pdf document. */
|
||||
export const FAMILY = stack("IBM Plex Sans");
|
||||
export const PAGE_W = 400;
|
||||
export const PAD = 20;
|
||||
|
||||
export const txt = (extra: Record<string, unknown> = {}) => ({ fontFamily: FAMILY, fontSize: 10, lineHeight: "12pt", ...extra });
|
||||
|
||||
export function doc(children: ReactNode, o: { height?: number; width?: number; pad?: number | Record<string, number> } = {}): ReactElement {
|
||||
const pad = o.pad ?? PAD;
|
||||
return h(
|
||||
Document,
|
||||
null,
|
||||
h(
|
||||
Page,
|
||||
{ size: { width: o.width ?? PAGE_W, height: o.height ?? 300 }, style: typeof pad === "number" ? { padding: pad } : pad },
|
||||
children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A frame matching `doc()`: uniform padding `PAD`, optional footer reserve inside the bottom padding. */
|
||||
export function frameFor(o: { height?: number; width?: number; footerReserve?: number; continuationReserve?: number } = {}): PageFrame {
|
||||
return computeFrame({
|
||||
page: { ...PAGE_SIZES.A4, width: o.width ?? PAGE_W, height: o.height ?? 300 },
|
||||
margins: { top: PAD, right: PAD, bottom: PAD, left: PAD },
|
||||
footerReserve: o.footerReserve ?? 0,
|
||||
continuationReserve: o.continuationReserve ?? 0,
|
||||
letterMode: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function find(page: LayoutPage, pred: (n: LayoutNode) => boolean): LayoutNode[] {
|
||||
const out: LayoutNode[] = [];
|
||||
walk(page.nodes, (n) => {
|
||||
if (pred(n)) out.push(n);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const byId = (page: LayoutPage, id: string) => find(page, (n) => n.id === id);
|
||||
|
||||
/** react-pdf's own layout tree, for facts the normalised tree hides (style values, raw props). */
|
||||
export async function rawLayout(el: ReactElement): Promise<{ type: string; children: any[] }> {
|
||||
let raw: any;
|
||||
const withHook = cloneElement(el as ReactElement<any>, { onRender: (p: any) => (raw = p._INTERNAL__LAYOUT__DATA_) });
|
||||
await renderToBuffer(withHook as never);
|
||||
return raw;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export interface LintViolation {
|
||||
|
||||
/** Paths (relative to the repo root, posix) where each otherwise-banned construct may appear. */
|
||||
export const LINT_ALLOW = {
|
||||
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts"],
|
||||
// testing/docs.ts builds throwaway fixture Text styles for tests, not template code.
|
||||
leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts", "src/pdf/testing/docs.ts"],
|
||||
absolute: ["src/pdf/decor/", "src/pdf/blocks/PageChrome.tsx", "src/pdf/blocks/FirstPageHeader.tsx"],
|
||||
rawFlags: ["src/pdf/blocks/pdfProps.ts"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
|
||||
/** `@napi-rs/canvas` is an optional dependency; null when its native binary is not installed. */
|
||||
export async function loadCanvas(): Promise<typeof import("@napi-rs/canvas") | null> {
|
||||
try {
|
||||
return await import("@napi-rs/canvas");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of non-white pixels in the box (x0,y0,x1,y1 in pt from the top-left) of page 1 drawn at `scale`. */
|
||||
export async function inkPixels(
|
||||
canvasLib: typeof import("@napi-rs/canvas"),
|
||||
bytes: Uint8Array,
|
||||
box: { x0: number; y0: number; x1: number; y1: number },
|
||||
scale = 2,
|
||||
): Promise<number> {
|
||||
const task = getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true });
|
||||
const doc = await task.promise;
|
||||
try {
|
||||
const page = await doc.getPage(1);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = canvasLib.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: ctx as never, canvas: canvas as never, viewport }).promise;
|
||||
const x = Math.floor(box.x0 * scale);
|
||||
const y = Math.floor(box.y0 * scale);
|
||||
const w = Math.ceil((box.x1 - box.x0) * scale);
|
||||
const h = Math.ceil((box.y1 - box.y0) * scale);
|
||||
const data = ctx.getImageData(x, y, w, h).data;
|
||||
let ink = 0;
|
||||
for (let i = 0; i < data.length; i += 4) if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) ink++;
|
||||
return ink;
|
||||
} finally {
|
||||
await task.destroy();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user