Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from the Canva PDFs with grid and band tables that survive page breaks, full-bleed fixed bands, pennant tabs, gradient corner swooshes with keep-outs, Carbon icon circles, and the user's logo in the measured slot (knockout or white chip on the dark Slate band). Adds static Poppins 500-700 and Inter fonts. Thumbnails regenerated for all eight templates; the item-count sweeps run in their own test file.
166 lines
7.0 KiB
TypeScript
166 lines
7.0 KiB
TypeScript
import { View } from "@react-pdf/renderer";
|
|
import { FILL } from "../engine/columns";
|
|
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 { FitText } from "./FitText";
|
|
import { RenderTxt } from "./RenderTxt";
|
|
|
|
export interface PageChromeProps {
|
|
frame: PageFrame;
|
|
/** Left footer text (e.g. the invoice number); omit for none. */
|
|
footerLeft?: string;
|
|
/**
|
|
* Pins the left text to this width (pt) and fits it to one line (shrunk, then cut with an ellipsis).
|
|
* A render-prop Text cannot be clamped, so a long supplier name there would wrap and push the footer
|
|
* up into the content. Without it the left text takes the remaining width.
|
|
*/
|
|
footerLeftWidth?: number;
|
|
/** Centre text between the left text and the page label. */
|
|
footerCenter?: 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[];
|
|
/** Footer row offset from the page bottom; default: the bottom margin (padding minus the footer reserve). */
|
|
bottom?: number;
|
|
/** Colour of the continuation band's text; default `color`. */
|
|
bandColor?: string;
|
|
/** Page-space left edge and width of the continuation band; default: the frame's content box. */
|
|
bandBox?: { left: number; width: number };
|
|
}
|
|
|
|
/**
|
|
* FitText is static text and needs a leading; the footer role has none (render-prop text), so the
|
|
* fitted left text borrows 1.4x the size, which stays within the auto-height line that chromeHeight
|
|
* reserves for the same role.
|
|
*/
|
|
function fineFooter(role: RoleName | TypeToken): TypeToken {
|
|
const t = roleToken(role);
|
|
return { ...t, leading: Math.round(t.size * 1.4 * 10) / 10 };
|
|
}
|
|
|
|
/** "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,
|
|
footerLeftWidth,
|
|
footerCenter,
|
|
pageLabel = true,
|
|
role = "footer",
|
|
color,
|
|
band,
|
|
bandTop,
|
|
decor = [],
|
|
bottom,
|
|
bandBox,
|
|
bandColor,
|
|
}: PageChromeProps) {
|
|
const { pad } = frame;
|
|
const need = chromeHeight(role);
|
|
const showFooter = pageLabel || footerLeft !== undefined || footerCenter !== 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 = bottom ?? pad.bottom - frame.footerReserve;
|
|
const bandLeftEdge = bandBox?.left ?? pad.left;
|
|
const bandWidth = bandBox?.width ?? frame.contentWidth;
|
|
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: bandLeftEdge, width: bandWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
|
{...pdfFlags({ fixed: true })}
|
|
>
|
|
{band.left !== undefined && (
|
|
<RenderTxt id={BAND_ID} role={role} color={bandColor ?? color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
|
)}
|
|
{band.right !== undefined && (
|
|
<RenderTxt
|
|
id={BAND_ID}
|
|
role={role}
|
|
color={bandColor ?? 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 &&
|
|
(footerLeftWidth !== undefined ? (
|
|
<FitText role={fineFooter(role)} width={footerLeftWidth} color={color}>
|
|
{footerLeft}
|
|
</FitText>
|
|
) : (
|
|
<RenderTxt role={role} color={color} grow render={() => footerLeft} />
|
|
))}
|
|
{footerCenter !== undefined && <RenderTxt role={role} color={color} align="center" grow render={() => footerCenter} />}
|
|
{footerCenter === undefined && footerLeftWidth !== undefined && <View style={FILL as never} />}
|
|
{pageLabel && (
|
|
<RenderTxt
|
|
id={PAGE_LABEL_ID}
|
|
role={role}
|
|
color={color}
|
|
align="right"
|
|
width={labelWidth}
|
|
render={({ pageNumber, totalPages }) => pageLabelText(pageNumber, totalPages)}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
</>
|
|
);
|
|
}
|