Add the Monolith and Cobalt Stripe templates
Standalone layouts for Canva templates 3 and 8, with the user's logo sized to the placeholder box, a CapRule decor primitive, optional TableRows rule/heading options, and a separate 1..45-row sweep test file.
This commit is contained in:
Vendored
+17
@@ -139,3 +139,20 @@ Voiced fields with no Canva slot, and where they go:
|
||||
## Similarity to other templates
|
||||
|
||||
Structurally this is the closest of the three to the existing in-app InvoiceDocument: a title with a meta stack, rule dividers, a ruled table with a heavy top rule, right-hand totals with a rule above Total, and bank details at bottom left. It is also close to format.pdf/4.pdf (rules, a column-header row with a rule, right-hand totals). It is the cheapest to build, as a restyle of the existing component: Poppins, a 60pt auto-fit title, 1.5pt table rules, centred numbers and a two-column footer. Within the set, 7 and 11 share the big Poppins Bold INVOICE with the brand at top right; 3 is the monochrome, rules-only variant.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the type roles and Canva distances, `plan.ts` the pure planner, `Layout.tsx` the body, `monolith.tsx` the definition), on the shared blocks and the `AccentRule` decor primitive. Measured against `3.pdf` with `npm run templates:compare -- monolith` (`/tmp/invoice-c6a/compare-monolith.png`, left Canva, right ours; `--numbers` prints the baselines).
|
||||
|
||||
**Matches the design (measured, A4, no due date so the stack is the design's three lines):** INVOICE origin x 50.7 with its baseline at 96.0 (Canva 96.2); the vendor name, date and INVOICE NO. lines at baselines 63.7 / 84.7 / 105.7 with their right edge at x 560.3 (Canva 560.6); BILLED TO at baseline 153.0 and x 35 (Canva 153.0, 34.4); client lines at 178.1 with the 17.25 pitch; Poppins 400/500/700 at the design's sizes (60.02 / 14.5 / 12.5 / 10.5), black only (a test scans the sources for any other colour); 0.75 pt rules, the table between two 1.5 pt rules around a 39.3 pt header; the totals block 215.5 pt wide with centred labels, a 0.75 pt rule over Total and the 23 pt row pitch; the footer grid with its 114 pt label column and the colon column at x 149; the vendor column at x 395.
|
||||
|
||||
**The logo.** The slot is the text-only "CAVEIN" placeholder (46.8 x 9 pt), so the logo is fitted to its 160 x 28 pt allowance, right-anchored, and the brand zone of the right-hand stack reserves the logo's whole box: the date and number lines are laid out under it, never over it (the earlier contact sheet showed a 28 pt logo covering the date line). A wordmark replaces the vendor name, a mark keeps it to its left, a tall logo is held to 28 pt, and with no logo the vendor name is printed in Bold 12.5 at the design's position. The zone is the `SUPPLIER_ID` box and the date and number lines the `META_ID` box, so the harness proves they do not meet.
|
||||
|
||||
**Intentional deviations, and why:**
|
||||
- **Title.** The design's 60 pt is shrunk (to 30 pt at the least) when the right-hand stack leaves less than the title needs: TAX INVOICE comes out at about 51 pt on A4, and a long invoice number shrinks first (IdText) before the title does.
|
||||
- **Right-hand stack.** The design's three lines plus a "Due:" line (Regular 12.5) when the invoice has a due date, and a red CANCELLED line on a cancelled one. The rule under it follows the stack, so an invoice with a logo or a due date starts 13 to 34 pt lower than the design.
|
||||
- **Supplier identifiers.** The design's empty band under the client holds a SUPPLIER heading with the GSTIN (through `Slot.Gstin`) and PAN, in the footer's right-hand column x. They are on page 1 whatever the length of the invoice. The supplier's address, phone and e-mail are the footer's vendor block, as in the design, so on a multi-page invoice they appear on the last page only.
|
||||
- **Table.** Numbers are right-aligned with a 10 pt pad (the design centres them; the doc's recommendation) except QTY, which stays centred; the cells of a row share one baseline (Canva's description and numbers are 7 pt apart); the rate's unit is `/hr` or a second line, HSN/SAC a 10 pt sub-line. Row padding is 16 pt for up to 3 rows, 12 for up to 5 and 8 beyond, then the elastic gaps take over.
|
||||
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
|
||||
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
|
||||
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where Canva's is at 812.
|
||||
|
||||
Vendored
+16
@@ -140,3 +140,19 @@ No raster XObjects. Everything is vector shapes plus live text.
|
||||
- No near-duplicate in the set. It is the only template with cobalt bars, zebra rows and a swoosh logo. It shares the 'Studio Shodwe' placeholder copy with 7 and 11, but the layout is unrelated.
|
||||
- Shared-component candidate: the icon contact footer (phone/email/address) also appears in 2, 9 (circled icons) and 12/13. The zebra rows loosely echo 1 (grey zebra) and 12/13 (pill rows).
|
||||
- Does not resemble the in-app InvoiceDocument.tsx (closest in the set is 3.pdf), or format.pdf / 4.pdf.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the type roles, palette and Canva distances, `plan.ts` the pure planner, `Layout.tsx` the body, `cobalt.tsx` the definition) on the shared blocks, the `CapRule` decor primitive (a grey round-capped rule with accent segments, one static Svg) and the Carbon icon paths. Measured against `8.pdf` with `npm run templates:compare -- cobalt-stripe` (`/tmp/invoice-c6a/compare-cobalt-stripe.png` and `-nologo.png`, left Canva, right ours; `--numbers` prints the baselines).
|
||||
|
||||
**Matches the design (measured, A4, the 8-row sample without GST data):** vendor name baseline 74.5 (Canva 74.6) at x 59.5; INVOICE origin x 395.4 and baseline 87.2 (395.6 / 87.3), tracked +0.043 em with its ink ending on the right edge; the rule text baseline 117.1 and the header rule centred on y 113.8 with its sky accent at the left; "Billed to :" 173.8, the client name 195.8 and the first contact line 219.8 (173.8 / 195.9 / 219.9); "Invoice no" and the date at 174.8 / 194.0 (175.2 / 194.4); the table bar 20.76 pt tall at y 292 with its heading baseline at 304.6 (304.7), the five columns at 24.9 / 84.9 / 105.4 / 89.3 pt and rows of 17.82 pt, the first row baseline 325.7 (325.2); the price ends 31.7 pt inside its column (x 414.7, Canva 414.9) and the total 3.1 pt inside the edge; Sub Total at 477.0 with its label at x 363.5 (477.0 / 363.7); the GRAND TOTAL bar 214.5 x 20.76 beside the PAYMENT METHOD bar 133.8 wide, both cobalt; the bank lines 541.0 (Canva 561.8 less its second tax row); every colour is one of the design's (a test scans the sources).
|
||||
|
||||
**Intentional deviations, and why:**
|
||||
- **Script signature.** Not reproduced (BD Script is licensed by Canva). The signature slot is the user's image (up to 36 pt tall, right-aligned, 5.5 pt inside the edge) or, without one, the signatory name and designation plus "Computer-generated invoice; no signature is required."
|
||||
- **Header content.** The tagline under the name is the supplier's GSTIN (through `Slot.Gstin`, in caps) and the text at the end of the rule is the PAN. A logo goes in the measured slot: a mark (at most 34.25 pt square) keeps the name and tagline beside it; a wordmark (at most 181.5 x 34.25 pt, left-anchored) replaces the name and the tagline moves under it, which makes the header 13 pt taller. With no logo the name starts at the margin. The rule row follows the header, so it is never nearer than 6 pt to it.
|
||||
- **Right-hand column.** "Invoice no : N" and the date as in the design, plus "Due date" and "PO" lines; a red CANCELLED line on a cancelled invoice. The client's GSTIN and place of supply follow the address in the grey 10 pt lines, whose pitch is 17.5 pt (Canva 19.2) so longer blocks fit.
|
||||
- **Table.** HSN/SAC is an 8.5 pt grey sub-line under the description (no slot in the design), which makes a row 11.5 pt taller; wrapped descriptions grow the row, and the zebra fill and the 1.5 pt light-blue separators are per cell, so they grow with it. The rate's unit is `/hr` or a second line. `TableRows` gained two additive options for this template: `innerRules` (separators between the columns only, no outer edge and no top rule) and `headAlign` (the heading centred over a left-aligned column).
|
||||
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
|
||||
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
|
||||
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
|
||||
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Side-by-side review of the Canva-based templates: the Canva original (left) against our render (right), plus
|
||||
// the multi-page, Letter, long-text and logo-variant samples. Run: npm run templates:compare [-- purple-pop citrus-split]
|
||||
// Output: /tmp/invoice-c5/ (override with --out=DIR). With --numbers it also prints text baselines (Canva vs ours) for tuning.
|
||||
// the multi-page, Letter, long-text and logo-variant samples. Run: npm run templates:compare [-- monolith cobalt-stripe]
|
||||
// Output: /tmp/invoice-c6a/ (override with --out=DIR). With --numbers it also prints text baselines (Canva vs ours) for tuning.
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { CANVA_DIR, loadCanvas, loadRenderer, rasterize, textItems } from "./lib.mjs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c5";
|
||||
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c6a";
|
||||
const SCALE = 1.6;
|
||||
const PDF_NO = { marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6, "crimson-grid": 7, "cobalt-stripe": 8, "slate-band": 9, "teal-swoosh": 10, "tangerine-ledger": 11, "purple-pop": 12, "citrus-split": 13 };
|
||||
const ROWS = { linea: 7, "crimson-grid": 3, "tangerine-ledger": 3, "slate-band": 5, "teal-swoosh": 5, "purple-pop": 3, "citrus-split": 3 };
|
||||
const ROWS = { linea: 7, "crimson-grid": 3, "tangerine-ledger": 3, "slate-band": 5, "teal-swoosh": 5, "purple-pop": 3, "citrus-split": 3, monolith: 3, "cobalt-stripe": 8 };
|
||||
const DARK = ["slate-band"];
|
||||
const args = process.argv.slice(2);
|
||||
const numbers = args.includes("--numbers");
|
||||
const ids = args.filter((a) => !a.startsWith("--"));
|
||||
const targets = ids.length ? ids : ["purple-pop", "citrus-split"];
|
||||
const targets = ids.length ? ids : ["monolith", "cobalt-stripe"];
|
||||
|
||||
const LONG = "Unit 4, Third Floor, The Grand Heritage Business Centre, Plot 1187-B, Old Airport Road Extension, Opposite the Municipal Corporation Ward Office, Domlur Second Stage, Bengaluru Urban District, Karnataka State, India - 560071; Registered office: Level 12, Tower C, Global Technology Park, Outer Ring Road, Marathahalli, Bengaluru - 560103";
|
||||
const TOKEN = "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890";
|
||||
@@ -40,7 +40,7 @@ async function main() {
|
||||
const canvaBytes = readFileSync(path.join(CANVA_DIR, `${no}.pdf`));
|
||||
const canva = await rasterize(canvaBytes, 1, SCALE);
|
||||
const rows = ROWS[id] ?? 5;
|
||||
const NAMES = ["Brand consultation", "Logo design", "Website design", "Social media templates", "Brand photography", "Brand guide", "Social media templates"];
|
||||
const NAMES = ["Brand consultation", "Logo design", "Website design", "Social media templates", "Brand photography", "Brand guide", "Social media templates", "Name card"];
|
||||
// Short one-line descriptions like the Canva sample, so positions can be compared row by row.
|
||||
const items = (hsn) => NAMES.slice(0, rows).map((description, i) => ({ id: i + 1, description, mode: "rate", rate: 100, unit: "hour", quantity: 1, amount: 0, sortOrder: i, ...(hsn ? { hsnSac: "998397" } : {}) }));
|
||||
const sample = (over = {}) => ({ name: `s-${id}`, items: items(over.registered !== false), signature: true, invoice: { poNumber: "", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, ...over });
|
||||
|
||||
@@ -141,6 +141,8 @@ export interface TableRowsProps extends TableLook {
|
||||
roles: TableRoles;
|
||||
/** Per column; default left. */
|
||||
align?: Array<"left" | "right" | "center">;
|
||||
/** Header alignment per column when it differs from the body's; default `align`. */
|
||||
headAlign?: Array<"left" | "right" | "center">;
|
||||
/** Passed as minPresenceAhead on the last row so the totals never start a page without an item row. */
|
||||
tailH?: number;
|
||||
/** Content after the last row, inside the table container (the totals). */
|
||||
@@ -178,6 +180,12 @@ export interface TableRowsProps extends TableLook {
|
||||
* of the container's left edge. Plan with planItemsTable at width - 2 * rowInset.
|
||||
*/
|
||||
pill?: { indent: number; inset: number };
|
||||
/**
|
||||
* Vertical rules between the columns only (no outer edges, no rule above the header): `width` after every column but
|
||||
* the last, in `color` on body rows and `headerColor` in the header (default `color`). The cells stretch to the row's
|
||||
* height. Plan with `innerRulePadRight(...)` added to the padding so wrapping uses the real inner width.
|
||||
*/
|
||||
innerRules?: { width: number; color: string; headerColor?: string };
|
||||
}
|
||||
|
||||
/** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */
|
||||
@@ -185,12 +193,17 @@ export function gridPadRight(columns: number, ruleWidth: number): number[] {
|
||||
return Array.from({ length: columns }, (_, i) => (i === 0 ? 2 : 1) * ruleWidth);
|
||||
}
|
||||
|
||||
/** Extra right padding per column that stands in for the inner rules of `innerRules`, for planItemsTable's `padRight`. */
|
||||
export function innerRulePadRight(columns: number, ruleWidth: number): number[] {
|
||||
return Array.from({ length: columns }, (_, i) => (i < columns - 1 ? ruleWidth : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Header (a `fixed` first child, so it repeats on every page the table spans) followed by one
|
||||
* `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, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, ...look }: TableRowsProps) {
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, innerRules, headAlign, ...look }: TableRowsProps) {
|
||||
const padX = look.padX ?? 0;
|
||||
const padY = look.padY ?? 4;
|
||||
const headPadY = look.headPadY ?? padY;
|
||||
@@ -208,14 +221,17 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
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);
|
||||
const inner2 = innerRules && !last ? innerRules.width : 0;
|
||||
const ruleW = grid ? edge * (i === 0 ? 2 : 1) : inner2;
|
||||
const alignOf = isHeader ? (headAlign?.[i] ?? align[i]) : align[i];
|
||||
return (
|
||||
<Cell
|
||||
key={i}
|
||||
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX + (look.padLeft?.[i] ?? 0), right: padX + (look.padRight?.[i] ?? 0) }}
|
||||
borderLeft={grid && i === 0 ? edge : 0}
|
||||
borderRight={edge}
|
||||
borderRight={grid ? edge : inner2}
|
||||
style={{
|
||||
...(innerRules ? { flexGrow: 1, flexBasis: "auto", ...(inner2 ? { borderRightColor: isHeader ? (innerRules.headerColor ?? innerRules.color) : innerRules.color } : {}) } : {}),
|
||||
...(grid
|
||||
? {
|
||||
flexGrow: 1,
|
||||
@@ -229,14 +245,14 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
}}
|
||||
>
|
||||
{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) - (look.padLeft?.[i] ?? 0) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - (look.padLeft?.[i] ?? 0) - ruleW)} align={alignOf} faceOverrides={look.faceOverrides}>
|
||||
{partText(part)}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
);
|
||||
});
|
||||
const rowAlign = grid ? ("stretch" as const) : undefined;
|
||||
const rowAlign = grid || innerRules ? ("stretch" as const) : undefined;
|
||||
const inset = pill?.inset ?? 0;
|
||||
const cellsWidth = width - 2 * inset;
|
||||
/** One table line: a plain Row, or for pills an outer box (the fill) around a Row of the inner width. */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Line, Svg } from "@react-pdf/renderer";
|
||||
|
||||
export interface CapRuleProps {
|
||||
/** Total width of the Svg, pt. */
|
||||
width: number;
|
||||
/** The grey line: its ends are round caps, so x runs between its outer edges. */
|
||||
line: { from: number; to: number; thickness: number; color: string };
|
||||
/** Thicker accent segments drawn over the line (round caps, centred on the line), pt from the left. */
|
||||
accents: Array<{ from: number; to: number; thickness: number; color: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grey rule with round ends and short accent segments over it (Cobalt Stripe's header and footer rules). One static
|
||||
* Svg, so it stays in the flow with a pinned size and needs no absolute positioning. Every `from`/`to` is the outer
|
||||
* edge of the segment including its caps.
|
||||
*/
|
||||
export function CapRule({ width, line, accents }: CapRuleProps) {
|
||||
const h = Math.max(line.thickness, ...accents.map((a) => a.thickness));
|
||||
const seg = (s: { from: number; to: number; thickness: number; color: string }, key: number) => {
|
||||
const r = s.thickness / 2;
|
||||
return <Line key={key} x1={s.from + r} y1={h / 2} x2={Math.max(s.from + r, s.to - r)} y2={h / 2} stroke={s.color} strokeWidth={s.thickness} strokeLinecap="round" />;
|
||||
};
|
||||
return (
|
||||
<Svg width={width} height={h} viewBox={`0 0 ${width} ${h}`}>
|
||||
{seg(line, -1)}
|
||||
{accents.map((a, i) => seg(a, i))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { setupPdfTest } from "../testing/setup";
|
||||
import { loadCanvas } from "../testing/rasterize";
|
||||
import { inkPixels } from "../testing/rasterize";
|
||||
import { cornerSwoosh, mirrorPath, SWOOSH_BOTTOM_LEFT, SWOOSH_BOX, SWOOSH_PAGE, SWOOSH_TOP_RIGHT, swooshKeepOuts } from "./CornerSwoosh";
|
||||
import { CapRule } from "./CapRule";
|
||||
import { CircledIcon, ContactIcon } from "./CircledIcon";
|
||||
import { fullBleedBand, GradientDefs } from "./FullBleedBand";
|
||||
import { pennant, pennantBox, TANGERINE_TABS } from "./Pennant";
|
||||
@@ -144,3 +145,33 @@ describe("icons", () => {
|
||||
expect(await inkPixels(canvas, r.bytes, { x0: 40, y0: 40, x1: 43, y1: 43 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CapRule", () => {
|
||||
it("is one static Svg of the rule's own size, so it stays in the flow", async () => {
|
||||
const r = await renderCore(
|
||||
h(Document, null, h(Page, { size: "A4", style: { padding: 40 } }, h(CapRule, { width: 300, line: { from: 0.75, to: 300, thickness: 1.5, color: "#A6A6A6" }, accents: [{ from: 1.5, to: 37.2, thickness: 3, color: "#51ADE5" }] }))),
|
||||
{ frame: frame("A4") },
|
||||
);
|
||||
const [svg] = find(r.layout[0], (n) => n.type === "svg");
|
||||
expect(find(r.layout[0], (n) => n.type === "svg")).toHaveLength(1);
|
||||
expect([svg.box.w, svg.box.h]).toEqual([300, 3]);
|
||||
expect(svg.abs).toBeFalsy();
|
||||
});
|
||||
|
||||
it("draws the accent thicker than the grey line, with rounded ends inside the segment's outer edges", async () => {
|
||||
const canvas = await loadCanvas();
|
||||
if (!canvas) return;
|
||||
const r = await renderCore(
|
||||
h(Document, null, h(Page, { size: "A4", style: { padding: 40 } }, h(CapRule, { width: 300, line: { from: 0.75, to: 300, thickness: 1.5, color: "#000000" }, accents: [{ from: 1.5, to: 37.2, thickness: 3, color: "#000000" }] }))),
|
||||
{ frame: frame("A4") },
|
||||
);
|
||||
const band = (x0: number, x1: number) => inkPixels(canvas, r.bytes, { x0: 40 + x0, y0: 40, x1: 40 + x1, y1: 43 });
|
||||
// Per unit of width the accent's strip carries clearly more ink than the grey line (3 pt against 1.5 pt, at pixel resolution).
|
||||
const accent = (await band(10, 30)) / 20;
|
||||
const grey = (await band(100, 120)) / 20;
|
||||
expect(accent / grey).toBeGreaterThan(1.3);
|
||||
expect(accent / grey).toBeLessThan(2.4);
|
||||
// Nothing is drawn beyond the line's outer end.
|
||||
expect(await band(301, 304)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("registry and catalog", () => {
|
||||
expect(catalogEntry(t.id).name).toBe(t.name);
|
||||
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
|
||||
}
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
||||
});
|
||||
|
||||
it("both are Montserrat throughout, and their slots are the measured Canva ones", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("template catalog", () => {
|
||||
it("has one entry per registered template, in the registry's order, ids unique", () => {
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
expect(new Set(TEMPLATE_CATALOG.map((e) => e.id)).size).toBe(TEMPLATE_CATALOG.length);
|
||||
expect(TEMPLATE_CATALOG).toHaveLength(10);
|
||||
expect(TEMPLATE_CATALOG).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("groups the C4 templates into the Ledger and Band families", () => {
|
||||
@@ -20,6 +20,11 @@ describe("template catalog", () => {
|
||||
expect(FAMILY_LABELS.band).toBe("Band");
|
||||
});
|
||||
|
||||
it("lists the standalone templates, Monolith and Cobalt Stripe, last", () => {
|
||||
expect(TEMPLATE_CATALOG.filter((e) => e.family === "standalone").map((e) => e.id)).toEqual(["monolith", "cobalt-stripe"]);
|
||||
expect(FAMILY_LABELS.standalone).toBe("Standalone");
|
||||
});
|
||||
|
||||
it("every entry has a name, a family label and a thumbnail on disk; every definition agrees", () => {
|
||||
for (const e of TEMPLATE_CATALOG) {
|
||||
expect(e.name.length, e.id).toBeGreaterThan(0);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export interface TemplateCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
family: "classic" | "neutral" | "ledger" | "band" | "northline";
|
||||
family: "classic" | "neutral" | "ledger" | "band" | "northline" | "standalone";
|
||||
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
|
||||
thumbnail: string;
|
||||
badge?: "Matches your original format";
|
||||
@@ -19,6 +19,7 @@ export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
|
||||
ledger: "Ledger",
|
||||
band: "Band",
|
||||
northline: "Northline",
|
||||
standalone: "Standalone",
|
||||
};
|
||||
|
||||
/** Picker order: Classic first, Linea (the vendor's original format) second. */
|
||||
@@ -33,6 +34,8 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
|
||||
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
|
||||
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true },
|
||||
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
|
||||
{ id: "monolith", name: "Monolith", family: "standalone", thumbnail: "/templates/monolith.png" },
|
||||
{ id: "cobalt-stripe", name: "Cobalt Stripe", family: "standalone", thumbnail: "/templates/cobalt-stripe.png" },
|
||||
];
|
||||
|
||||
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("classic plan matches the laid-out blocks", () => {
|
||||
|
||||
describe("registry", () => {
|
||||
it("has Classic and falls back to it for an unknown or missing id", () => {
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
||||
expect(getTemplate("classic")).toBe(classic);
|
||||
expect(getTemplate("no-such-template")).toBe(classic);
|
||||
expect(getTemplate(undefined)).toBe(classic);
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { pinned } from "../../engine/columns";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
|
||||
import { BottomSpacer } from "../../blocks/BottomSpacer";
|
||||
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
|
||||
import { IdText } from "../../blocks/IdText";
|
||||
import { TableRows } from "../../blocks/ItemsTable";
|
||||
import { Row } from "../../blocks/Row";
|
||||
import { SafeTxt } from "../../blocks/SafeTxt";
|
||||
import { Slot, slotHeights } from "../../blocks/slots";
|
||||
import { TableGuard } from "../../blocks/TableGuard";
|
||||
import { Tracked } from "../../blocks/Tracked";
|
||||
import { Txt } from "../../blocks/Txt";
|
||||
import { CapRule } from "../../decor/CapRule";
|
||||
import { ContactIcon } from "../../decor/CircledIcon";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import type { PlacedLogo, TemplateProps } from "../contract";
|
||||
import { includesName } from "../logoName";
|
||||
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
|
||||
import { labelWithColon, NOTES_FLOW_GAP, planCobalt, signatureOptions, type CobaltPlan } from "./plan";
|
||||
import { COBALT, GREY, INK, RULE_GREY, SKY, WHITE_INK, ZEBRA, type CobaltVariant } from "./tokens";
|
||||
|
||||
interface Ctx {
|
||||
model: TemplateProps["model"];
|
||||
plan: CobaltPlan;
|
||||
v: CobaltVariant;
|
||||
logo: PlacedLogo | null;
|
||||
}
|
||||
|
||||
function Spacer({ h }: { h: number }) {
|
||||
return h > 0 ? <View style={{ height: h }} /> : null;
|
||||
}
|
||||
|
||||
// --- header -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function Tagline({ ctx }: { ctx: Ctx }) {
|
||||
const { model, v } = ctx;
|
||||
return <Slot.Gstin model={model} party="supplier" role={v.roles.tagline} color={INK} />;
|
||||
}
|
||||
|
||||
/** Logo, name and tagline: a mark sits beside the name, a wordmark replaces it with the tagline under the logo. */
|
||||
function Supplier({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v, logo } = ctx;
|
||||
const h = plan.head;
|
||||
const image = logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, objectFit: "contain" } as never} /> : null;
|
||||
if (h.mode === "name") {
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={{ ...pinned(h.leftW), flexDirection: "row" } as never}>
|
||||
{image ? <View style={{ marginTop: v.head.logoTop }}>{image}</View> : null}
|
||||
<View style={{ ...pinned(h.nameW), marginLeft: logo ? v.head.markGap : 0, marginTop: v.head.nameTop } as never}>
|
||||
<AvailableWidthProvider width={h.nameW}>
|
||||
<SafeTxt role={v.roles.brand} color={INK}>
|
||||
{model.supplier.name}
|
||||
</SafeTxt>
|
||||
{h.tagline ? (
|
||||
<View style={{ marginTop: v.head.taglineGap }}>
|
||||
<Tagline ctx={ctx} />
|
||||
</View>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={pinned(h.leftW) as never}>
|
||||
{image ? <View style={{ marginTop: v.head.logoTop }}>{image}</View> : null}
|
||||
{h.tagline ? (
|
||||
<View style={{ ...pinned(h.leftW), marginTop: image ? 3 : 0 } as never}>
|
||||
<AvailableWidthProvider width={h.leftW}>
|
||||
<Tagline ctx={ctx} />
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const h = plan.head;
|
||||
return (
|
||||
<Row widths={[{ fill: true }, { fixed: h.titleW }]} width={plan.W} style={{ minHeight: h.h }}>
|
||||
<Supplier ctx={ctx} />
|
||||
<View style={pinned(h.titleW) as never}>
|
||||
<Tracked id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={COBALT} align="right" width={h.titleW}>
|
||||
{model.docTitle}
|
||||
</Tracked>
|
||||
</View>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRule({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const h = plan.head;
|
||||
const r = v.head.rule;
|
||||
const rule = (
|
||||
<View style={{ marginTop: (h.ruleH - r.accent) / 2 + r.drop }}>
|
||||
<CapRule width={h.ruleW} line={{ from: r.inset, to: h.ruleW, thickness: r.thickness, color: RULE_GREY }} accents={[{ from: r.inset + 0.7, to: r.inset + 0.7 + r.accentW, thickness: r.accent, color: SKY }]} />
|
||||
</View>
|
||||
);
|
||||
if (!h.ruleText) return <View style={{ ...pinned(plan.W), minHeight: h.ruleH } as never}>{rule}</View>;
|
||||
return (
|
||||
<Row widths={[{ fixed: h.ruleW }, { fixed: plan.W - h.ruleW }]} width={plan.W} style={{ minHeight: h.ruleH }}>
|
||||
{rule}
|
||||
<View style={{ ...pinned(h.ruleTextW), marginLeft: v.head.ruleTextGap } as never}>
|
||||
<AvailableWidthProvider width={h.ruleTextW}>
|
||||
<IdText role={v.roles.ruleText} color="#000000" align="right" fill width={h.ruleTextW}>
|
||||
{h.ruleText}
|
||||
</IdText>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
// --- info row ---------------------------------------------------------------------------------------------------
|
||||
|
||||
function ClientBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = model.client;
|
||||
const w = plan.info.clientW;
|
||||
const more = c.addressLines.length > 0 || slotHeights.gstinLines(model, "client") > 0 || slotHeights.placeOfSupplyLines(model, r.clientLine, w) > 0;
|
||||
return (
|
||||
<View style={pinned(w) as never}>
|
||||
<AvailableWidthProvider width={w}>
|
||||
<Txt role={r.label} color={INK} width={w}>
|
||||
Billed to :
|
||||
</Txt>
|
||||
<View style={{ marginBottom: more ? v.info.nameGap : 0 }}>
|
||||
<SafeTxt role={r.clientName} color={INK}>
|
||||
{c.name}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{c.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.clientLine} color={GREY}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
<Slot.Gstin model={model} party="client" role={r.clientLine} color={GREY} />
|
||||
<Slot.PlaceOfSupply model={model} role={r.clientLine} color={GREY} />
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const w = plan.info.metaW;
|
||||
return (
|
||||
<View id={META_ID} style={pinned(w) as never}>
|
||||
<AvailableWidthProvider width={w}>
|
||||
{plan.info.lines.map((l, i) => (
|
||||
<IdText key={i} role={l.strong ? v.roles.metaStrong : v.roles.metaLine} color={l.cancelled ? CANCELLED_INK : INK} align="right" fill width={w}>
|
||||
{l.text}
|
||||
</IdText>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- totals -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
const labelW = v.totals.labelW;
|
||||
return (
|
||||
<Row widths={[{ fixed: labelW }, { fixed: t.valueW }]} width={t.rightW}>
|
||||
<Txt role={v.roles.totalLabel} color={INK} width={labelW} align="right">
|
||||
{label}
|
||||
</Txt>
|
||||
<Txt role={v.roles.totalValue} color={INK} width={t.valueW - v.totals.valuePad} align="right">
|
||||
{amount}
|
||||
</Txt>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({ v, width, children }: { v: CobaltVariant; width: number; children: ReactNode }) {
|
||||
return <View style={{ ...pinned(width), minHeight: v.totals.barH, paddingTop: (v.totals.barH - (v.roles.barLabel.leading as number)) / 2, backgroundColor: COBALT } as never}>{children}</View>;
|
||||
}
|
||||
|
||||
function RightColumn({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const t = plan.tail;
|
||||
const o = v.totals;
|
||||
return (
|
||||
<View style={pinned(t.rightW) as never}>
|
||||
<AvailableWidthProvider width={t.rightW}>
|
||||
{t.stack.map((row, i) => (
|
||||
<StackRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
|
||||
))}
|
||||
<Slot.TaxRows model={model} role={r.totalLabel} color={INK} amountWidth={t.valueW} format={(label, rate) => labelWithColon(label, rate)} renderRow={(row) => <StackRow ctx={ctx} label={row.label} amount={row.amount} />} />
|
||||
<View style={{ marginTop: o.barGap }}>
|
||||
<Bar v={v} width={t.rightW}>
|
||||
<Row widths={[{ fixed: o.labelW }, { fixed: t.valueW }]} width={t.rightW}>
|
||||
<Txt role={r.barLabel} color={WHITE_INK} width={o.labelW} align="right">
|
||||
{"Grand total :"}
|
||||
</Txt>
|
||||
<Txt role={r.barValue} color={WHITE_INK} width={t.valueW - o.valuePad} align="right">
|
||||
{model.totals.totalText}
|
||||
</Txt>
|
||||
</Row>
|
||||
</Bar>
|
||||
</View>
|
||||
{t.wordsH > 0 ? (
|
||||
<View style={{ marginTop: o.wordsGap }}>
|
||||
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={GREY} align="right" />
|
||||
</View>
|
||||
) : null}
|
||||
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.words} color={GREY} style={{ marginTop: o.reverseGap, textAlign: "right" }} /> : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftColumn({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const t = plan.tail;
|
||||
const o = v.totals;
|
||||
if (!model.bank) return <View />;
|
||||
return (
|
||||
<View style={{ ...pinned(t.leftW), marginTop: t.leftTop } as never}>
|
||||
<AvailableWidthProvider width={t.leftW}>
|
||||
<Bar v={v} width={o.leftBarW}>
|
||||
<View style={{ marginLeft: o.barInset }}>
|
||||
<Txt role={r.barLabel} color={WHITE_INK} width={o.leftBarW - o.barInset}>
|
||||
Payment method :
|
||||
</Txt>
|
||||
</View>
|
||||
</Bar>
|
||||
<View style={{ marginTop: o.bankGap, marginLeft: 1.5, ...pinned(t.leftW - o.barInset) } as never}>
|
||||
<AvailableWidthProvider width={t.leftW - o.barInset}>
|
||||
{t.bank.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.bank} color={INK}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Tail({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
return (
|
||||
// The gap is inside the block, so the totals' height is what the last row's minPresenceAhead (tailH) asks for.
|
||||
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
|
||||
<View style={{ marginTop: plan.gaps.tail }}>
|
||||
<Row widths={[{ fill: true }, { fixed: plan.tail.rightW }]} gap={v.totals.leftGap} width={plan.W}>
|
||||
<LeftColumn ctx={ctx} />
|
||||
<RightColumn ctx={ctx} />
|
||||
</Row>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- closing ----------------------------------------------------------------------------------------------------
|
||||
|
||||
function Terms({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const lines = plan.closing.terms;
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<View style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
{/* The heading travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
|
||||
<View wrap={false}>
|
||||
<Txt role={r.termsHead} color={INK} width={width} style={{ marginBottom: 3.8 }}>
|
||||
Term and Conditions :
|
||||
</Txt>
|
||||
<SafeTxt role={r.terms} color={GREY}>
|
||||
{lines[0]}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{lines.slice(1).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.terms} color={GREY} wrap={false}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Contacts({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = plan.closing;
|
||||
const { size, gap } = v.closing.icon;
|
||||
return (
|
||||
<Row widths={c.contacts.map((x) => ({ fixed: size + gap + x.textW }))} gap={c.contactGap} width={c.contactsW}>
|
||||
{c.contacts.map((x, i) => (
|
||||
<View key={i} style={{ ...pinned(size + gap + x.textW), flexDirection: "row" } as never}>
|
||||
<ContactIcon name={x.icon} size={size} color={SKY} />
|
||||
<View style={{ ...pinned(x.textW), marginLeft: gap, marginTop: Math.max(0, (size - (r.contact.leading as number)) / 2) } as never}>
|
||||
<AvailableWidthProvider width={x.textW}>
|
||||
{x.id ? (
|
||||
<IdText role={r.contact} color={INK}>
|
||||
{x.lines[0]}
|
||||
</IdText>
|
||||
) : (
|
||||
x.lines.map((line, j) => (
|
||||
<SafeTxt key={j} role={r.contact} color={INK}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))
|
||||
)}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function Closing({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = plan.closing;
|
||||
const cl = v.closing;
|
||||
const rule = v.head.rule;
|
||||
const opts = signatureOptions(v);
|
||||
const W = plan.W;
|
||||
return (
|
||||
<>
|
||||
<BottomSpacer closingH={plan.closingH} />
|
||||
<View id={CLOSING_ID} wrap={false} style={pinned(W) as never}>
|
||||
<AvailableWidthProvider width={W}>
|
||||
<Row widths={[{ fill: true }, { fixed: c.signCol }]} width={W}>
|
||||
<View style={pinned(c.leftW) as never}>
|
||||
<CapRule width={cl.divider.w} line={{ from: 0, to: cl.divider.w, thickness: cl.divider.thickness, color: RULE_GREY }} accents={[]} />
|
||||
<Spacer h={cl.dividerGap} />
|
||||
<Txt role={r.thanks} color={INK} width={c.leftW}>
|
||||
Thank you for business with us!
|
||||
</Txt>
|
||||
{c.termsBeside && c.terms.length ? (
|
||||
<View style={{ marginTop: plan.gaps.terms }}>
|
||||
<Terms ctx={ctx} width={c.leftW} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Slot.Signatory model={model} width={cl.signW} {...opts} />
|
||||
</Row>
|
||||
<Spacer h={plan.gaps.footer} />
|
||||
<CapRule
|
||||
width={W}
|
||||
line={{ from: cl.footerRule.inset, to: W - cl.footerRule.inset, thickness: rule.thickness, color: RULE_GREY }}
|
||||
accents={[
|
||||
{ from: cl.footerRule.accentInset, to: cl.footerRule.accentInset + cl.footerRule.accentW, thickness: rule.accent, color: SKY },
|
||||
{ from: W - cl.footerRule.accentInset - cl.footerRule.accentW, to: W - cl.footerRule.accentInset, thickness: rule.accent, color: SKY },
|
||||
]}
|
||||
/>
|
||||
{c.contacts.length ? (
|
||||
<View style={{ marginTop: cl.ruleToContacts }}>
|
||||
<Contacts ctx={ctx} />
|
||||
</View>
|
||||
) : null}
|
||||
{model.cancelled ? (
|
||||
<Txt role={r.fine} color={CANCELLED_INK} width={W} style={{ marginTop: 10 }}>
|
||||
{CANCELLED_SENTENCE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- the layout -------------------------------------------------------------------------------------------------
|
||||
|
||||
export function CobaltBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: CobaltVariant }) {
|
||||
const plan = planCobalt(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const r = v.roles;
|
||||
const notesFlow: ReactNode = plan.closing.termsBeside ? null : (
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Terms ctx={ctx} width={plan.closing.flowW} />
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
<HeaderRow ctx={ctx} />
|
||||
<Spacer h={plan.head.ruleGap} />
|
||||
<HeaderRule ctx={ctx} />
|
||||
<Spacer h={plan.gaps.client} />
|
||||
<Row widths={[{ fixed: plan.info.clientW }, { fixed: plan.info.metaW }]} gap={v.info.clientGap} width={plan.W}>
|
||||
<ClientBlock ctx={ctx} />
|
||||
<MetaBlock ctx={ctx} />
|
||||
</Row>
|
||||
</FirstPageHeader>
|
||||
|
||||
<Spacer h={plan.gaps.table} />
|
||||
|
||||
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
|
||||
<View style={pinned(plan.W) as never}>
|
||||
<TableRows
|
||||
columns={plan.columns}
|
||||
gap={0}
|
||||
width={plan.W}
|
||||
header={plan.headerCells}
|
||||
rows={plan.rows}
|
||||
roles={{ head: r.tableHead, body: r.tableBody }}
|
||||
align={plan.align}
|
||||
headAlign={plan.headAlign}
|
||||
padX={0}
|
||||
padLeft={plan.padLeft}
|
||||
padRight={plan.padRight}
|
||||
padY={plan.padY}
|
||||
headPadY={plan.headPadY}
|
||||
border={0}
|
||||
headColor={WHITE_INK}
|
||||
headerStyle={{ backgroundColor: COBALT }}
|
||||
headCellStyle={() => v.table.headPad}
|
||||
innerRules={{ width: v.table.separator, color: ZEBRA, headerColor: COBALT }}
|
||||
rowStyle={(i) => (i % 2 === 1 ? { backgroundColor: ZEBRA } : {})}
|
||||
tailH={plan.tailH}
|
||||
tail={<Tail ctx={ctx} />}
|
||||
/>
|
||||
</View>
|
||||
{notesFlow}
|
||||
<Closing ctx={ctx} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
|
||||
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
||||
import { SLOT_ID } from "../../blocks/slots";
|
||||
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
|
||||
import { loadCanvas, pagePng } from "../../testing/rasterize";
|
||||
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
|
||||
import { marginsFor } from "../contract";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
|
||||
import { getTemplate, TEMPLATES } from "../registry";
|
||||
import { SLOTS } from "../slots.generated";
|
||||
import { cobaltStripe } from "./cobalt";
|
||||
import { planCobalt } from "./plan";
|
||||
import { COBALT, COBALT_ROLES, COBALT_VARIANT, SKY, ZEBRA } from "./tokens";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const V = COBALT_VARIANT;
|
||||
const slot = SLOTS["cobalt-stripe"]!;
|
||||
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
||||
const out: LayoutNode[] = [];
|
||||
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
type R = Awaited<ReturnType<typeof renderFixture>>;
|
||||
const onPage = (r: R, i: number) => nodes([r.result.core.layout[i]]);
|
||||
const imageBoxes = (r: R) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
||||
const byId = (r: R, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
|
||||
const textLines = (root: LayoutNode) => {
|
||||
const out: Array<{ text: string; x: number; right: number; y: number }> = [];
|
||||
walk([root], (n) => {
|
||||
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y });
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const contentRight = (r: R) => {
|
||||
const f = frameForPrefs(r.prefs);
|
||||
return f.page.width - f.pad.right;
|
||||
};
|
||||
/** The Canva sample's shape: no due date, no logo, nothing the design has no slot for. */
|
||||
const NAMES = ["Logo Design", "Website Design", "Brand Design", "Banner Design", "Flyer Design", "Social Media Template", "Name Card", "Web Developer"];
|
||||
const PLAIN: FixtureSpec = {
|
||||
name: "plain",
|
||||
items: NAMES.map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i })),
|
||||
registered: false,
|
||||
logo: "none",
|
||||
invoice: { dueDate: "", notes: "" },
|
||||
};
|
||||
|
||||
/** RGB of pixels of page 1 at 4x. */
|
||||
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise<number[][]> {
|
||||
const lib = await loadCanvas();
|
||||
if (!lib) return [];
|
||||
const png = await pagePng(lib, bytes, 1, 4);
|
||||
const img = await lib.loadImage(png);
|
||||
const c = lib.createCanvas(img.width, img.height);
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
return points.map(([x, y]) => Array.from(ctx.getImageData(Math.round(x * 4), Math.round(y * 4), 1, 1).data).slice(0, 3));
|
||||
}
|
||||
const near = (c: number[], hex: string, tol = 8) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).every((w, i) => Math.abs(c[i] - w) <= tol);
|
||||
|
||||
describe("registry, catalog and fonts", () => {
|
||||
it("is registered last, as a standalone template with its thumbnail on disk", () => {
|
||||
expect(getTemplate("cobalt-stripe")).toBe(cobaltStripe);
|
||||
expect(cobaltStripe.family).toBe("standalone");
|
||||
expect(catalogEntry("cobalt-stripe").name).toBe("Cobalt Stripe");
|
||||
expect(existsSync(path.join(process.cwd(), "public", cobaltStripe.thumbnail)), cobaltStripe.thumbnail).toBe(true);
|
||||
expect(Object.keys(TEMPLATES).at(-1)).toBe("cobalt-stripe");
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
it("uses Poppins 400/700 and Open Sans 400/700 only, and the design's palette only", () => {
|
||||
for (const [name, token] of Object.entries(COBALT_ROLES)) {
|
||||
expect(["Poppins", "Open Sans"], name).toContain(token.face);
|
||||
expect([400, 700], name).toContain(token.weight);
|
||||
}
|
||||
const palette = new Set([COBALT, ZEBRA, SKY, "#1B1B1B", "#595A5A", "#A6A6A6", "#FFFFFF", "#000000"].map((c) => c.toLowerCase()));
|
||||
const dir = path.join(process.cwd(), "src/pdf/templates/cobalt");
|
||||
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
|
||||
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{6}\b/g) ?? [];
|
||||
expect(colours.filter((c) => !palette.has(c.toLowerCase())), f).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("page geometry", () => {
|
||||
it("template margins: the logo's top is the page margin, the footer's bottom sits 59.5 pt above the page foot", () => {
|
||||
expect(marginsFor(cobaltStripe, "template")).toEqual({ top: 54.2, right: 59.55, bottom: 35.5, left: 59.55 });
|
||||
const frame = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(frame.pad.bottom).toBeCloseTo(59.5, 1);
|
||||
expect(frame.contentWidth).toBeCloseTo(476.18, 1);
|
||||
});
|
||||
|
||||
it("Letter keeps the physical margins and gives the extra width to the description", async () => {
|
||||
const a4 = frameForPrefs(prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "cobalt-stripe" }));
|
||||
expect(letter.pad).toEqual(a4.pad);
|
||||
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
||||
const model = buildFixture(PLAIN).model;
|
||||
const pa = planCobalt(model, a4, V, null, false);
|
||||
const pl = planCobalt(model, letter, V, null, false);
|
||||
expect(pl.cw).toEqual(pa.cw);
|
||||
expect(pl.table.widths[1] - pa.table.widths[1]).toBeCloseTo(letter.contentWidth - a4.contentWidth, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("header", () => {
|
||||
it("matches the design: name, INVOICE and the rule text at the Canva baselines, INVOICE and the rule text ending on the right edge", async () => {
|
||||
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const items = r.pdf.pages[0].items;
|
||||
const right = contentRight(r);
|
||||
const name = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
|
||||
const title = items.find((i) => i.str.trim() === "INVOICE")!;
|
||||
const pan = items.find((i) => i.str.startsWith("PAN"))!;
|
||||
expect(name.bottom).toBeCloseTo(74.6, 0);
|
||||
expect(name.x0).toBeCloseTo(59.5, 0);
|
||||
expect(title.bottom).toBeCloseTo(87.3, 0);
|
||||
expect(title.x0).toBeCloseTo(395.6, 0);
|
||||
expect(pan.bottom).toBeCloseTo(117.1, 0);
|
||||
expect(pan.x1).toBeCloseTo(right, 0);
|
||||
// INVOICE is tracked: its ink ends on the edge (the trailing letter-spacing is cancelled by Tracked).
|
||||
expect(title.x1).toBeLessThanOrEqual(right + 1.5);
|
||||
expect(title.x1).toBeGreaterThan(right - 3);
|
||||
});
|
||||
|
||||
it("the GSTIN is the tagline under the name and the PAN is the rule text; the Canva script signature is not reproduced", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const supplier = textLines(byId(r, SUPPLIER_ID)).map((l) => l.text);
|
||||
expect(supplier).toEqual(expect.arrayContaining(["Test Vendor", "GSTIN: 29AAAAA0000A1Z5"]));
|
||||
expect(r.pdf.pages[0].items.some((i) => i.str.startsWith("PAN: ABCDE1234F"))).toBe(true);
|
||||
expect(r.pdf.raw).not.toMatch(/BDScript|BD Script/i);
|
||||
});
|
||||
|
||||
it("TAX INVOICE takes the room it needs and the left block shrinks; a long invoice number never meets the title", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const meta = textLines(byId(r, META_ID));
|
||||
expect(Math.max(...meta.map((l) => l.right))).toBeLessThanOrEqual(contentRight(r) + 0.5);
|
||||
const supplier = byId(r, SUPPLIER_ID);
|
||||
const title = byId(r, TITLE_ID);
|
||||
expect(supplier.box.x + supplier.box.w).toBeLessThanOrEqual(title.box.x + 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it("the grey rule has its sky accent at the left and sits under the header at the design's y (113.8)", async () => {
|
||||
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const svgs = onPage(r, 0).filter((n) => n.type === "svg" && !n.decor);
|
||||
const rule = svgs.find((n) => n.box.h === V.head.rule.accent && n.box.w > 300)!;
|
||||
expect(rule.box.y + rule.box.h / 2).toBeCloseTo(113.8, 0);
|
||||
const [accent, grey] = await colourAt(r.result.bytes, [
|
||||
[rule.box.x + 20, rule.box.y + rule.box.h / 2],
|
||||
[rule.box.x + 120, rule.box.y + rule.box.h / 2],
|
||||
]);
|
||||
expect(near(accent, SKY), `accent ${accent}`).toBe(true);
|
||||
expect(near(grey, "#A6A6A6"), `grey ${grey}`).toBe(true);
|
||||
expect(rule.box.x).toBeCloseTo(frame.pad.left, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logo", () => {
|
||||
it("a wordmark fills the measured lockup box from its left edge and replaces the name; the tagline stays under it", async () => {
|
||||
const lockup = slot.lockup!;
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor(size, { templateId: "cobalt-stripe" }));
|
||||
const [img] = imageBoxes(r);
|
||||
expect(imageBoxes(r)).toHaveLength(1);
|
||||
expect(img.box.h).toBeLessThanOrEqual(lockup.h + 0.6);
|
||||
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.6);
|
||||
expect(img.box.x).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 1);
|
||||
expect(r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)).toHaveLength(0);
|
||||
const tag = r.pdf.pages[0].items.find((i) => i.str.startsWith("GSTIN: 29AAAAA"))!;
|
||||
expect(tag.top).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it("a mark keeps to the measured mark box and keeps the name beside it; with no logo the name is Poppins Bold at the design's baseline", async () => {
|
||||
const mark = slot.mark!;
|
||||
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const [img] = imageBoxes(square);
|
||||
expect(img.box.h).toBeLessThanOrEqual(mark.h + 0.6);
|
||||
expect(img.box.w).toBeLessThanOrEqual(mark.w + 0.6);
|
||||
const name = square.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
|
||||
expect(name.x0).toBeGreaterThanOrEqual(img.box.x + img.box.w + V.head.markGap - 0.6);
|
||||
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(imageBoxes(none)).toHaveLength(0);
|
||||
expect(none.pdf.pages[0].items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!.bottom).toBeCloseTo(74.6, 0);
|
||||
});
|
||||
|
||||
it("a tall logo is held to the lockup height", async () => {
|
||||
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
|
||||
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
|
||||
const result = await renderInvoicePdf({ model, prefs: prefsFor("A4", { templateId: "cobalt-stripe" }) });
|
||||
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const img = nodes(result.core.layout).find((n) => n.type === "image" && !n.decor)!;
|
||||
expect(img.box.h).toBeLessThanOrEqual(slot.mark!.h + 0.6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("table", () => {
|
||||
it("is a cobalt bar over zebra rows: 20.76 pt header, 17.82 pt rows, white and light-blue alternating, 1.5 pt light-blue separators", async () => {
|
||||
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const head = byId(r, TABLE_HEAD_ID);
|
||||
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
|
||||
const rows = [0, 1, 2, 3].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
||||
for (const row of rows) expect(row.box.h).toBeCloseTo(V.table.rowH, 0);
|
||||
const plan = planCobalt(r.fixture.model, frame, V, null, false);
|
||||
const left1 = frame.pad.left + plan.cw.no;
|
||||
const colours = await colourAt(r.result.bytes, [
|
||||
[head.box.x + 1.5, head.box.y + 10],
|
||||
[rows[0].box.x + 1.5, rows[0].box.y + 8],
|
||||
[rows[1].box.x + 1.5, rows[1].box.y + 8],
|
||||
[left1 - 0.75, rows[0].box.y + 8],
|
||||
[left1 - 0.75, head.box.y + 10],
|
||||
[left1 - 0.75, rows[1].box.y + 8],
|
||||
]);
|
||||
if (colours.length) {
|
||||
expect(near(colours[0], COBALT), `header ${colours[0]}`).toBe(true);
|
||||
expect(near(colours[1], "#FFFFFF"), `row 1 ${colours[1]}`).toBe(true);
|
||||
expect(near(colours[2], ZEBRA), `row 2 ${colours[2]}`).toBe(true);
|
||||
expect(near(colours[3], ZEBRA), `separator on a white row ${colours[3]}`).toBe(true);
|
||||
expect(near(colours[4], COBALT), `no separator in the header ${colours[4]}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("columns follow the design: NO 24.9, QTY 84.9, PRICE 105.4, TOTAL 89.3 pt; amounts end 3.1 pt inside the edge, prices 31.7 pt inside their column", async () => {
|
||||
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const plan = planCobalt(r.fixture.model, frame, V, null, false);
|
||||
expect(plan.cw).toMatchObject({ no: 24.9, qty: 84.9, price: 105.4, amount: 89.3 });
|
||||
const right = contentRight(r);
|
||||
const priceRight = right - plan.cw.amount - V.table.priceInset;
|
||||
// The headings follow the design: DESCRIPTION is centred over its left-aligned column, PRICE ends where the prices do.
|
||||
const heads = r.pdf.pages[0].items;
|
||||
const desc = heads.find((i) => i.str.trim() === "DESCRIPTION")!;
|
||||
const descLeft = frame.pad.left + plan.cw.no;
|
||||
expect((desc.x0 + desc.x1) / 2).toBeCloseTo(descLeft + plan.table.widths[1] / 2, 0);
|
||||
expect(heads.find((i) => i.str.trim() === "PRICE")!.x1).toBeCloseTo(priceRight, 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
||||
const amount = lines.find((l) => /^₹[\d,]+\.\d\d$/.test(l.text) && l.right > right - 20)!;
|
||||
expect(amount.right, `row ${i}`).toBeCloseTo(right - V.table.amountInset, 0);
|
||||
const price = lines.find((l) => l.text.endsWith("/hr"))!;
|
||||
expect(price.right, `row ${i} price`).toBeCloseTo(priceRight, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("totals", () => {
|
||||
it("Sub Total rows end 3.1 pt inside the edge, labels end 95.9 pt into the 214.5 pt column; the GRAND TOTAL and PAYMENT METHOD bars share a top", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const totals = byId(r, TOTALS_ID);
|
||||
const lines = textLines(totals);
|
||||
const right = contentRight(r);
|
||||
const colLeft = right - V.totals.rightW;
|
||||
const sub = lines.find((l) => l.text === "Sub Total :")!;
|
||||
expect(sub.right).toBeCloseTo(colLeft + V.totals.labelW, 0);
|
||||
expect(lines.find((l) => l.text === r.fixture.model.totals.subtotalText)!.right).toBeCloseTo(right - V.totals.valuePad, 0);
|
||||
const bars = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && n.box.y >= totals.box.y);
|
||||
// The Sub Total rows are as tall and wide as the bar, so the bar is the lowest of them.
|
||||
const grand = bars.filter((n) => Math.abs(n.box.w - V.totals.rightW) < 0.5).sort((a, b) => b.box.y - a.box.y)[0];
|
||||
const pay = bars.find((n) => Math.abs(n.box.w - V.totals.leftBarW) < 0.5)!;
|
||||
expect(grand.box.x).toBeCloseTo(colLeft, 1);
|
||||
expect(pay.box.x).toBeCloseTo(frame.pad.left, 1);
|
||||
expect(pay.box.y).toBeCloseTo(grand.box.y, 0);
|
||||
const [g, p] = await colourAt(r.result.bytes, [
|
||||
[grand.box.x + 3, grand.box.y + 2],
|
||||
[pay.box.x + 3, pay.box.y + 2],
|
||||
]);
|
||||
if (g) {
|
||||
expect(near(g, COBALT)).toBe(true);
|
||||
expect(near(p, COBALT)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("amount in words sits right-aligned under the GRAND TOTAL bar", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const words = byId(r, SLOT_ID.amountInWords);
|
||||
const totals = byId(r, TOTALS_ID);
|
||||
const grand = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.totals.barH) < 0.5 && Math.abs(n.box.w - V.totals.rightW) < 0.5 && n.box.y >= totals.box.y).sort((a, b) => b.box.y - a.box.y)[0];
|
||||
expect(words.box.y).toBeGreaterThanOrEqual(grand.box.y + grand.box.h);
|
||||
for (const l of textLines(words)) expect(l.right).toBeCloseTo(contentRight(r), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing and footer", () => {
|
||||
it("ends with the icon footer: a rule with accents at both ends and three contacts, all inside the content box", async () => {
|
||||
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const last = r.result.pages - 1;
|
||||
const closing = byId(r, CLOSING_ID, last);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const svgs = onPage(r, last).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y - 0.01);
|
||||
const icons = svgs.filter((n) => n.box.w === V.closing.icon.size);
|
||||
expect(icons).toHaveLength(3);
|
||||
const rule = svgs.find((n) => n.box.w > 400 && n.box.h === V.head.rule.accent)!;
|
||||
expect(rule.box.w).toBeCloseTo(frame.contentWidth, 1);
|
||||
for (const i of icons) {
|
||||
expect(i.box.y).toBeGreaterThanOrEqual(rule.box.y + rule.box.h);
|
||||
expect(i.box.x + i.box.w).toBeLessThanOrEqual(contentRight(r) + 0.5);
|
||||
}
|
||||
expect(r.result.pages).toBe(1);
|
||||
const sample = await colourAt(r.result.bytes, [[rule.box.x + 20, rule.box.y + 1.5], [rule.box.x + 200, rule.box.y + 1.5], [rule.box.x + rule.box.w - 20, rule.box.y + 1.5]]);
|
||||
if (sample.length) {
|
||||
expect(near(sample[0], SKY), `left accent ${sample[0]}`).toBe(true);
|
||||
expect(near(sample[1], "#A6A6A6"), `grey ${sample[1]}`).toBe(true);
|
||||
expect(near(sample[2], SKY), `right accent ${sample[2]}`).toBe(true);
|
||||
}
|
||||
// The closing block ends the page: its bottom is the wrap area's bottom.
|
||||
expect(closing.box.y + closing.box.h).toBeCloseTo(frame.page.height - frame.pad.bottom, 0);
|
||||
const text = r.pdf.pages[last].items.map((i) => i.str).join(" ");
|
||||
expect(text).toContain("9000000000");
|
||||
expect(text).toContain("vendor@example.com");
|
||||
});
|
||||
|
||||
it("a pasted signature image replaces the script: right-aligned, at most 36 pt tall, with the signatory name and designation under it", async () => {
|
||||
const r = await renderFixture({ name: "s", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const sig = byId(r, SLOT_ID.signatory, r.result.pages - 1);
|
||||
const img = onPage(r, r.result.pages - 1).find((n) => n.type === "image" && n.box.y >= sig.box.y - 0.01 && n.box.y + n.box.h <= sig.box.y + sig.box.h + 0.01)!;
|
||||
expect(img.box.h).toBeLessThanOrEqual(V.closing.signatureImageH + 0.01);
|
||||
const text = textLines(sig).map((l) => l.text);
|
||||
expect(text).toEqual(expect.arrayContaining(["Test Vendor", "Proprietor"]));
|
||||
// Without an image: the computer-generated note stands in.
|
||||
const plain = await renderFixture({ name: "s", items: 2, logo: "none", signature: false }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(plain.pdf.pages[plain.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("Computer-generated");
|
||||
});
|
||||
|
||||
it("long terms flow as a block of their own and keep the closing block bounded", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const plan = planCobalt(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
expect(plan.closing.termsBeside).toBe(false);
|
||||
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
|
||||
expect(r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" ")).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("an address, e-mail and phone that are too long for one row shrink and wrap inside the footer", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
|
||||
for (const l of textLines(closing)) expect(l.right).toBeLessThanOrEqual(contentRight(r) + 0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the GST slots", () => {
|
||||
it("every GST slot has a home on a tax invoice", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
|
||||
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
|
||||
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
|
||||
for (const word of ["SUBTOTAL:", "TAXABLEVALUE:", "CGST9%:", "SGST9%:", "HSN/SAC:998397", "PAN:ABCDE1234F"]) expect(flat, word).toContain(word);
|
||||
});
|
||||
|
||||
it("an unregistered invoice carries none of the GST slots", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
|
||||
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("long text", () => {
|
||||
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "cobalt-stripe" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("wrapped description rows grow and keep their zebra colour and separators (no row is taller than its text)", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "multiline")!, prefsFor("A4", { templateId: "cobalt-stripe" }));
|
||||
const rows = [0, 1, 2].map((i) => byId(r, `${TABLE_ROW_PREFIX}${i}`));
|
||||
expect(rows[0].box.h).toBeGreaterThan(3 * 13.6);
|
||||
expect(rows[2].box.h).toBeGreaterThan(rows[1].box.h);
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan matches the laid-out blocks", () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const spec of FIXTURES) {
|
||||
it(`${spec.name} on ${size}`, async () => {
|
||||
const prefs = prefsFor(size, { templateId: "cobalt-stripe" });
|
||||
const frame = frameForPrefs(prefs);
|
||||
// Logo-less, so the plan made here is the one the layout made.
|
||||
const r = await renderFixture({ ...spec, logo: "none" }, prefs);
|
||||
const find = (id: string) => {
|
||||
const hits: number[] = [];
|
||||
for (const page of r.result.core.layout) walk(page.nodes, (n) => n.id === id && hits.push(n.box.h));
|
||||
expect(hits).toHaveLength(1);
|
||||
return hits[0];
|
||||
};
|
||||
const plan = planCobalt(r.fixture.model, frame, V, null, false);
|
||||
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(24);
|
||||
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(32);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("design docs", () => {
|
||||
it("records its implementation notes", () => {
|
||||
const text = readFileSync(path.join(process.cwd(), "docs/templates/08-cobalt-stripe.md"), "utf8");
|
||||
expect(text).toContain("## Implementation notes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition, TemplateProps } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { CobaltBody } from "./Layout";
|
||||
import { COBALT_ROLES, COBALT_VARIANT, GREY, INK } from "./tokens";
|
||||
|
||||
export { COBALT_ROLES, COBALT_VARIANT } from "./tokens";
|
||||
|
||||
const CobaltLayout: ComponentType<TemplateProps> = (props) => <CobaltBody {...props} variant={COBALT_VARIANT} />;
|
||||
CobaltLayout.displayName = "CobaltLayout";
|
||||
|
||||
const slot = slotFor("cobalt-stripe");
|
||||
/** The measured lockup (mark + name placeholder): the room a wordmark may fill; a mark keeps to the mark box. */
|
||||
const lockup = slot?.lockup ?? { w: 181.5, h: 34.25 };
|
||||
|
||||
export const cobaltStripe: TemplateDefinition = {
|
||||
...catalogEntry("cobalt-stripe"),
|
||||
version: 1,
|
||||
roles: COBALT_ROLES,
|
||||
margins: COBALT_VARIANT.margins,
|
||||
marginFloor: COBALT_VARIANT.marginFloor,
|
||||
decor: { ink: INK },
|
||||
chrome: {
|
||||
color: GREY,
|
||||
rule: null,
|
||||
role: COBALT_ROLES.footer,
|
||||
leftWidth: COBALT_VARIANT.footerLeftW,
|
||||
},
|
||||
logo: {
|
||||
box: { w: lockup.w, h: lockup.h },
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: true,
|
||||
slotWidth: () => lockup.w,
|
||||
slot,
|
||||
available: () => ({ w: lockup.w, h: lockup.h }),
|
||||
},
|
||||
Layout: CobaltLayout,
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
import type { PlacedLogo } from "../contract";
|
||||
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
|
||||
import { INK, leading, type CobaltVariant } from "./tokens";
|
||||
|
||||
export const NOTES_FLOW_GAP = 14;
|
||||
const FIT_AIR = 2;
|
||||
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
|
||||
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
|
||||
/** Narrowest the footer address column may get before the phone and e-mail shrink to make room. */
|
||||
const MIN_ADDRESS_W = 120;
|
||||
/** Space between a mark and the tagline under a wordmark logo. */
|
||||
const WORDMARK_TAG_GAP = 3;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
|
||||
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
|
||||
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
|
||||
|
||||
function idLines(role: TypeToken, text: string, width: number): number {
|
||||
const f = fitSize(role, text, Math.max(1, width));
|
||||
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)) + 1);
|
||||
}
|
||||
|
||||
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
|
||||
|
||||
export interface MetaLine {
|
||||
strong: boolean;
|
||||
text: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
export function metaLines(model: RenderModel): MetaLine[] {
|
||||
const out: MetaLine[] = [...(model.cancelled ? [{ strong: true, text: "CANCELLED", cancelled: true }] : []), { strong: true, text: `${model.meta.numberLabel.replace(/\.$/, "")} : ${model.meta.number}` }, { strong: false, text: model.meta.date }];
|
||||
if (model.meta.dueDate) out.push({ strong: false, text: `Due date : ${model.meta.dueDate}` });
|
||||
if (model.client.poNumber) out.push({ strong: false, text: `PO : ${model.client.poNumber}` });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The bank block's lines: "Label : value", the design's "Bank Name : ..." and "Account Number : ...". */
|
||||
export function bankLines(model: RenderModel): string[] {
|
||||
const bank = model.bank;
|
||||
if (!bank) return [];
|
||||
const rename: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account Number" };
|
||||
const out = bank.lines.map((l) => `${rename[l.label] ?? l.label} : ${l.value}`);
|
||||
const at = bank.lines.findIndex((l) => l.label === "Bank") + 1;
|
||||
out.splice(at, 0, `Account Name : ${bank.accountName}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function signatureOptions(v: CobaltVariant): SignatoryOptions {
|
||||
const r = v.roles;
|
||||
return { roles: { name: r.signName, designation: r.signTitle, fine: r.fine }, align: "right", rule: null, color: INK, imageHeight: v.closing.signatureImageH };
|
||||
}
|
||||
|
||||
/** The table's no., description (+ HSN/SAC sub-line), qty, price and amount cells. */
|
||||
export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number): TableCellValue[][] {
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
|
||||
});
|
||||
}
|
||||
|
||||
export interface ContactItem {
|
||||
icon: "phone" | "email" | "location";
|
||||
lines: string[];
|
||||
/** Identifiers (phone, e-mail) shrink instead of wrapping; the address wraps. */
|
||||
id: boolean;
|
||||
/** Width of the text box beside the icon. */
|
||||
textW: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CobaltPlan {
|
||||
v: CobaltVariant;
|
||||
W: number;
|
||||
head: {
|
||||
titleSize: number;
|
||||
titleW: number;
|
||||
leftW: number;
|
||||
mode: "name" | "wordmark" | "none";
|
||||
showName: boolean;
|
||||
markW: number;
|
||||
nameW: number;
|
||||
nameH: number;
|
||||
tagline: boolean;
|
||||
taglineH: number;
|
||||
leftH: number;
|
||||
h: number;
|
||||
ruleGap: number;
|
||||
ruleText: string;
|
||||
ruleTextW: number;
|
||||
ruleW: number;
|
||||
ruleH: number;
|
||||
};
|
||||
info: { metaW: number; clientW: number; lines: MetaLine[]; metaH: number; clientH: number; h: number };
|
||||
columns: ColSpec[];
|
||||
headerCells: string[];
|
||||
rows: TableCellValue[][];
|
||||
align: Array<"left" | "center" | "right">;
|
||||
headAlign: Array<"left" | "center" | "right">;
|
||||
padLeft: number[];
|
||||
padRight: number[];
|
||||
padY: number;
|
||||
headPadY: number;
|
||||
cw: { no: number; qty: number; price: number; amount: number; priceInset: number };
|
||||
table: ItemsTablePlan;
|
||||
guardH: number;
|
||||
tail: {
|
||||
stack: Array<{ label: string; amount: string }>;
|
||||
slotRows: number;
|
||||
stackH: number;
|
||||
rightW: number;
|
||||
valueW: number;
|
||||
leftW: number;
|
||||
leftTop: number;
|
||||
/** The PAYMENT METHOD bar is level with the GRAND TOTAL bar (the design) rather than beside the first totals row. */
|
||||
aligned: boolean;
|
||||
bank: string[];
|
||||
bankH: number;
|
||||
wordsH: number;
|
||||
reverseH: number;
|
||||
rightH: number;
|
||||
leftH: number;
|
||||
body: number;
|
||||
};
|
||||
closing: {
|
||||
leftW: number;
|
||||
signCol: number;
|
||||
terms: string[];
|
||||
termsBeside: boolean;
|
||||
termsH: number;
|
||||
flowW: number;
|
||||
flowH: number;
|
||||
leftH: number;
|
||||
signH: number;
|
||||
rowH: number;
|
||||
contacts: ContactItem[];
|
||||
contactGap: number;
|
||||
contactsW: number;
|
||||
contactsH: number;
|
||||
footerH: number;
|
||||
h: number;
|
||||
};
|
||||
gaps: { client: number; table: number; tail: number; terms: number; footer: number };
|
||||
tailH: number;
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
function planContacts(model: RenderModel, v: CobaltVariant, W: number): { items: ContactItem[]; gap: number; width: number; h: number } {
|
||||
const s = model.supplier;
|
||||
const r = v.roles;
|
||||
const c = v.closing;
|
||||
const raw: Array<Pick<ContactItem, "icon" | "lines" | "id">> = [];
|
||||
if (s.phone) raw.push({ icon: "phone", lines: [s.phone], id: true });
|
||||
if (s.email) raw.push({ icon: "email", lines: [s.email], id: true });
|
||||
if (s.addressLines.length) raw.push({ icon: "location", lines: s.addressLines, id: false });
|
||||
const n = raw.length;
|
||||
if (n === 0) return { items: [], gap: 0, width: 0, h: 0 };
|
||||
const natural = raw.map((x) => Math.max(...x.lines.map((l) => widthOf(r.contact, l))));
|
||||
const iconBlock = c.icon.size + c.icon.gap;
|
||||
const budget = W - n * iconBlock - (n - 1) * c.minContactGap;
|
||||
let texts = natural;
|
||||
if (natural.reduce((a, b) => a + b, 0) > budget) {
|
||||
// Identifiers keep their natural width while the address (which wraps) keeps a usable column; otherwise they shrink
|
||||
// (to their minimum size, then chunk) to a share of the row and the address wraps in what is left.
|
||||
const ids = raw.reduce((a, x, i) => a + (x.id ? natural[i] : 0), 0);
|
||||
const free = raw.filter((x) => !x.id).length;
|
||||
const room = free ? (budget - ids) / free : 0;
|
||||
const cap = free === 0 ? Math.floor(budget / n) : room >= MIN_ADDRESS_W ? Infinity : Math.floor(budget * 0.3);
|
||||
const idW = raw.map((x, i) => (x.id ? Math.min(natural[i], cap) : 0));
|
||||
const rest = budget - idW.reduce((a, b) => a + b, 0);
|
||||
texts = raw.map((x, i) => (x.id ? idW[i] : Math.max(60, Math.floor(rest / Math.max(1, free)))));
|
||||
}
|
||||
const used = texts.reduce((a, b) => a + b, 0) + n * iconBlock;
|
||||
const gap = n > 1 ? Math.max(c.minContactGap, Math.min(c.contactGap, (W - used) / (n - 1))) : 0;
|
||||
const items = raw.map((x, i): ContactItem => {
|
||||
const textW = texts[i];
|
||||
const h = x.id ? idLines(r.contact, x.lines[0], textW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, textW), 0);
|
||||
return { ...x, textW, h };
|
||||
});
|
||||
const width = used + (n - 1) * gap;
|
||||
return { items, gap, width, h: Math.max(c.icon.size, ...items.map((x) => x.h + Math.max(0, (c.icon.size - leading(r.contact)) / 2))) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the layout must know before rendering: the header (the INVOICE title's size and the left block's mode),
|
||||
* the info row, the five table columns, the totals' two columns, the closing block and the footer's contact row, and
|
||||
* the heights of the tail and the closing block (their minPresenceAhead values), each a hair high rather than low.
|
||||
*/
|
||||
export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVariant, logo: PlacedLogo | null, includesName: boolean): CobaltPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const s = model.supplier;
|
||||
const c = model.client;
|
||||
const h = v.head;
|
||||
|
||||
// --- header ------------------------------------------------------------------------------------------------------------
|
||||
const tFit = fitSize(r.title, model.docTitle, Math.floor(W * 0.62));
|
||||
const titleW = Math.ceil(tFit.width) + 2;
|
||||
const leftW = W - titleW - h.titleGap;
|
||||
const showName = !(logo && includesName) && s.name.trim() !== "";
|
||||
const mode: "name" | "wordmark" | "none" = showName ? "name" : logo ? "wordmark" : "none";
|
||||
const markW = logo && showName ? logo.w : 0;
|
||||
const nameW = showName ? Math.max(h.minNameW, leftW - (markW ? markW + h.markGap : 0)) : 0;
|
||||
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
|
||||
const tagline = slotHeights.gstinLines(model, "supplier") > 0;
|
||||
const tagW = mode === "name" ? nameW : leftW;
|
||||
const taglineH = tagline ? idLines(r.tagline, GSTIN_SAMPLE, tagW) * leading(r.tagline) : 0;
|
||||
let headLeftH = 0;
|
||||
if (mode === "name") headLeftH = Math.max(h.nameTop + nameH + (tagline ? h.taglineGap + taglineH : 0), logo ? h.logoTop + logo.h : 0);
|
||||
else if (mode === "wordmark") headLeftH = h.logoTop + (logo?.h ?? 0) + (tagline ? WORDMARK_TAG_GAP + taglineH : 0);
|
||||
const headH = Math.max(headLeftH, leading(r.title));
|
||||
const ruleText = s.pan ? `PAN: ${s.pan}` : "";
|
||||
const ruleH = leading(r.ruleText);
|
||||
const ruleTextW = ruleText ? Math.min(widthOf(r.ruleText, ruleText), Math.floor(W * 0.38)) : 0;
|
||||
const ruleW = W - (ruleTextW ? ruleTextW + h.ruleTextGap : 0);
|
||||
const ruleGap = Math.max(h.ruleMinGap, h.ruleTop - headH);
|
||||
|
||||
// --- info row: Billed to at the left, the invoice numbers at the right --------------------------------------------------
|
||||
const lines = metaLines(model);
|
||||
const metaW = Math.min(Math.floor(W * v.info.metaMaxShare), Math.max(v.info.metaMinW, ...lines.map((l) => widthOf(l.strong ? r.metaStrong : r.metaLine, l.text))));
|
||||
const clientW = W - metaW - v.info.clientGap;
|
||||
let clientH = leading(r.label) + textHeight(r.clientName, c.name, clientW);
|
||||
const clientLines = c.addressLines.length + slotHeights.gstinLines(model, "client") + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW);
|
||||
if (clientLines > 0) clientH += v.info.nameGap;
|
||||
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, clientW);
|
||||
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, clientW) * leading(r.clientLine);
|
||||
const metaH = lines.reduce((a, l) => a + idLines(l.strong ? r.metaStrong : r.metaLine, l.text, metaW) * leading(r.metaLine), 0);
|
||||
const infoH = Math.max(clientH, metaH);
|
||||
|
||||
// --- table: NO | DESCRIPTION | QTY | PRICE | TOTAL ---------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const rule = t.separator;
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const amountTexts = model.items.map((i) => i.amountText);
|
||||
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
|
||||
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule);
|
||||
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
|
||||
const price = Math.max(t.cols.price, priceNeed);
|
||||
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
|
||||
const amount = Math.max(t.cols.amount, Math.max(widest(r.tableBody, amountTexts), widest(r.tableHead, ["Total"])) + t.amountInset + 8);
|
||||
const columns: ColSpec[] = [{ fixed: no }, { fill: true }, { fixed: qty }, { fixed: price }, { fixed: amount }];
|
||||
const headerCells = ["No", "Description", "Qty", "Price", "Total"];
|
||||
const align: CobaltPlan["align"] = ["center", "left", "center", "right", "right"];
|
||||
const headAlign: CobaltPlan["headAlign"] = ["center", "center", "center", "right", "center"];
|
||||
const padLeft = [0, t.descInset, 0, 0, 0];
|
||||
// The inner rule counts toward the inset, so the price ends `priceInset` short of its column edge as in the design.
|
||||
const padRight = [0, 6, 0, priceInset - rule, t.amountInset];
|
||||
const planPadRight = padRight.map((p, i) => p + innerRulePadRight(5, rule)[i]);
|
||||
const headPadY = (t.headH - leading(r.tableHead)) / 2;
|
||||
const rows = tableRows(model, v, price - rule - padRight[3] - 2);
|
||||
const n = rows.length;
|
||||
const padY = t.padY;
|
||||
const planTable = (tailH: number) =>
|
||||
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight: planPadRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
|
||||
|
||||
// --- totals: Sub Total rows and the GRAND TOTAL bar at the right, the PAYMENT METHOD bar and the bank lines at the left ------
|
||||
const tt = model.totals;
|
||||
const o = v.totals;
|
||||
const stack: Array<{ label: string; amount: string }> = [{ label: labelWithColon("Sub Total"), amount: tt.subtotalText }];
|
||||
if (tt.discountText) stack.push({ label: labelWithColon("Discount"), amount: `- ${tt.discountText}` });
|
||||
if (tt.taxableText) stack.push({ label: labelWithColon("Taxable Value"), amount: tt.taxableText });
|
||||
const slotRows = slotHeights.taxRowCount(model);
|
||||
const stackH = (stack.length + slotRows) * leading(r.totalLabel);
|
||||
const valueW = o.rightW - o.labelW;
|
||||
const wordsH = slotHeights.amountInWords(model, o.rightW, { role: r.words, labelRole: r.wordsLabel });
|
||||
const reverseH = slotHeights.reverseCharge(model, o.rightW, r.words);
|
||||
const rightH = stackH + o.barGap + o.barH + (wordsH ? o.wordsGap + wordsH : 0) + (reverseH ? o.reverseGap + reverseH : 0);
|
||||
const tailLeftW = W - o.rightW - o.leftGap;
|
||||
const bank = bankLines(model);
|
||||
const bankH = bank.reduce((a, l) => a + textHeight(r.bank, l, tailLeftW - o.barInset), 0);
|
||||
const leftBlockH = o.barH + o.bankGap + bankH;
|
||||
const leftHFor = (top: number) => (model.bank ? top + leftBlockH : 0);
|
||||
|
||||
// --- closing ------------------------------------------------------------------------------------------------------------------
|
||||
const cl = v.closing;
|
||||
const signCol = cl.signW + cl.signInset;
|
||||
const cLeftW = W - signCol - 8;
|
||||
const terms = model.notesLines;
|
||||
const termsH = terms.length ? leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, cLeftW), 0) : 0;
|
||||
const termsBeside = terms.length === 0 || termsH <= cl.notesBesideMax;
|
||||
const flowW = W - 2;
|
||||
const flowH = termsBeside ? 0 : leading(r.termsHead) + 3.8 + terms.reduce((a, l) => a + textHeight(r.terms, l, flowW), 0);
|
||||
const signH = signatoryHeight(model, cl.signW, signatureOptions(v));
|
||||
const contacts = planContacts(model, v, W);
|
||||
const footerRuleH = h.rule.accent;
|
||||
const footerH = footerRuleH + (contacts.items.length ? cl.ruleToContacts + contacts.h : 0);
|
||||
const leftBase = cl.divider.thickness + cl.dividerGap + cl.thanksH;
|
||||
const cancelledH = model.cancelled ? 10 + textHeight(r.fine, CANCELLED_SENTENCE, W) : 0;
|
||||
const closingFor = (g: { terms: number; footer: number }) => {
|
||||
const leftHh = leftBase + (termsBeside && terms.length ? g.terms + termsH : 0);
|
||||
const rowH = Math.max(leftHh, signH);
|
||||
return { leftH: leftHh, rowH, h: rowH + g.footer + footerH + cancelledH };
|
||||
};
|
||||
|
||||
// --- gaps -----------------------------------------------------------------------------------------------------------------------
|
||||
const ranges = { client: v.gaps.client, table: v.gaps.table, tail: v.gaps.tail, terms: termsBeside && terms.length ? v.gaps.terms : { nominal: 0, min: 0 }, footer: v.gaps.footer };
|
||||
const nominalClosing = closingFor({ terms: ranges.terms.nominal, footer: ranges.footer.nominal });
|
||||
const table0 = planTable(0);
|
||||
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
|
||||
const notesBlock = termsBeside ? 0 : NOTES_FLOW_GAP + flowH;
|
||||
const nominalFor = (tailBody: number) => headH + ruleGap + ruleH + ranges.client.nominal + infoH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + notesBlock + nominalClosing.h;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
// The PAYMENT METHOD bar sits level with the GRAND TOTAL bar, as in the design, whenever the invoice still fits its page
|
||||
// that way; with more totals rows than the design's two it moves up beside the first row, which saves up to the stack's height.
|
||||
const alignedTop = stackH + o.barGap;
|
||||
const aligned = nominalFor(Math.max(rightH, leftHFor(alignedTop))) - slack <= frame.contentHeight || !model.bank;
|
||||
const leftTop = aligned ? alignedTop : 0;
|
||||
const leftH = leftHFor(leftTop);
|
||||
const tailBody = Math.max(rightH, leftH);
|
||||
const nominalTotal = nominalFor(tailBody);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
const overflowBeforeClosing = nominalTotal - nominalClosing.h - frame.contentHeight;
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const closing = closingFor(g);
|
||||
const tailH = g.tail + tailBody;
|
||||
const table = planTable(tailH);
|
||||
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
|
||||
|
||||
return {
|
||||
v,
|
||||
W,
|
||||
head: { titleSize: tFit.size, titleW, leftW, mode, showName, markW, nameW, nameH, tagline, taglineH, leftH: headLeftH, h: headH, ruleGap, ruleText, ruleTextW, ruleW, ruleH },
|
||||
info: { metaW, clientW, lines, metaH, clientH, h: infoH },
|
||||
columns,
|
||||
headerCells,
|
||||
rows,
|
||||
align,
|
||||
headAlign,
|
||||
padLeft,
|
||||
padRight,
|
||||
padY,
|
||||
headPadY,
|
||||
cw: { no, qty, price, amount, priceInset },
|
||||
table,
|
||||
guardH,
|
||||
tail: { stack, slotRows, stackH, rightW: o.rightW, valueW, leftW: tailLeftW, leftTop, aligned, bank, bankH, wordsH, reverseH, rightH, leftH, body: tailBody },
|
||||
closing: { leftW: cLeftW, signCol, terms, termsBeside, termsH, flowW, flowH, leftH: closing.leftH, signH, rowH: closing.rowH, contacts: contacts.items, contactGap: contacts.gap, contactsW: contacts.width, contactsH: contacts.h, footerH, h: closing.h },
|
||||
gaps: { client: g.client, table: g.table, tail: g.tail, terms: g.terms, footer: g.footer },
|
||||
tailH,
|
||||
closingH: closing.h,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Sides } from "../../engine/geometry";
|
||||
import { defineRoles, type TypeToken } from "../../fonts/roles";
|
||||
import type { GapRange } from "../neutral/variant";
|
||||
|
||||
const P = "Poppins" as const;
|
||||
const O = "Open Sans" as const;
|
||||
|
||||
export const COBALT = "#3871C1";
|
||||
export const ZEBRA = "#BEDEF1";
|
||||
export const SKY = "#51ADE5";
|
||||
export const INK = "#1B1B1B";
|
||||
export const GREY = "#595A5A";
|
||||
export const RULE_GREY = "#A6A6A6";
|
||||
const WHITE = "#FFFFFF";
|
||||
|
||||
/**
|
||||
* Cobalt Stripe's type (docs/templates/08-cobalt-stripe.md): Poppins Bold for the name and the tracked INVOICE title,
|
||||
* Open Sans for everything else, Poppins Regular for the footer contacts. Open Sans ascent 1.069, descent 0.293;
|
||||
* Poppins 1.05 / 0.35. Leadings are the design's pitches where the page keeps them, tighter where a longer
|
||||
* invoice needs the room (client lines).
|
||||
*/
|
||||
export const COBALT_ROLES = defineRoles({
|
||||
brand: { face: P, weight: 700, size: 17.38, leading: 20.5, tracking: -0.028, minSize: 11 },
|
||||
tagline: { face: O, weight: 400, size: 10, leading: 13.6, caps: true, minSize: 6.5 },
|
||||
title: { face: P, weight: 700, size: 31.45, leading: 36, tracking: 0.043, caps: true, minSize: 18 },
|
||||
ruleText: { face: O, weight: 400, size: 10, leading: 13.6, caps: true, minSize: 6.5 },
|
||||
label: { face: O, weight: 400, size: 11, leading: 17.8 },
|
||||
clientName: { face: O, weight: 700, size: 15, leading: 20, minSize: 10 },
|
||||
clientLine: { face: O, weight: 400, size: 10, leading: 17.5, minSize: 7 },
|
||||
metaStrong: { face: O, weight: 700, size: 12, leading: 19.2, minSize: 8 },
|
||||
metaLine: { face: O, weight: 400, size: 12, leading: 19.2, minSize: 8 },
|
||||
tableHead: { face: O, weight: 700, size: 11, leading: 14.8, caps: true },
|
||||
tableBody: { face: O, weight: 400, size: 10, leading: 13.6 },
|
||||
tableSub: { face: O, weight: 400, size: 8.5, leading: 11.5 },
|
||||
totalLabel: { face: O, weight: 400, size: 11, leading: 20.76 },
|
||||
totalValue: { face: O, weight: 400, size: 11, leading: 20.76 },
|
||||
barLabel: { face: O, weight: 700, size: 11, leading: 16.76, caps: true },
|
||||
barValue: { face: O, weight: 700, size: 11, leading: 16.76 },
|
||||
bank: { face: O, weight: 400, size: 11, leading: 15.76 },
|
||||
thanks: { face: O, weight: 700, size: 11, leading: 16 },
|
||||
termsHead: { face: O, weight: 700, size: 11, leading: 15 },
|
||||
terms: { face: O, weight: 400, size: 10, leading: 14.25 },
|
||||
words: { face: O, weight: 400, size: 9.5, leading: 12.5 },
|
||||
wordsLabel: { face: O, weight: 700, size: 9.5, leading: 12.5 },
|
||||
signName: { face: O, weight: 700, size: 13, leading: 17.8 },
|
||||
signTitle: { face: O, weight: 700, size: 11, leading: 15 },
|
||||
fine: { face: O, weight: 400, size: 9, leading: 12.5 },
|
||||
contact: { face: P, weight: 400, size: 10, leading: 14, minSize: 6.5 },
|
||||
footer: { face: O, weight: 400, size: 7 },
|
||||
});
|
||||
|
||||
export type CobaltRoles = typeof COBALT_ROLES;
|
||||
|
||||
export interface CobaltVariant {
|
||||
roles: CobaltRoles;
|
||||
ink: string;
|
||||
margins: Sides;
|
||||
marginFloor: Sides;
|
||||
head: {
|
||||
/** Offsets below the header's top (the INVOICE line box): the logo, the name's line box. */
|
||||
logoTop: number;
|
||||
nameTop: number;
|
||||
/** Name's last line to the tagline. */
|
||||
taglineGap: number;
|
||||
/** Mark to name, and the narrowest the name column may get. */
|
||||
markGap: number;
|
||||
minNameW: number;
|
||||
/** Where the rule row starts below the header's top, and the narrowest gap under a taller header. */
|
||||
ruleTop: number;
|
||||
ruleMinGap: number;
|
||||
/** Gap between the left block and the INVOICE title. */
|
||||
titleGap: number;
|
||||
/** Rule text: the rule's end to the text. */
|
||||
ruleTextGap: number;
|
||||
/** The rule row: grey line thickness, accent thickness and length, and the line's inset from the row. */
|
||||
rule: { thickness: number; accent: number; accentW: number; inset: number; drop: number };
|
||||
};
|
||||
info: { metaMinW: number; metaMaxShare: number; clientGap: number; nameGap: number };
|
||||
table: {
|
||||
cols: { no: number; qty: number; price: number; amount: number };
|
||||
headH: number;
|
||||
/** The design sets the heading 1.8 pt above the bar's centre. */
|
||||
headPad: { paddingTop: number; paddingBottom: number };
|
||||
descInset: number;
|
||||
priceInset: number;
|
||||
amountInset: number;
|
||||
padY: number;
|
||||
separator: number;
|
||||
/** Zebra row height of a single-line row, for the plan. */
|
||||
rowH: number;
|
||||
};
|
||||
totals: { rightW: number; labelW: number; valuePad: number; barGap: number; barH: number; barInset: number; leftBarW: number; bankGap: number; wordsGap: number; reverseGap: number; leftGap: number };
|
||||
closing: {
|
||||
divider: { w: number; thickness: number };
|
||||
dividerGap: number;
|
||||
thanksH: number;
|
||||
signW: number;
|
||||
signInset: number;
|
||||
signatureImageH: number;
|
||||
notesBesideMax: number;
|
||||
footerRule: { inset: number; accentInset: number; accentW: number };
|
||||
ruleToContacts: number;
|
||||
icon: { size: number; gap: number };
|
||||
contactGap: number;
|
||||
minContactGap: number;
|
||||
};
|
||||
gaps: {
|
||||
/** Header rule row to "Billed to :". */
|
||||
client: GapRange;
|
||||
/** Client / meta block to the table. */
|
||||
table: GapRange;
|
||||
/** Last row to the Sub Total row. */
|
||||
tail: GapRange;
|
||||
/** Thank-you line to the terms. */
|
||||
terms: GapRange;
|
||||
/** Last closing line to the footer rule. */
|
||||
footer: GapRange;
|
||||
};
|
||||
footerLeftW: number;
|
||||
}
|
||||
|
||||
export const COBALT_VARIANT: CobaltVariant = {
|
||||
roles: COBALT_ROLES,
|
||||
ink: INK,
|
||||
margins: { top: 54.2, right: 59.55, bottom: 35.5, left: 59.55 },
|
||||
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
|
||||
head: {
|
||||
logoTop: 5.3,
|
||||
nameTop: 2.1,
|
||||
taglineGap: 2.7,
|
||||
markGap: 8.5,
|
||||
minNameW: 80,
|
||||
ruleTop: 52.2,
|
||||
ruleMinGap: 6,
|
||||
titleGap: 16,
|
||||
ruleTextGap: 11,
|
||||
rule: { thickness: 1.5, accent: 3, accentW: 35.7, inset: 0.75, drop: 0.6 },
|
||||
},
|
||||
info: { metaMinW: 150, metaMaxShare: 0.45, clientGap: 16, nameGap: 9.3 },
|
||||
table: {
|
||||
cols: { no: 24.9, qty: 84.9, price: 105.4, amount: 89.3 },
|
||||
headH: 20.76,
|
||||
headPad: { paddingTop: 0.74, paddingBottom: 5.22 },
|
||||
descInset: 6.8,
|
||||
priceInset: 31.7,
|
||||
amountInset: 3.1,
|
||||
padY: 2.11,
|
||||
separator: 1.5,
|
||||
rowH: 17.82,
|
||||
},
|
||||
totals: { rightW: 214.5, labelW: 95.9, valuePad: 3.1, barGap: 7.2, barH: 20.76, barInset: 6, leftBarW: 133.8, bankGap: 15.3, wordsGap: 6, reverseGap: 4, leftGap: 16 },
|
||||
closing: {
|
||||
divider: { w: 176.6, thickness: 1.5 },
|
||||
dividerGap: 12.3,
|
||||
thanksH: 16,
|
||||
signW: 200,
|
||||
signInset: 5.5,
|
||||
signatureImageH: 36,
|
||||
notesBesideMax: 110,
|
||||
footerRule: { inset: 15.35, accentInset: 1.5, accentW: 35.7 },
|
||||
ruleToContacts: 9.3,
|
||||
icon: { size: 26, gap: 4 },
|
||||
contactGap: 32.5,
|
||||
minContactGap: 12,
|
||||
},
|
||||
gaps: {
|
||||
client: { nominal: 42, min: 26 },
|
||||
table: { nominal: 30.5, min: 16 },
|
||||
tail: { nominal: 9.8, min: 4 },
|
||||
terms: { nominal: 26, min: 14 },
|
||||
footer: { nominal: 16, min: 8 },
|
||||
},
|
||||
footerLeftW: 150,
|
||||
};
|
||||
|
||||
export const WHITE_INK = WHITE;
|
||||
export const leading = (t: TypeToken) => t.leading as number;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
// Monolith and Cobalt Stripe: the same sweep as the other templates, in a second file so the two run side by side.
|
||||
describeSweep((id) => STANDALONE_SWEEP.includes(id));
|
||||
|
||||
it("the standalone sweep names registered templates only", () => {
|
||||
expect(STANDALONE_SWEEP.every((id) => Object.hasOwn(TEMPLATES, id))).toBe(true);
|
||||
});
|
||||
@@ -1,21 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeItems, prefsFor, PAGE_SIZE_IDS, renderFixture, type FixtureSpec } from "./harness";
|
||||
import { checkRendered } from "./harness.check";
|
||||
import { TEMPLATES } from "./registry";
|
||||
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
|
||||
|
||||
// Its own file so vitest runs the long item-count sweeps in parallel with the fixture matrix.
|
||||
describe("template harness: item-count sweep", () => {
|
||||
for (const template of Object.values(TEMPLATES)) {
|
||||
for (const size of PAGE_SIZE_IDS) {
|
||||
it(`${template.id}: 1..45 items on ${size}`, async () => {
|
||||
const prefs = prefsFor(size, { templateId: template.id });
|
||||
for (let count = 1; count <= 45; count++) {
|
||||
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
|
||||
const r = await renderFixture(spec, prefs);
|
||||
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describeSweep((id) => !STANDALONE_SWEEP.includes(id));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { makeItems, prefsFor, PAGE_SIZE_IDS, renderFixture, type FixtureSpec } from "./harness";
|
||||
import { checkRendered } from "./harness.check";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
/** The standalone templates sweep in a file of their own, so vitest runs the two sweeps in parallel and the suite's wall time stays put. */
|
||||
export const STANDALONE_SWEEP = ["monolith", "cobalt-stripe"];
|
||||
|
||||
/** 1..45 generated rows on A4 and Letter for the templates `pick` selects, each render through the harness checks. */
|
||||
export function describeSweep(pick: (id: string) => boolean): void {
|
||||
describe("template harness: item-count sweep", () => {
|
||||
for (const template of Object.values(TEMPLATES).filter((t) => pick(t.id))) {
|
||||
for (const size of PAGE_SIZE_IDS) {
|
||||
it(`${template.id}: 1..45 items on ${size}`, async () => {
|
||||
const prefs = prefsFor(size, { templateId: template.id });
|
||||
for (let count = 1; count <= 45; count++) {
|
||||
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
|
||||
const r = await renderFixture(spec, prefs);
|
||||
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { pinned } from "../../engine/columns";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
|
||||
import { BottomSpacer } from "../../blocks/BottomSpacer";
|
||||
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
|
||||
import { FitText } from "../../blocks/FitText";
|
||||
import { IdText } from "../../blocks/IdText";
|
||||
import { TableRows } from "../../blocks/ItemsTable";
|
||||
import { Row } from "../../blocks/Row";
|
||||
import { SafeTxt } from "../../blocks/SafeTxt";
|
||||
import { Slot } from "../../blocks/slots";
|
||||
import { TableGuard } from "../../blocks/TableGuard";
|
||||
import { Txt } from "../../blocks/Txt";
|
||||
import { AccentRule } from "../../decor/AccentRule";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import type { PlacedLogo, TemplateProps } from "../contract";
|
||||
import { includesName } from "../logoName";
|
||||
import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan";
|
||||
import { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
|
||||
import type { MonolithVariant } from "./tokens";
|
||||
|
||||
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
|
||||
export const BRAND_ID = "monolith-brand";
|
||||
export const IDS_ID = "monolith-ids";
|
||||
|
||||
interface Ctx {
|
||||
model: TemplateProps["model"];
|
||||
plan: MonolithPlan;
|
||||
v: MonolithVariant;
|
||||
logo: PlacedLogo | null;
|
||||
}
|
||||
|
||||
function Spacer({ h }: { h: number }) {
|
||||
return h > 0 ? <View style={{ height: h }} /> : null;
|
||||
}
|
||||
|
||||
function Rule({ ctx, width }: { ctx: Ctx; width?: number }) {
|
||||
return <AccentRule width={width ?? ctx.plan.W} thickness={ctx.v.rule} color={ctx.v.ink} />;
|
||||
}
|
||||
|
||||
// --- header -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function Title({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const h = plan.head;
|
||||
return (
|
||||
<View style={{ ...pinned(h.titleW), marginLeft: v.titleIndent } as never}>
|
||||
<FitText id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
|
||||
{model.docTitle}
|
||||
</FitText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** The logo (right-anchored in the stack's column) or the vendor name; under it the date and number lines flow, so a tall logo never covers them. */
|
||||
function Brand({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v, logo } = ctx;
|
||||
const h = plan.head;
|
||||
const name = h.showName ? (
|
||||
<View style={{ ...pinned(h.nameW), marginTop: h.nameOffset } as never}>
|
||||
<AvailableWidthProvider width={h.nameW}>
|
||||
<SafeTxt role={v.roles.brand} color={v.ink} align="right">
|
||||
{model.supplier.name}
|
||||
</SafeTxt>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
);
|
||||
const body = logo ? (
|
||||
<Row widths={[{ fill: true }, { fixed: logo.w + (h.showName ? v.stack.markGap : 0) }]} width={h.stackW}>
|
||||
{name}
|
||||
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginLeft: h.showName ? v.stack.markGap : 0, objectFit: "contain" } as never} />
|
||||
</Row>
|
||||
) : (
|
||||
name
|
||||
);
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={{ ...pinned(h.stackW), paddingTop: h.zoneTop, paddingBottom: logo ? v.stack.logoGap : 0 } as never}>
|
||||
<View id={BRAND_ID}>{body}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaStack({ ctx }: { ctx: Ctx }) {
|
||||
const { plan, v } = ctx;
|
||||
const h = plan.head;
|
||||
return (
|
||||
<View id={META_ID} style={pinned(h.stackW) as never}>
|
||||
<AvailableWidthProvider width={h.stackW}>
|
||||
{h.lines.map((l, i) => (
|
||||
<IdText key={i} role={roleOf(v, l.kind)} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} align="right" fill width={h.stackW}>
|
||||
{l.text}
|
||||
</IdText>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({ ctx }: { ctx: Ctx }) {
|
||||
const { plan } = ctx;
|
||||
return (
|
||||
<Row widths={[{ fill: true }, { fixed: plan.head.stackW }]} width={plan.W} style={{ minHeight: plan.head.h }}>
|
||||
<Title ctx={ctx} />
|
||||
<View style={pinned(plan.head.stackW) as never}>
|
||||
<Brand ctx={ctx} />
|
||||
<MetaStack ctx={ctx} />
|
||||
</View>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
// --- info row ---------------------------------------------------------------------------------------------------
|
||||
|
||||
function ClientBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = model.client;
|
||||
const w = plan.info.clientW;
|
||||
return (
|
||||
<View style={pinned(w) as never}>
|
||||
<AvailableWidthProvider width={w}>
|
||||
<Txt role={r.heading} color={v.ink} width={w} style={{ marginBottom: v.headingGap }}>
|
||||
Billed to:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{c.name}
|
||||
</SafeTxt>
|
||||
{c.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
<Slot.Gstin model={model} party="client" role={r.body} color={v.ink} />
|
||||
<Slot.PlaceOfSupply model={model} role={r.body} color={v.ink} />
|
||||
{c.poNumber ? (
|
||||
<SafeTxt role={r.body} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function IdsBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
const s = model.supplier;
|
||||
return (
|
||||
<View id={IDS_ID} style={pinned(v.sideW) as never}>
|
||||
<AvailableWidthProvider width={v.sideW}>
|
||||
<Txt role={r.heading} color={v.ink} width={v.sideW} style={{ marginBottom: v.headingGap }}>
|
||||
Supplier:
|
||||
</Txt>
|
||||
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
|
||||
{s.pan ? <IdText role={r.body} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- totals -----------------------------------------------------------------------------------------------------
|
||||
|
||||
function TotalRow({ ctx, label, amount, strong }: { ctx: Ctx; label: string; amount: string; strong?: boolean }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
const r = v.roles;
|
||||
return (
|
||||
<Row widths={[{ fixed: t.labelW }, { fixed: t.valueW }]} width={t.boxW} style={strong ? undefined : { marginBottom: v.totals.rowGap }}>
|
||||
<Txt role={strong ? r.total : r.totalLabel} color={v.ink} width={t.labelW} align="center">
|
||||
{label}
|
||||
</Txt>
|
||||
<Txt role={strong ? r.total : r.totalValue} color={v.ink} width={t.valueW - v.totals.valuePad} align="right">
|
||||
{amount}
|
||||
</Txt>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalsBox({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
return (
|
||||
<View style={{ ...pinned(t.boxW), paddingTop: v.totals.top } as never}>
|
||||
<AvailableWidthProvider width={t.boxW}>
|
||||
{t.stack.map((row, i) => (
|
||||
<TotalRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
|
||||
))}
|
||||
<Slot.TaxRows model={model} role={v.roles.totalLabel} color={v.ink} amountWidth={t.valueW} format={atRate} renderRow={(row) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} />
|
||||
<View style={{ marginTop: v.totals.ruleGap, marginBottom: v.totals.totalGap }}>
|
||||
<Rule ctx={ctx} width={t.boxW} />
|
||||
</View>
|
||||
<TotalRow ctx={ctx} label="Total" amount={model.totals.totalText} strong />
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Tail({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const t = plan.tail;
|
||||
const r = v.roles;
|
||||
return (
|
||||
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
|
||||
<Rule ctx={ctx} />
|
||||
<Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={v.totals.leftGap} width={plan.W}>
|
||||
<View style={{ ...pinned(t.leftW), paddingTop: t.leftH ? v.totals.top : 0 } as never}>
|
||||
<Slot.AmountInWords model={model} role={r.small} labelRole={r.smallStrong} color={v.ink} />
|
||||
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
|
||||
</View>
|
||||
<TotalsBox ctx={ctx} />
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- closing ----------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Notes: a label held with the first line, every line unbreakable (a cut Text would leave a zero-line node). */
|
||||
function Notes({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const lines = plan.closing.notes;
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<View style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
<View wrap={false}>
|
||||
<Txt role={r.smallStrong} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
|
||||
Notes / terms:
|
||||
</Txt>
|
||||
<SafeTxt role={r.small} color={v.ink}>
|
||||
{lines[0]}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{lines.slice(1).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Payment({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const cl = v.closing;
|
||||
const w = plan.closing.leftW;
|
||||
if (!model.bank) return <View />;
|
||||
return (
|
||||
<View style={pinned(w) as never}>
|
||||
<AvailableWidthProvider width={w}>
|
||||
<Txt role={r.heading} color={v.ink} width={w} style={{ marginBottom: cl.headingGap }}>
|
||||
Payment information :
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{model.bank.accountName}
|
||||
</SafeTxt>
|
||||
{paymentRows(model).map((x, i) => (
|
||||
<Row key={i} widths={[{ fixed: cl.labelW }, { fixed: cl.colonW }, { fill: true }]} width={w}>
|
||||
<Txt role={r.body} color={v.ink} width={cl.labelW}>
|
||||
{x.label}
|
||||
</Txt>
|
||||
<Txt role={r.colon} color={v.ink} width={cl.colonW}>
|
||||
:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{x.value}
|
||||
</SafeTxt>
|
||||
</Row>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Vendor({ ctx }: { ctx: Ctx }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
const s = model.supplier;
|
||||
return (
|
||||
<View style={pinned(v.sideW) as never}>
|
||||
<AvailableWidthProvider width={v.sideW}>
|
||||
<SafeTxt role={r.vendor} color={v.ink} style={{ marginBottom: v.closing.headingGap }}>
|
||||
{s.name}
|
||||
</SafeTxt>
|
||||
{s.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
{s.phone ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{s.phone}
|
||||
</IdText>
|
||||
) : null}
|
||||
{s.email ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{s.email}
|
||||
</IdText>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Closing({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const cl = v.closing;
|
||||
const c = plan.closing;
|
||||
const opts = signatureOptions(v);
|
||||
return (
|
||||
<>
|
||||
<BottomSpacer closingH={plan.closingH} />
|
||||
<View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}>
|
||||
<AvailableWidthProvider width={plan.W}>
|
||||
<Row widths={[{ fixed: c.leftW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
|
||||
{c.notesBeside ? <Notes ctx={ctx} width={c.leftW} /> : <View />}
|
||||
<Slot.Signatory model={model} width={v.sideW} {...opts} />
|
||||
</Row>
|
||||
<Spacer h={plan.gaps.closeBand} />
|
||||
<Rule ctx={ctx} />
|
||||
<Spacer h={cl.top} />
|
||||
<Row widths={[{ fixed: c.leftW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
|
||||
<Payment ctx={ctx} />
|
||||
<Vendor ctx={ctx} />
|
||||
</Row>
|
||||
<Spacer h={plan.gaps.closeBottom} />
|
||||
<Rule ctx={ctx} />
|
||||
{model.cancelled ? (
|
||||
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
|
||||
{CANCELLED_SENTENCE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- the layout -------------------------------------------------------------------------------------------------
|
||||
|
||||
export function MonolithBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MonolithVariant }) {
|
||||
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v, logo };
|
||||
const r = v.roles;
|
||||
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
|
||||
<View style={{ marginTop: NOTES_FLOW_GAP }}>
|
||||
<Notes ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
<HeaderRow ctx={ctx} />
|
||||
<Spacer h={v.headRuleGap} />
|
||||
<Rule ctx={ctx} />
|
||||
<Spacer h={plan.gaps.infoTop} />
|
||||
{plan.info.hasIds ? (
|
||||
<Row widths={[{ fixed: plan.info.clientW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
|
||||
<ClientBlock ctx={ctx} />
|
||||
<IdsBlock ctx={ctx} />
|
||||
</Row>
|
||||
) : (
|
||||
<ClientBlock ctx={ctx} />
|
||||
)}
|
||||
<Spacer h={plan.gaps.infoBottom} />
|
||||
<Rule ctx={ctx} />
|
||||
</FirstPageHeader>
|
||||
|
||||
<Spacer h={plan.gaps.band} />
|
||||
|
||||
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
|
||||
<View style={pinned(plan.W) as never}>
|
||||
<TableRows
|
||||
columns={plan.columns}
|
||||
gap={0}
|
||||
width={plan.W}
|
||||
header={plan.headerCells}
|
||||
rows={plan.rows}
|
||||
roles={{ head: r.tableHead, body: r.tableBody }}
|
||||
align={plan.align}
|
||||
padX={0}
|
||||
padLeft={plan.padLeft}
|
||||
padRight={plan.padRight}
|
||||
padY={plan.padY}
|
||||
headPadY={plan.headPadY}
|
||||
border={0}
|
||||
headBorder={v.heavyRule}
|
||||
borderColor={v.ink}
|
||||
headColor={v.ink}
|
||||
headerStyle={{ borderTopWidth: v.heavyRule, borderTopColor: v.ink }}
|
||||
tailH={plan.tailH}
|
||||
tail={<Tail ctx={ctx} />}
|
||||
/>
|
||||
</View>
|
||||
{notesFlow}
|
||||
<Closing ctx={ctx} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
|
||||
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
||||
import { SLOT_ID } from "../../blocks/slots";
|
||||
import { frameForPrefs, renderInvoicePdf } from "../../render/invoice";
|
||||
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
|
||||
import { marginsFor } from "../contract";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
|
||||
import { getTemplate, TEMPLATES } from "../registry";
|
||||
import { SLOTS } from "../slots.generated";
|
||||
import { BRAND_ID, IDS_ID } from "./Layout";
|
||||
import { monolith } from "./monolith";
|
||||
import { planMonolith } from "./plan";
|
||||
import { MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const V = MONOLITH_VARIANT;
|
||||
const allowance = SLOTS.monolith!.allowance;
|
||||
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
||||
const out: LayoutNode[] = [];
|
||||
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
const onPage = (r: Awaited<ReturnType<typeof renderFixture>>, i: number) => nodes([r.result.core.layout[i]]);
|
||||
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
||||
const byId = (r: Awaited<ReturnType<typeof renderFixture>>, id: string, page = 0) => onPage(r, page).find((n) => n.id === id)!;
|
||||
const textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
|
||||
const textLines = (root: LayoutNode) => {
|
||||
const out: Array<{ text: string; x: number; right: number; y: number }> = [];
|
||||
walk([root], (n) => {
|
||||
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ text: l.text.trim(), x: l.x, right: l.x + l.w - (n.letterSpacing ?? 0), y: l.y });
|
||||
});
|
||||
return out;
|
||||
};
|
||||
/** The Canva sample's shape: no due date, so the right-hand stack is the design's three lines. */
|
||||
const PLAIN: FixtureSpec = { name: "plain", items: 3, registered: false, logo: "none", invoice: { dueDate: "" } };
|
||||
const contentRight = (r: Awaited<ReturnType<typeof renderFixture>>) => {
|
||||
const f = frameForPrefs(r.prefs);
|
||||
return f.page.width - f.pad.right;
|
||||
};
|
||||
|
||||
describe("registry, catalog and fonts", () => {
|
||||
it("is registered after the Northline family, as a standalone template with its thumbnail on disk", () => {
|
||||
expect(getTemplate("monolith")).toBe(monolith);
|
||||
expect(monolith.family).toBe("standalone");
|
||||
expect(catalogEntry("monolith").name).toBe("Monolith");
|
||||
expect(existsSync(path.join(process.cwd(), "public", monolith.thumbnail)), monolith.thumbnail).toBe(true);
|
||||
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["monolith", "cobalt-stripe"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
it("is black Poppins only: every role is Poppins 400/500/700 and the sources carry no colour but #000000", () => {
|
||||
for (const [name, token] of Object.entries(MONOLITH_ROLES)) {
|
||||
expect(token.face, name).toBe("Poppins");
|
||||
expect([400, 500, 700], name).toContain(token.weight);
|
||||
}
|
||||
const dir = path.join(process.cwd(), "src/pdf/templates/monolith");
|
||||
for (const f of readdirSync(dir).filter((x) => /\.(ts|tsx)$/.test(x) && !/\.test\./.test(x))) {
|
||||
const colours = readFileSync(path.join(dir, f), "utf8").match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
|
||||
expect(colours.filter((c) => c.toLowerCase() !== "#000000"), f).toEqual([]);
|
||||
}
|
||||
expect(monolith.decor.ink).toBe("#000000");
|
||||
expect(monolith.chrome.color).toBe("#000000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("page geometry", () => {
|
||||
it("template margins", () => {
|
||||
expect(marginsFor(monolith, "template")).toEqual({ top: 33, right: 35, bottom: 22, left: 35 });
|
||||
expect(marginsFor(monolith, "narrow").left).toBe(36);
|
||||
});
|
||||
|
||||
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
|
||||
const a4 = frameForPrefs(prefsFor("A4", { templateId: "monolith" }));
|
||||
const letter = frameForPrefs(prefsFor("LETTER", { templateId: "monolith" }));
|
||||
expect(letter.pad).toEqual(a4.pad);
|
||||
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("header", () => {
|
||||
it("matches the design: title origin 16 pt in, baseline 96 pt; the vendor, date and number lines end on the right edge at the design's pitch", async () => {
|
||||
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
|
||||
const items = r.pdf.pages[0].items;
|
||||
const title = items.find((i) => i.str.trim() === "INVOICE")!;
|
||||
expect(title.x0).toBeCloseTo(35 + V.titleIndent, 0);
|
||||
expect(title.bottom).toBeCloseTo(96.2, 0);
|
||||
const right = contentRight(r);
|
||||
const brand = items.find((i) => i.str.trim() === "Test Vendor" && i.bottom < 130)!;
|
||||
const date = items.find((i) => i.str.includes("2026") && i.bottom < 130)!;
|
||||
const number = items.find((i) => i.str.includes("INVOICE NO."))!;
|
||||
for (const [what, it, baseline] of [["name", brand, 63.7], ["date", date, 84.7], ["number", number, 105.7]] as const) {
|
||||
expect(it.bottom, what).toBeCloseTo(baseline, 0);
|
||||
expect(it.x1, what).toBeCloseTo(right, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("TAX INVOICE shrinks to the room the right-hand stack leaves instead of meeting it", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor(size, { templateId: "monolith" }));
|
||||
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
expect(plan.head.titleSize).toBeLessThan(MONOLITH_ROLES.title.size);
|
||||
expect(plan.head.titleSize).toBeGreaterThanOrEqual(MONOLITH_ROLES.title.minSize as number);
|
||||
const title = textLines(byId(r, TITLE_ID));
|
||||
const stack = textLines(byId(r, META_ID));
|
||||
const stackLeft = Math.min(...stack.map((l) => l.x));
|
||||
expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(stackLeft - 10);
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("a cancelled invoice is marked in the stack and closes with its sentence", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "monolith" }));
|
||||
expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true);
|
||||
expect(textsOf(r, r.result.pages - 1)).toContain("This invoice has been cancelled.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("logo", () => {
|
||||
it("is the text-only CAVEIN slot: the allowance is 160 x 28, right-anchored, and the template offers exactly it", () => {
|
||||
expect(SLOTS.monolith!.textOnly).toBe(true);
|
||||
expect(allowance).toMatchObject({ w: 160, h: 28 });
|
||||
expect(monolith.logo.slot).toBe(SLOTS.monolith);
|
||||
expect(monolith.logo.box).toEqual({ w: allowance.w, h: allowance.h });
|
||||
expect(monolith.logo.available?.(frameForPrefs(prefsFor("A4", { templateId: "monolith" })))).toEqual({ w: allowance.w, h: allowance.h });
|
||||
expect(monolith.logo.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("every logo fits the allowance, ends on the right edge and the date and number lines flow BELOW its box", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const logo of ["real", "wide", "square"] as const) {
|
||||
const r = await renderFixture({ name: "o", items: 2, logo }, prefsFor(size, { templateId: "monolith" }));
|
||||
const imgs = imageBoxes(r);
|
||||
expect(imgs, `${size}/${logo}`).toHaveLength(1);
|
||||
const [img] = imgs;
|
||||
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
|
||||
expect(img.box.w).toBeLessThanOrEqual(allowance.w + 0.01);
|
||||
expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r), 0);
|
||||
// The brand zone reserves the logo box, so the meta lines start under it.
|
||||
const brand = byId(r, BRAND_ID);
|
||||
const supplier = byId(r, SUPPLIER_ID);
|
||||
const meta = byId(r, META_ID);
|
||||
expect(img.box.y + img.box.h, `${size}/${logo} logo bottom against meta top`).toBeLessThanOrEqual(meta.box.y + 0.01);
|
||||
expect(supplier.box.y + supplier.box.h, `${size}/${logo} supplier box against meta`).toBeLessThanOrEqual(meta.box.y + 0.01);
|
||||
expect(brand.box.y + brand.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
|
||||
const lines = textLines(meta);
|
||||
for (const l of lines) expect(l.y, `${size}/${logo} meta line`).toBeGreaterThanOrEqual(img.box.y + img.box.h - 0.01);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("a tall logo is held to the 28 pt allowance and still leaves the stack under it", async () => {
|
||||
const { model } = buildFixture({ name: "tall", items: 2, logo: "square" });
|
||||
model.logo = { ...model.logo!, aspect: 0.5, width: 60, height: 120, kind: "tall", includesName: false };
|
||||
const prefs = prefsFor("A4", { templateId: "monolith" });
|
||||
const result = await renderInvoicePdf({ model, prefs });
|
||||
expect(result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
const all = nodes(result.core.layout);
|
||||
const img = all.find((n) => n.type === "image" && !n.decor)!;
|
||||
const meta = all.find((n) => n.id === META_ID)!;
|
||||
expect(img.box.h).toBeLessThanOrEqual(allowance.h + 0.01);
|
||||
expect(img.box.y + img.box.h).toBeLessThanOrEqual(meta.box.y + 0.01);
|
||||
});
|
||||
|
||||
it("a wordmark replaces the vendor name text; a mark keeps it beside the mark; no logo prints the name in the template's type", async () => {
|
||||
const header = (r: Awaited<ReturnType<typeof renderFixture>>) => r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 130);
|
||||
const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
expect(header(wide)).toHaveLength(0);
|
||||
const square = await renderFixture({ name: "o", items: 2, logo: "square" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const [name] = header(square);
|
||||
expect(name).toBeTruthy();
|
||||
expect(name.x1).toBeLessThanOrEqual(imageBoxes(square)[0].box.x + 0.5);
|
||||
const none = await renderFixture({ name: "o", items: 2, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
expect(imageBoxes(none)).toHaveLength(0);
|
||||
expect(header(none)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("table, totals and footer", () => {
|
||||
it("rules only: a 1.5 pt rule above and below a 39.3 pt header, amounts right-aligned 10 pt inside the edge, qty centred", async () => {
|
||||
const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "monolith" }));
|
||||
const head = byId(r, TABLE_HEAD_ID);
|
||||
expect(head.box.h).toBeCloseTo(V.table.headH, 0);
|
||||
const right = contentRight(r);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const row = byId(r, `${TABLE_ROW_PREFIX}${i}`);
|
||||
const lines = textLines(row);
|
||||
const amount = lines[lines.length - 1];
|
||||
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
|
||||
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
|
||||
}
|
||||
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
|
||||
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
|
||||
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
|
||||
});
|
||||
|
||||
it("the totals box ends on the right edge, its labels are centred and a rule sits above Total", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const totals = byId(r, TOTALS_ID);
|
||||
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const lines = textLines(totals);
|
||||
const labelLeft = frame.pad.left + plan.W - plan.tail.boxW;
|
||||
const subtotal = lines.find((l) => l.text === "Subtotal")!;
|
||||
expect(subtotal.x + (subtotal.right - subtotal.x) / 2 - labelLeft).toBeCloseTo(plan.tail.labelW / 2, 0);
|
||||
expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(frame.page.width - frame.pad.right - V.totals.valuePad, 0);
|
||||
const rules = nodes([r.result.core.layout[0]]).filter((n) => n.type === "view" && n.box.h <= 0.76 && n.box.h >= 0.74 && Math.abs(n.box.w - plan.tail.boxW) < 0.5);
|
||||
// The rule sits in a spacing wrapper of the same size, so count distinct positions.
|
||||
expect(new Set(rules.map((n) => Math.round(n.box.y * 10)))).toHaveProperty("size", 1);
|
||||
const total = textLines(totals).find((l) => l.text === "Total")!;
|
||||
expect(rules[0].box.y).toBeLessThan(totals.box.y + total.y);
|
||||
});
|
||||
|
||||
it("the closing block is the design's footer: payment information as a label : value grid at 114 pt, the vendor block at the right, between two rules", async () => {
|
||||
const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const closing = byId(r, CLOSING_ID, r.result.pages - 1);
|
||||
const lines = textLines(closing);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const bank = lines.find((l) => l.text === "State Bank of India")!;
|
||||
const label = lines.find((l) => l.text === "Bank")!;
|
||||
expect(bank.x - label.x).toBeCloseTo(V.closing.labelW + V.closing.colonW, 0);
|
||||
expect(lines.some((l) => l.text === "PAYMENT INFORMATION :")).toBe(true);
|
||||
const plan = planMonolith(r.fixture.model, frame, V, null, false);
|
||||
const vendor = lines.find((l) => l.text === "9000000000")!;
|
||||
expect(vendor.x).toBeCloseTo(frame.pad.left + plan.W - V.sideW, 0);
|
||||
const rules = onPage(r, r.result.pages - 1).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5 && n.box.y >= closing.box.y - 0.01);
|
||||
expect(rules.length).toBeGreaterThanOrEqual(2);
|
||||
// The signature (an image and the signatory) sits in the band above the footer's top rule.
|
||||
expect(byId(r, SLOT_ID.signatory, r.result.pages - 1).box.y + byId(r, SLOT_ID.signatory, r.result.pages - 1).box.h).toBeLessThanOrEqual(rules[0].box.y + 0.5);
|
||||
});
|
||||
|
||||
it("a short invoice with the sample's data fits one A4 page by giving back the empty band, row padding and spacing", async () => {
|
||||
const spec: FixtureSpec = {
|
||||
name: "thumb",
|
||||
items: [
|
||||
{ id: 1, description: "Voice-over narration", mode: "rate", rate: 1500, unit: "hour", quantity: 4, amount: 0, sortOrder: 0, hsnSac: "998397" },
|
||||
{ id: 2, description: "Audio mastering", mode: "rate", rate: 800, unit: "hour", quantity: 2, amount: 0, sortOrder: 1, hsnSac: "998397" },
|
||||
],
|
||||
logo: "real",
|
||||
vendor: { vendorEmail: "", vendorPhone: "", vendorPan: "" },
|
||||
};
|
||||
const r = await renderFixture(spec, prefsFor("A4", { templateId: "monolith" }));
|
||||
expect(r.result.pages).toBe(1);
|
||||
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the GST slots and the supplier ids", () => {
|
||||
it("every GST slot has a home on a tax invoice, and the supplier's GSTIN and PAN are on page 1 whatever the length", async () => {
|
||||
const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
|
||||
for (const id of [`${SLOT_ID.gstin}-supplier`, `${SLOT_ID.gstin}-client`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.amountInWords, SLOT_ID.signatory, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(true);
|
||||
expect(byId(r, IDS_ID)).toBeTruthy();
|
||||
expect(textLines(byId(r, IDS_ID)).map((l) => l.text)).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: ABCDE1234F"]));
|
||||
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
|
||||
for (const word of ["SUBTOTAL", "TAXABLEVALUE", "CGST@9%", "SGST@9%", "HSN/SAC:998397", "PLACEOFSUPPLY"]) expect(flat, word).toContain(word);
|
||||
});
|
||||
|
||||
it("an unregistered invoice carries none of the GST slots", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "monolith" }));
|
||||
const ids = new Set(nodes(r.result.core.layout).flatMap((n) => (n.id ? [n.id] : [])));
|
||||
for (const id of [`${SLOT_ID.gstin}-supplier`, SLOT_ID.pos, SLOT_ID.taxRows, SLOT_ID.reverseCharge]) expect(ids.has(id), `#${id}`).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("long text", () => {
|
||||
it("long names, addresses and unbroken tokens raise no audit error on either size", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const name of ["id-tokens", "long-address", "long-client", "long-notes"]) {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: "monolith" }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("long terms flow as a block of their own and keep the closing block bounded", async () => {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "monolith" }));
|
||||
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
expect(plan.closing.notesBeside).toBe(false);
|
||||
expect(byId(r, CLOSING_ID, r.result.pages - 1).box.h).toBeLessThan(300);
|
||||
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
|
||||
});
|
||||
|
||||
it("an over-long vendor name wraps right-aligned in its column and never reaches the title", async () => {
|
||||
const r = await renderFixture({ ...FIXTURES.find((f) => f.name === "id-tokens")!, logo: "none" }, prefsFor("A4", { templateId: "monolith" }));
|
||||
const brand = textLines(byId(r, BRAND_ID));
|
||||
expect(brand.length).toBeGreaterThan(1);
|
||||
const title = textLines(byId(r, TITLE_ID));
|
||||
expect(Math.min(...brand.map((l) => l.x))).toBeGreaterThan(Math.max(...title.map((l) => l.right)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan matches the laid-out blocks", () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const spec of FIXTURES) {
|
||||
it(`${spec.name} on ${size}`, async () => {
|
||||
const prefs = prefsFor(size, { templateId: "monolith" });
|
||||
const frame = frameForPrefs(prefs);
|
||||
// Logo-less, so the plan made here is the one the layout made.
|
||||
const r = await renderFixture({ ...spec, logo: "none" }, prefs);
|
||||
const find = (id: string) => {
|
||||
const hits: number[] = [];
|
||||
for (const page of r.result.core.layout) walk(page.nodes, (n) => n.id === id && hits.push(n.box.h));
|
||||
expect(hits).toHaveLength(1);
|
||||
return hits[0];
|
||||
};
|
||||
const plan = planMonolith(r.fixture.model, frame, V, null, false);
|
||||
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(24);
|
||||
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(24);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("design docs", () => {
|
||||
it("records its implementation notes", () => {
|
||||
const text = readFileSync(path.join(process.cwd(), "docs/templates/03-monolith.md"), "utf8");
|
||||
expect(text).toContain("## Implementation notes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition, TemplateProps } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { MonolithBody } from "./Layout";
|
||||
import { INK, MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
|
||||
|
||||
export { MONOLITH_ROLES, MONOLITH_VARIANT } from "./tokens";
|
||||
|
||||
/** The text-only "CAVEIN" placeholder's allowance (slots.generated.ts): the largest box a logo may fill, right-anchored. */
|
||||
const ALLOWANCE = { w: 160, h: 28 };
|
||||
|
||||
const MonolithLayout: ComponentType<TemplateProps> = (props) => <MonolithBody {...props} variant={MONOLITH_VARIANT} />;
|
||||
MonolithLayout.displayName = "MonolithLayout";
|
||||
|
||||
export const monolith: TemplateDefinition = {
|
||||
...catalogEntry("monolith"),
|
||||
version: 1,
|
||||
roles: MONOLITH_ROLES,
|
||||
margins: MONOLITH_VARIANT.margins,
|
||||
marginFloor: MONOLITH_VARIANT.marginFloor,
|
||||
decor: { ink: INK },
|
||||
chrome: {
|
||||
color: INK,
|
||||
rule: null,
|
||||
role: MONOLITH_ROLES.footer,
|
||||
leftWidth: 150,
|
||||
// The footer line is one 7 pt line, so the closing rule sits close to the design's (30 pt above the page foot in Canva).
|
||||
reserve: 16,
|
||||
},
|
||||
logo: {
|
||||
box: ALLOWANCE,
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: true,
|
||||
slotWidth: () => ALLOWANCE.w,
|
||||
slot: slotFor("monolith"),
|
||||
available: () => ALLOWANCE,
|
||||
},
|
||||
Layout: MonolithLayout,
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
import type { PlacedLogo } from "../contract";
|
||||
import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
|
||||
import { leading, type MonolithVariant } from "./tokens";
|
||||
|
||||
export const NOTES_FLOW_GAP = 14;
|
||||
const FIT_AIR = 2;
|
||||
/** A GSTIN is always 15 characters: the text the layout prints for the height estimate, which must not read the regulated field. */
|
||||
const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
|
||||
export const REVERSE_GAP = 4;
|
||||
/** Space between the label of a words / notes block and its text. */
|
||||
export const LABEL_GAP = 4;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
|
||||
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
|
||||
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
|
||||
|
||||
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
|
||||
function idLines(role: TypeToken, text: string, width: number): number {
|
||||
const f = fitSize(role, text, Math.max(1, width));
|
||||
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)));
|
||||
}
|
||||
|
||||
const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(getMeasurer(), role, text)) + 1;
|
||||
|
||||
export interface StackLine {
|
||||
kind: "cancelled" | "date" | "due" | "number";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The right-hand stack under the brand: the design's date and invoice number, plus a due date and the cancelled tag. */
|
||||
export function stackLines(model: RenderModel): StackLine[] {
|
||||
const out: StackLine[] = [];
|
||||
if (model.cancelled) out.push({ kind: "cancelled", text: "CANCELLED" });
|
||||
out.push({ kind: "date", text: model.meta.date });
|
||||
if (model.meta.dueDate) out.push({ kind: "due", text: `Due: ${model.meta.dueDate}` });
|
||||
out.push({ kind: "number", text: `${model.meta.numberLabel.toUpperCase()} ${model.meta.number}` });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function signatureOptions(v: MonolithVariant): SignatoryOptions {
|
||||
const r = v.roles;
|
||||
return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.rule, color: v.ink }, color: v.ink, imageHeight: v.closing.signatureImageH };
|
||||
}
|
||||
|
||||
export const roleOf = (v: MonolithVariant, kind: StackLine["kind"]): TypeToken => (kind === "date" || kind === "due" ? v.roles.metaDate : v.roles.metaNo);
|
||||
|
||||
/** The table's description (+ HSN/SAC sub-line), price, qty and amount cells. */
|
||||
export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number): TableCellValue[][] {
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, priceW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [desc, rateParts, item.qtyText, item.amountText];
|
||||
});
|
||||
}
|
||||
|
||||
/** The footer's payment grid: the account holder as a plain line, then label : value rows. */
|
||||
export function paymentRows(model: RenderModel): Array<{ label: string; value: string }> {
|
||||
return model.bank ? model.bank.lines : [];
|
||||
}
|
||||
|
||||
export interface MonolithPlan {
|
||||
v: MonolithVariant;
|
||||
W: number;
|
||||
head: {
|
||||
titleSize: number;
|
||||
titleW: number;
|
||||
stackW: number;
|
||||
lines: StackLine[];
|
||||
showName: boolean;
|
||||
nameW: number;
|
||||
nameH: number;
|
||||
/** Height of the brand zone (name or logo box) including its top offset and the gap below. */
|
||||
zoneH: number;
|
||||
zoneTop: number;
|
||||
nameOffset: number;
|
||||
stackH: number;
|
||||
h: number;
|
||||
};
|
||||
info: { clientW: number; hasIds: boolean; idsH: number; clientH: number; h: number };
|
||||
columns: ColSpec[];
|
||||
headerCells: string[];
|
||||
rows: TableCellValue[][];
|
||||
align: Array<"left" | "center" | "right">;
|
||||
padLeft: number[];
|
||||
padRight: number[];
|
||||
padY: number;
|
||||
headPadY: number;
|
||||
cw: { price: number; qty: number; amount: number };
|
||||
table: ItemsTablePlan;
|
||||
guardH: number;
|
||||
tail: { boxW: number; labelW: number; valueW: number; leftW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rowH: number; boxH: number; wordsH: number; reverseH: number; leftH: number; body: number };
|
||||
closing: {
|
||||
leftW: number;
|
||||
notes: string[];
|
||||
notesBeside: boolean;
|
||||
notesH: number;
|
||||
flowH: number;
|
||||
signH: number;
|
||||
bandH: number;
|
||||
valueW: number;
|
||||
payH: number;
|
||||
vendorH: number;
|
||||
footerH: number;
|
||||
h: number;
|
||||
};
|
||||
gaps: { band: number; infoTop: number; infoBottom: number; closeBand: number; closeBottom: number };
|
||||
tailH: number;
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the layout must know before rendering: the right-hand stack (it reserves the logo's whole box, so the
|
||||
* date and number lines flow under the logo), the title size, the columns, the totals box and the heights of the tail
|
||||
* and the closing block (their minPresenceAhead values), each a hair high rather than low.
|
||||
*/
|
||||
export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVariant, logo: PlacedLogo | null, includesName: boolean): MonolithPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const s = model.supplier;
|
||||
const c = model.client;
|
||||
|
||||
// --- header: the title at the left, the brand zone and the date / number stack at the right -----------------
|
||||
const lines = stackLines(model);
|
||||
const showName = !(logo && includesName) && s.name.trim() !== "";
|
||||
const markBlock = logo ? logo.w + (showName ? v.stack.markGap : 0) : 0;
|
||||
const natural = Math.max(...lines.map((l) => widthOf(roleOf(v, l.kind), l.text)), showName ? widthOf(r.brand, s.name) + markBlock : markBlock);
|
||||
let stackW = Math.min(Math.max(v.stack.minW, natural), Math.floor(W * v.stack.maxShare));
|
||||
stackW = Math.max(stackW, markBlock + (showName ? v.stack.minNameW : 0), logo?.w ?? 0);
|
||||
const nameW = showName ? stackW - markBlock : 0;
|
||||
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
|
||||
const zoneTop = logo ? v.stack.logoTop : v.stack.nameTop;
|
||||
const zoneBody = logo ? Math.max(logo.h, nameH) : nameH;
|
||||
const zoneH = zoneTop + zoneBody + (logo ? v.stack.logoGap : 0);
|
||||
const nameOffset = logo ? Math.max(0, (logo.h - nameH) / 2) : 0;
|
||||
let linesH = 0;
|
||||
for (const l of lines) linesH += idLines(roleOf(v, l.kind), l.text, stackW) * leading(roleOf(v, l.kind));
|
||||
const stackH = zoneH + linesH;
|
||||
const titleW = Math.max(40, W - stackW - v.titleGap - v.titleIndent);
|
||||
const tFit = fitSize(r.title, model.docTitle, titleW);
|
||||
const headH = Math.max(stackH, leading(r.title));
|
||||
|
||||
// --- info row: BILLED TO at the left, the supplier's identifiers at the right ---------------------------------
|
||||
const gstinLines = slotHeights.gstinLines(model, "supplier");
|
||||
const hasIds = gstinLines + (s.pan ? 1 : 0) > 0;
|
||||
const clientW = hasIds ? W - v.sideW - v.sideGap : W;
|
||||
let clientH = leading(r.heading) + v.headingGap + textHeight(r.body, c.name, clientW);
|
||||
for (const l of c.addressLines) clientH += textHeight(r.body, l, clientW);
|
||||
clientH += slotHeights.gstinLines(model, "client") * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, clientW) * leading(r.body);
|
||||
if (c.poNumber) clientH += textHeight(r.body, `PO: ${c.poNumber}`, clientW);
|
||||
const idsH = hasIds ? leading(r.heading) + v.headingGap + (gstinLines ? idLines(r.body, GSTIN_SAMPLE, v.sideW) * leading(r.body) : 0) + (s.pan ? idLines(r.body, `PAN: ${s.pan}`, v.sideW) * leading(r.body) : 0) : 0;
|
||||
const infoH = Math.max(clientH, idsH);
|
||||
|
||||
// --- table ---------------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6;
|
||||
const cw = {
|
||||
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)),
|
||||
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
|
||||
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
|
||||
const headerCells = ["Description", "Price", "Qty", "Total"];
|
||||
const align: MonolithPlan["align"] = ["left", "right", "center", "right"];
|
||||
const padLeft = [t.descInset, 0, 0, 0];
|
||||
const padRight = [8, t.numPad, 0, t.numPad];
|
||||
const headPadY = (t.headH - 2 * v.heavyRule - leading(r.tableHead)) / 2;
|
||||
const rows = tableRows(model, v, cw.price - t.numPad - 2);
|
||||
const n = rows.length;
|
||||
const planTable = (padY: number, tailH: number) =>
|
||||
planItemsTable({ columns, gap: 0, width: W, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY, border: 0, headBorder: v.heavyRule, headerTailH: v.heavyRule, tailH, contentH: frame.contentHeight });
|
||||
|
||||
// --- totals: the stack, a rule, the Total row; amount in words and reverse charge at the left ---------------------------
|
||||
const tt = model.totals;
|
||||
const o = v.totals;
|
||||
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
|
||||
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
|
||||
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
|
||||
const slotRows = slotHeights.taxRowCount(model);
|
||||
const taxTexts = slotHeights.taxRowTexts(model, atRate);
|
||||
const labels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
|
||||
const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)];
|
||||
const labelW = Math.max(o.labelW, Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)), measureToken(m, r.total, "Total"))) + 12);
|
||||
const valueW = Math.max(o.valueW, Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.total, tt.totalText))) + o.valuePad + 6);
|
||||
const boxW = Math.max(o.boxW, labelW + valueW);
|
||||
const rowH = leading(r.totalLabel) + o.rowGap;
|
||||
const boxH = o.top + (stack.length + slotRows) * rowH + o.ruleGap + v.rule + o.totalGap + leading(r.total);
|
||||
const leftW = Math.max(0, W - boxW - o.leftGap);
|
||||
const wordsH = slotHeights.amountInWords(model, leftW, { role: r.small, labelRole: r.smallStrong });
|
||||
const reverseH = slotHeights.reverseCharge(model, leftW, r.small);
|
||||
const leftH = wordsH || reverseH ? o.top + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0;
|
||||
const body = Math.max(boxH, leftH);
|
||||
const tailH = v.rule + body;
|
||||
|
||||
// --- closing: notes and signature, then the footer between two rules ------------------------------------------------
|
||||
const cl = v.closing;
|
||||
const cLeftW = W - v.sideW - v.sideGap;
|
||||
const notes = model.notesLines;
|
||||
const notesH = notes.length ? leading(r.smallStrong) + LABEL_GAP + notes.reduce((h, l) => h + textHeight(r.small, l, cLeftW), 0) : 0;
|
||||
const notesBeside = notes.length === 0 || notesH <= cl.notesBesideMax;
|
||||
const flowH = notesBeside ? 0 : notesH;
|
||||
const signH = signatoryHeight(model, v.sideW, signatureOptions(v));
|
||||
const bandH = Math.max(notesBeside ? notesH : 0, signH);
|
||||
const valueColW = cLeftW - cl.labelW - cl.colonW;
|
||||
const payH = model.bank
|
||||
? leading(r.heading) + cl.headingGap + textHeight(r.body, model.bank.accountName, cLeftW) + paymentRows(model).reduce((h, x) => h + textHeight(r.body, x.value, valueColW), 0)
|
||||
: 0;
|
||||
let vendorH = textHeight(r.vendor, s.name, v.sideW) + cl.headingGap;
|
||||
for (const l of s.addressLines) vendorH += textHeight(r.body, l, v.sideW);
|
||||
if (s.phone) vendorH += idLines(r.body, s.phone, v.sideW) * leading(r.body);
|
||||
if (s.email) vendorH += idLines(r.body, s.email, v.sideW) * leading(r.body);
|
||||
const footerH = Math.max(payH, vendorH);
|
||||
const cancelledH = model.cancelled ? 10 + textHeight(r.small, CANCELLED_SENTENCE, W) : 0;
|
||||
const closingFor = (g: { closeBand: number; closeBottom: number }) => bandH + g.closeBand + v.rule + cl.top + footerH + g.closeBottom + v.rule + cancelledH;
|
||||
|
||||
// --- gaps: the empty band under the info row and the row padding give back Letter's missing height ------------------------
|
||||
const padYn = t.padY(n);
|
||||
const ranges = { ...v.gaps, rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n } };
|
||||
const closingNominal = closingFor({ closeBand: ranges.closeBand.nominal, closeBottom: ranges.closeBottom.nominal });
|
||||
const table0 = planTable(padYn, 0);
|
||||
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
|
||||
const notesBlock = notesBeside ? 0 : NOTES_FLOW_GAP + flowH;
|
||||
const nominalTotal = headH + v.headRuleGap + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailH + notesBlock + closingNominal;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together on
|
||||
// page 1 and let only the closing block move to the next page.
|
||||
const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight;
|
||||
// The estimates are a hair low or high, so a page the gaps fill exactly could still spill by a fraction: keep a little air.
|
||||
const air = (over: number) => Math.min(slack, over + FIT_AIR);
|
||||
const fits = overflow > 0 && overflow <= slack ? air(overflow) : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? air(overflowBeforeClosing) : 0;
|
||||
const deficit = Math.max(frame.heightDeficit, fits);
|
||||
const g = elastic(ranges, deficit);
|
||||
const closingH = closingFor(g);
|
||||
const padY = n ? g.rowPad / (2 * n) : padYn;
|
||||
const table = planTable(padY, tailH);
|
||||
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailH : 0);
|
||||
|
||||
return {
|
||||
v,
|
||||
W,
|
||||
head: { titleSize: tFit.size, titleW, stackW, lines, showName, nameW, nameH, zoneH, zoneTop, nameOffset, stackH, h: headH },
|
||||
info: { clientW, hasIds, idsH, clientH, h: infoH },
|
||||
columns,
|
||||
headerCells,
|
||||
rows,
|
||||
align,
|
||||
padLeft,
|
||||
padRight,
|
||||
padY,
|
||||
headPadY,
|
||||
cw,
|
||||
table,
|
||||
guardH,
|
||||
tail: { boxW, labelW, valueW, leftW, stack, slotRows, rowH, boxH, wordsH, reverseH, leftH, body },
|
||||
closing: { leftW: cLeftW, notes, notesBeside, notesH, flowH, signH, bandH, valueW: valueColW, payH, vendorH, footerH, h: closingH },
|
||||
gaps: { band: g.band, infoTop: g.infoTop, infoBottom: g.infoBottom, closeBand: g.closeBand, closeBottom: g.closeBottom },
|
||||
tailH,
|
||||
closingH,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Sides } from "../../engine/geometry";
|
||||
import { defineRoles, type TypeToken } from "../../fonts/roles";
|
||||
import type { GapRange } from "../neutral/variant";
|
||||
|
||||
const P = "Poppins" as const;
|
||||
export const INK = "#000000";
|
||||
|
||||
/**
|
||||
* Monolith's type (docs/templates/03-monolith.md): Poppins Bold for the title, headings, labels and the Total row,
|
||||
* Medium for every data line, Regular for the date. Everything is black. Poppins ascent 1.05, descent 0.35.
|
||||
*/
|
||||
export const MONOLITH_ROLES = defineRoles({
|
||||
/** "INVOICE" / "TAX INVOICE": 60 pt in the design, shrunk to the room the right-hand stack leaves. */
|
||||
title: { face: P, weight: 700, size: 60.02, leading: 66, caps: true, minSize: 30 },
|
||||
/** The vendor name (or its logo) at the top right. */
|
||||
brand: { face: P, weight: 700, size: 12.5, leading: 21, minSize: 8 },
|
||||
metaDate: { face: P, weight: 400, size: 12.5, leading: 21, minSize: 8 },
|
||||
metaNo: { face: P, weight: 700, size: 12.5, leading: 21, minSize: 8 },
|
||||
/** BILLED TO:, SUPPLIER:, PAYMENT INFORMATION :. */
|
||||
heading: { face: P, weight: 700, size: 14.5, leading: 20, caps: true },
|
||||
body: { face: P, weight: 500, size: 12.5, leading: 17.25, minSize: 8 },
|
||||
bodyStrong: { face: P, weight: 700, size: 12.5, leading: 17.25, minSize: 8 },
|
||||
colon: { face: P, weight: 400, size: 12.5, leading: 17.25 },
|
||||
/** The footer's vendor name. */
|
||||
vendor: { face: P, weight: 700, size: 12.5, leading: 20 },
|
||||
tableHead: { face: P, weight: 700, size: 12.5, leading: 17.25, caps: true },
|
||||
tableBody: { face: P, weight: 500, size: 12.5, leading: 17.25 },
|
||||
tableSub: { face: P, weight: 500, size: 10, leading: 13.5 },
|
||||
totalLabel: { face: P, weight: 700, size: 12.5, leading: 17.25 },
|
||||
totalValue: { face: P, weight: 500, size: 12.5, leading: 17.25 },
|
||||
total: { face: P, weight: 700, size: 12.5, leading: 17.25 },
|
||||
small: { face: P, weight: 500, size: 10.5, leading: 14.5 },
|
||||
smallStrong: { face: P, weight: 700, size: 10.5, leading: 14.5, caps: true },
|
||||
signName: { face: P, weight: 700, size: 11, leading: 15 },
|
||||
signDesignation: { face: P, weight: 500, size: 10, leading: 14 },
|
||||
fine: { face: P, weight: 500, size: 9, leading: 12.5 },
|
||||
footer: { face: P, weight: 500, size: 7 },
|
||||
});
|
||||
|
||||
export type MonolithRoles = typeof MONOLITH_ROLES;
|
||||
|
||||
/**
|
||||
* Canva A4 numbers (docs 03): content x 35..560.3, title origin 16 pt inside the left edge, the right-hand column
|
||||
* ends on the right edge; y values are the design's distances turned into flow gaps.
|
||||
*/
|
||||
export interface MonolithVariant {
|
||||
roles: MonolithRoles;
|
||||
ink: string;
|
||||
margins: Sides;
|
||||
marginFloor: Sides;
|
||||
/** Title origin inside the content edge. */
|
||||
titleIndent: number;
|
||||
/** Space between the title and the right-hand stack at its narrowest. */
|
||||
titleGap: number;
|
||||
stack: {
|
||||
/** Narrowest the right-hand stack gets (the logo allowance's width). */
|
||||
minW: number;
|
||||
/** Widest share of the content width. */
|
||||
maxShare: number;
|
||||
/** Top of the vendor name's line box below the header's top (cap height lines up with the title's). */
|
||||
nameTop: number;
|
||||
/** Top of the logo box below the header's top: the allowance's top edge. */
|
||||
logoTop: number;
|
||||
/** Space between the logo box's bottom and the date line. */
|
||||
logoGap: number;
|
||||
/** Gap between a mark and the vendor name beside it, and the narrowest the name may get. */
|
||||
markGap: number;
|
||||
minNameW: number;
|
||||
};
|
||||
rule: number;
|
||||
heavyRule: number;
|
||||
/** Header bottom to its rule. */
|
||||
headRuleGap: number;
|
||||
/** The rule to the BILLED TO heading, and the last info line to the next rule. */
|
||||
/** Heading to its first line. */
|
||||
headingGap: number;
|
||||
/** Width of the right-hand columns (supplier ids, footer vendor, signature) and the space beside them. */
|
||||
sideW: number;
|
||||
sideGap: number;
|
||||
gaps: {
|
||||
/** The empty band between the info row and the table (88 pt in the design). */
|
||||
band: GapRange;
|
||||
/** The rule to BILLED TO, and the last info line to the next rule. */
|
||||
infoTop: GapRange;
|
||||
infoBottom: GapRange;
|
||||
/** The signature band to the footer, and the footer text to its bottom rule. */
|
||||
closeBand: GapRange;
|
||||
closeBottom: GapRange;
|
||||
};
|
||||
table: {
|
||||
/** Header band height inside the heavy rules. */
|
||||
headH: number;
|
||||
descInset: number;
|
||||
cols: { price: number; qty: number; amount: number };
|
||||
/** Right padding of the price and amount columns. */
|
||||
numPad: number;
|
||||
/** Vertical row padding by item count: generous like the design for a few rows, tighter for many. */
|
||||
padY: (rows: number) => number;
|
||||
padYMin: number;
|
||||
};
|
||||
totals: { boxW: number; labelW: number; valueW: number; top: number; rowGap: number; ruleGap: number; totalGap: number; valuePad: number; leftGap: number; wordsMax: number };
|
||||
closing: {
|
||||
/** The footer's top rule to its heading, and the heading to its first line. */
|
||||
top: number;
|
||||
headingGap: number;
|
||||
labelW: number;
|
||||
colonW: number;
|
||||
notesBesideMax: number;
|
||||
signatureImageH: number;
|
||||
};
|
||||
footerLeftW: number;
|
||||
}
|
||||
|
||||
export const MONOLITH_VARIANT: MonolithVariant = {
|
||||
roles: MONOLITH_ROLES,
|
||||
ink: INK,
|
||||
margins: { top: 33, right: 35, bottom: 22, left: 35 },
|
||||
marginFloor: { top: 24, right: 30, bottom: 18, left: 30 },
|
||||
titleIndent: 15.7,
|
||||
titleGap: 20,
|
||||
stack: { minW: 160, maxShare: 0.46, nameTop: 17.6, logoTop: 21.75, logoGap: 4, markGap: 8, minNameW: 70 },
|
||||
rule: 0.75,
|
||||
heavyRule: 1.5,
|
||||
headRuleGap: 3.15,
|
||||
headingGap: 7.2,
|
||||
sideW: 165,
|
||||
sideGap: 24,
|
||||
gaps: { band: { nominal: 88, min: 24 }, infoTop: { nominal: 21, min: 14 }, infoBottom: { nominal: 21.5, min: 14 }, closeBand: { nominal: 18, min: 8 }, closeBottom: { nominal: 14.8, min: 8 } },
|
||||
table: {
|
||||
headH: 39.3,
|
||||
descInset: 9.4,
|
||||
cols: { price: 98, qty: 74, amount: 98 },
|
||||
numPad: 10,
|
||||
padY: (rows) => (rows <= 3 ? 16 : rows <= 5 ? 12 : 8),
|
||||
padYMin: 6,
|
||||
},
|
||||
totals: { boxW: 215.5, labelW: 108, valueW: 107.5, top: 6.15, rowGap: 5.75, ruleGap: 3, totalGap: 6.35, valuePad: 10, leftGap: 16, wordsMax: 120 },
|
||||
closing: { top: 6.9, headingGap: 12.9, labelW: 114, colonW: 21, notesBesideMax: 110, signatureImageH: 45 },
|
||||
footerLeftW: 336,
|
||||
};
|
||||
|
||||
export const leading = (t: TypeToken) => t.leading as number;
|
||||
@@ -28,7 +28,7 @@ const nodes = (layout: LayoutNode[]) => {
|
||||
|
||||
describe("registry and catalog", () => {
|
||||
it("lists Classic first and Linea second, then the Neutral rest, the Ledger and the Band families", () => {
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
|
||||
@@ -55,14 +55,14 @@ const near = (c: number[], hex: string, tol = 6) => {
|
||||
};
|
||||
|
||||
describe("registry, catalog and fonts", () => {
|
||||
it("lists Purple Pop and Citrus Split last, in the Northline family, with their thumbnails on disk", () => {
|
||||
it("lists Purple Pop and Citrus Split before the standalone templates, in the Northline family, with their thumbnails on disk", () => {
|
||||
for (const t of NORTHLINE) {
|
||||
expect(getTemplate(t.id)).toBe(t);
|
||||
expect(t.family).toBe("northline");
|
||||
expect(catalogEntry(t.id).name).toBe(t.name);
|
||||
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
|
||||
}
|
||||
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["purple-pop", "citrus-split"]);
|
||||
expect(Object.keys(TEMPLATES).slice(-4, -2)).toEqual(["purple-pop", "citrus-split"]);
|
||||
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
expect(TEMPLATE_CATALOG.filter((e) => e.logoOptIn).map((e) => e.id)).toEqual(["linea", "purple-pop"]);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { slateBand } from "./band/slate";
|
||||
import { tealSwoosh } from "./band/teal";
|
||||
import { citrusSplit } from "./northline/citrus";
|
||||
import { purplePop } from "./northline/purple";
|
||||
import { cobaltStripe } from "./cobalt/cobalt";
|
||||
import { monolith } from "./monolith/monolith";
|
||||
import { monogram } from "./neutral/monogram";
|
||||
import { serenity } from "./neutral/serenity";
|
||||
|
||||
@@ -22,6 +24,8 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
"teal-swoosh": tealSwoosh,
|
||||
"purple-pop": purplePop,
|
||||
"citrus-split": citrusSplit,
|
||||
monolith,
|
||||
"cobalt-stripe": cobaltStripe,
|
||||
};
|
||||
|
||||
export const DEFAULT_TEMPLATE_ID = "classic";
|
||||
|
||||
Reference in New Issue
Block a user