Files
Voiced/src/pdf/blocks/PageChrome.tsx
T
xavierk 06976df55c Add Ledger and Band template families with decor primitives
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.
2026-10-04 08:34:18 +05:30

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>
)}
</>
);
}