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.
This commit is contained in:
@@ -29,8 +29,12 @@ export interface TableLook {
|
||||
padX?: number;
|
||||
/** Vertical cell padding, pt per side. Default 4. */
|
||||
padY?: number;
|
||||
/** Vertical padding of the header row; default `padY`. */
|
||||
headPadY?: number;
|
||||
/** Bottom border of each row in pt (counted in the row height). Default 0. */
|
||||
border?: number;
|
||||
/** Bottom border of the header row; default `border`. */
|
||||
headBorder?: number;
|
||||
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
|
||||
padRight?: number[];
|
||||
faceOverrides?: Readonly<Record<string, string>>;
|
||||
@@ -105,12 +109,12 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
|
||||
|
||||
const { widths } = colsDetailed(width, columns, gap);
|
||||
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
|
||||
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
|
||||
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[], py = padY, bd = border) => {
|
||||
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 };
|
||||
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * py + bd };
|
||||
};
|
||||
|
||||
const headH = heightOf(roles.head, header).height + (input.headerTailH ?? 0);
|
||||
const headH = heightOf(roles.head, header, input.headPadY ?? padY, input.headBorder ?? border).height + (input.headerTailH ?? 0);
|
||||
const measured = rows.map((r) => heightOf(roles.body, r));
|
||||
const rowHeights = measured.map((r) => r.height);
|
||||
|
||||
@@ -150,6 +154,24 @@ export interface TableRowsProps extends TableLook {
|
||||
* Pass the same height as `headerTailH` to planItemsTable.
|
||||
*/
|
||||
headerTail?: ReactNode;
|
||||
/**
|
||||
* Ruled grid: a vertical rule after every column, one on the outer left edge, and a rule above the
|
||||
* header cells (each row draws its own bottom rule through `border`). The cells stretch to the row's
|
||||
* height so the verticals run the full row. In the header the inner verticals take `headerColor` and
|
||||
* the outer edges (top, left, right) `headerEdge`; both default to `color`. Plan with `gridPadRight(...)` so wrapping uses the real
|
||||
* inner width, and add `grid.width` to `headerTailH` for the header's top rule.
|
||||
*/
|
||||
grid?: { width: number; color: string; headerColor?: string; headerEdge?: string };
|
||||
/**
|
||||
* Header band that bleeds to the page edges: `left` is the page-edge-to-table distance, `width` the page
|
||||
* width. The header group is fixed (it repeats), so the bleed passes the safe-zone audit.
|
||||
*/
|
||||
bleed?: { left: number; width: number; bg: string };
|
||||
}
|
||||
|
||||
/** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */
|
||||
export function gridPadRight(columns: number, ruleWidth: number): number[] {
|
||||
return Array.from({ length: columns }, (_, i) => (i === 0 ? 2 : 1) * ruleWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,39 +179,68 @@ export interface TableRowsProps extends TableLook {
|
||||
* `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, headColor, headerTail, ...look }: TableRowsProps) {
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, grid, bleed, ...look }: TableRowsProps) {
|
||||
const padX = look.padX ?? 0;
|
||||
const padY = look.padY ?? 4;
|
||||
const headPadY = look.headPadY ?? padY;
|
||||
const border = look.border ?? 0;
|
||||
const headBorder = look.headBorder ?? border;
|
||||
const { widths } = colsDetailed(width, columns, gap);
|
||||
const cellStyle = (extra?: Record<string, unknown>) => ({
|
||||
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||
const cellStyle = (extra?: Record<string, unknown>, bd = border) => ({
|
||||
...(bd ? { borderBottomWidth: bd, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||
...extra,
|
||||
});
|
||||
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
|
||||
values.map((v, i) => (
|
||||
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
|
||||
{cellParts(v).map((part, li) => (
|
||||
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{partText(part)}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
));
|
||||
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string, isHeader = false) =>
|
||||
values.map((v, i) => {
|
||||
const edge = grid?.width ?? 0;
|
||||
const last = i === values.length - 1;
|
||||
const inner = isHeader ? (grid?.headerColor ?? grid?.color) : grid?.color;
|
||||
const outer = isHeader ? (grid?.headerEdge ?? grid?.color) : grid?.color;
|
||||
const ruleW = edge * (i === 0 ? 2 : 1);
|
||||
return (
|
||||
<Cell
|
||||
key={i}
|
||||
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}
|
||||
borderLeft={grid && i === 0 ? edge : 0}
|
||||
borderRight={edge}
|
||||
style={
|
||||
grid
|
||||
? {
|
||||
flexGrow: 1,
|
||||
flexBasis: "auto",
|
||||
borderLeftColor: outer,
|
||||
borderRightColor: last ? outer : inner,
|
||||
...(isHeader ? { borderTopWidth: edge, borderTopColor: outer } : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{cellParts(v).map((part, li) => (
|
||||
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{partText(part)}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
);
|
||||
});
|
||||
const rowAlign = grid ? ("stretch" as const) : undefined;
|
||||
|
||||
return (
|
||||
<AvailableWidthProvider width={width}>
|
||||
<View>
|
||||
{headerTail ? (
|
||||
<View {...pdfFlags({ fixed: true })}>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
{headerTail || bleed ? (
|
||||
<View
|
||||
{...pdfFlags({ fixed: true })}
|
||||
style={bleed ? { marginLeft: -bleed.left, width: bleed.width, minWidth: bleed.width, maxWidth: bleed.width, paddingLeft: bleed.left, backgroundColor: bleed.bg } : undefined}
|
||||
>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} style={cellStyle(headerStyle, headBorder)}>
|
||||
{cells(roles.head, header, headColor, true)}
|
||||
</Row>
|
||||
{headerTail}
|
||||
</View>
|
||||
) : (
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} fixed style={cellStyle(headerStyle, headBorder)}>
|
||||
{cells(roles.head, header, headColor, true)}
|
||||
</Row>
|
||||
)}
|
||||
{rows.map((r, i) => (
|
||||
@@ -199,6 +250,7 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
widths={columns}
|
||||
gap={gap}
|
||||
width={width}
|
||||
align={rowAlign}
|
||||
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
|
||||
style={cellStyle(rowStyle)}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface PageChromeProps {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +82,9 @@ export function PageChrome({
|
||||
band,
|
||||
bandTop,
|
||||
decor = [],
|
||||
bottom,
|
||||
bandBox,
|
||||
bandColor,
|
||||
}: PageChromeProps) {
|
||||
const { pad } = frame;
|
||||
const need = chromeHeight(role);
|
||||
@@ -84,7 +93,9 @@ export function PageChrome({
|
||||
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 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);
|
||||
|
||||
@@ -103,17 +114,17 @@ export function PageChrome({
|
||||
|
||||
{band && (band.left !== undefined || band.right !== undefined) && (
|
||||
<View
|
||||
style={{ position: "absolute", top, left: pad.left, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||
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={color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
||||
<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={color}
|
||||
color={bandColor ?? color}
|
||||
align="right"
|
||||
width={bandRightWidth}
|
||||
render={({ pageNumber }) => (pageNumber > 1 ? (band.right ?? "") : "")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { pinned } from "../engine/columns";
|
||||
import { textLines } from "../engine/textplan";
|
||||
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
@@ -68,21 +69,29 @@ export interface TaxRowsProps {
|
||||
/** Space between the label and the amount column. */
|
||||
labelGap?: number;
|
||||
color?: string;
|
||||
/** Role of the amount when it differs from the label's (Band's tracked labels over plain amounts). */
|
||||
amountRole?: Role;
|
||||
/** Draws each row itself (a ruled grid cell pair) from the slot's label and amount text; the slot still owns the container. */
|
||||
renderRow?: (row: { label: string; amount: string }, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
/** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */
|
||||
function TaxRows({ model, role = "body", amountWidth, rowStyle, format, labelAlign, labelGap = 0, color }: TaxRowsProps) {
|
||||
function TaxRows({ model, role = "body", amountWidth, rowStyle, format, labelAlign, labelGap = 0, color, amountRole, renderRow }: TaxRowsProps) {
|
||||
const w = useAvailableWidth();
|
||||
if (model.totals.taxRows.length === 0) return null;
|
||||
const text = format ?? ((label: string, rate: string | undefined) => (rate ? `${label} (${rate})` : label));
|
||||
return (
|
||||
<View id={SLOT_ID.taxRows}>
|
||||
{model.totals.taxRows.map((r, i) => (
|
||||
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
|
||||
<Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
|
||||
<Txt role={role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
|
||||
</Row>
|
||||
))}
|
||||
{model.totals.taxRows.map((r, i) =>
|
||||
renderRow ? (
|
||||
<Fragment key={i}>{renderRow({ label: text(r.label, r.rateText), amount: r.amountText }, i)}</Fragment>
|
||||
) : (
|
||||
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
|
||||
<Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
|
||||
<Txt role={amountRole ?? role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
|
||||
</Row>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user