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,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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user