Files
Voiced/src/pdf/InvoicePage.tsx
T
xavierk 9de9a6ffee Add Ledger and Band template families with decor primitives
Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from
the reference 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

76 lines
3.2 KiB
TypeScript

import { Page, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import type { PageFrame } from "./engine/geometry";
import { chromeHeight } from "./engine/metrics";
import type { RenderModel } from "./model/build";
import type { TemplateDefinition } from "./templates/contract";
import { AvailableWidthProvider } from "./blocks/AvailableWidth";
import type { DecorItem } from "./blocks/FirstPageHeader";
import { PageChrome } from "./blocks/PageChrome";
/** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */
export const FOOTER_RESERVE = 24;
/** Room above the content on pages 2+ for the continuation band, inside the top margin. */
export const CONTINUATION_RESERVE = 18;
export interface InvoicePageProps {
frame: PageFrame;
model: RenderModel;
template: TemplateDefinition;
children?: ReactNode;
}
/**
* The only `<Page>` in the app: size and padding come from the frame (no page-size or margin literal
* anywhere else), the flow is the template's, and the chrome (footer, continuation band) is added after
* it as fixed page-level boxes. No lineHeight here: it would reach the render-prop page-number text.
*/
export function InvoicePage({ frame, model, template, children }: InvoicePageProps) {
const { pad } = frame;
const { chrome, decor } = template;
const marginBottom = pad.bottom - frame.footerReserve;
const ruleY = frame.page.height - marginBottom - chromeHeight(chrome.role ?? "footer") - 6.6;
const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`;
const items: DecorItem[] = [...(decor.everyPage?.(frame, model) ?? [])];
if (chrome.rule) {
items.push({ x: pad.left, y: ruleY, width: frame.contentWidth, height: 0.6, children: <View style={{ height: 0.6, backgroundColor: chrome.rule }} /> });
}
// The footer sits inside the template's text column, which may be narrower than the page content width.
const inset = chrome.inset ?? { left: 0, right: 0 };
const chromeFrame: PageFrame = {
...frame,
pad: { ...pad, left: pad.left + inset.left, right: pad.right + inset.right },
contentWidth: frame.contentWidth - inset.left - inset.right,
};
const bandInset = chrome.bandInset ?? inset;
const bandBox = { left: pad.left + bandInset.left, width: frame.contentWidth - bandInset.left - bandInset.right };
return (
<Page
size={frame.page.size}
style={{
paddingTop: pad.top,
paddingRight: pad.right,
paddingBottom: pad.bottom,
paddingLeft: pad.left,
color: decor.ink,
...(decor.background ? { backgroundColor: decor.background } : {}),
}}
>
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
<PageChrome
frame={chromeFrame}
footerLeft={chrome.footerName === false ? undefined : model.supplier.name}
footerLeftWidth={chrome.leftWidth}
footerCenter={model.footerText}
role={chrome.role}
color={chrome.color}
band={{ left: bandLeft, ...(chrome.bandRight === false ? {} : { right: "continued" }) }}
bandBox={bandBox}
bandColor={chrome.bandColor}
bottom={chrome.bottom}
decor={items}
/>
</Page>
);
}