From 579508742fb0025443f8e4dd2f382c0e1fdebf99 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 12:47:05 +0530 Subject: [PATCH] Add the Marble and Highlighter templates, completing the 13 Canva templates Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL). Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the tests work around with warm-up renders; the engine fix follows separately. --- docs/templates/01-marble.md | 18 + docs/templates/02-highlighter.md | 16 + public/fonts/lustria/Lustria-Regular.ttf | Bin 0 -> 37400 bytes public/fonts/lustria/OFL.txt | 93 +++ public/fonts/manifest.json | 23 + public/templates/highlighter.png | Bin 0 -> 71725 bytes public/templates/marble.png | Bin 0 -> 56769 bytes scripts/templates/compare.mjs | 45 +- src/pdf/fonts/register.test.ts | 1 + src/pdf/fonts/roles.ts | 1 + src/pdf/quirks.test.tsx | 9 + src/pdf/templates/band/band.test.tsx | 2 +- src/pdf/templates/catalog.test.ts | 6 +- src/pdf/templates/catalog.ts | 2 + src/pdf/templates/classic/classic.test.tsx | 2 +- src/pdf/templates/cobalt/cobalt.test.tsx | 4 +- .../harness.sweep.standalone.test.tsx | 14 +- src/pdf/templates/harness.sweep.ts | 2 +- src/pdf/templates/highlighter/Layout.tsx | 575 ++++++++++++++++++ .../highlighter/highlighter.test.tsx | 425 +++++++++++++ src/pdf/templates/highlighter/highlighter.tsx | 40 ++ src/pdf/templates/highlighter/plan.ts | 305 ++++++++++ src/pdf/templates/highlighter/tokens.ts | 168 +++++ src/pdf/templates/marble/Layout.tsx | 392 ++++++++++++ src/pdf/templates/marble/marble.test.tsx | 483 +++++++++++++++ src/pdf/templates/marble/marble.tsx | 146 +++++ src/pdf/templates/marble/plan.ts | 277 +++++++++ src/pdf/templates/marble/tokens.ts | 156 +++++ src/pdf/templates/monolith/monolith.test.tsx | 2 +- src/pdf/templates/neutral/neutral.test.tsx | 2 +- .../templates/northline/northline.test.tsx | 4 +- src/pdf/templates/registry.ts | 4 + 32 files changed, 3190 insertions(+), 27 deletions(-) create mode 100644 public/fonts/lustria/Lustria-Regular.ttf create mode 100644 public/fonts/lustria/OFL.txt create mode 100644 public/templates/highlighter.png create mode 100644 public/templates/marble.png create mode 100644 src/pdf/templates/highlighter/Layout.tsx create mode 100644 src/pdf/templates/highlighter/highlighter.test.tsx create mode 100644 src/pdf/templates/highlighter/highlighter.tsx create mode 100644 src/pdf/templates/highlighter/plan.ts create mode 100644 src/pdf/templates/highlighter/tokens.ts create mode 100644 src/pdf/templates/marble/Layout.tsx create mode 100644 src/pdf/templates/marble/marble.test.tsx create mode 100644 src/pdf/templates/marble/marble.tsx create mode 100644 src/pdf/templates/marble/plan.ts create mode 100644 src/pdf/templates/marble/tokens.ts diff --git a/docs/templates/01-marble.md b/docs/templates/01-marble.md index 582949d..9d7a404 100644 --- a/docs/templates/01-marble.md +++ b/docs/templates/01-marble.md @@ -160,3 +160,21 @@ Structure (react-pdf 4.9 with yoga 3): ## Similarity to other templates No near-duplicate in the set. This is the only template that combines photographic texture bands, zebra rows and a right-aligned tracked serif title (6 also uses a raster paper texture, with a different layout). It shares the tracked-caps title idea with 4/format.pdf and 9/10, and the grey-band idea with 9/10. It has nothing in common with the in-app IBM Plex template beyond the data blocks. Effort is medium: new header, parties band, zebra table, band decorations and a font substitution. + +## Implementation notes + +Built as `src/pdf/templates/marble/`: a standalone template (`tokens.ts` the type roles, palette and Canva distances, `plan.ts` the pure planner, `Layout.tsx` the body, `marble.tsx` the definition and the page art) on the shared blocks. Measured against `1.pdf` with `npm run templates:compare -- marble --rows=3` (the sample images are in `/tmp/invoice-c6b/`, left Canva, right ours; `--numbers` prints the baselines). Lustria is bundled for this template (`public/fonts/lustria`, OFL, regular only; the upright file is registered as its italic, like the other families without one). + +**Matches the design (measured, A4):** the title baseline 124.9 with its ink ending on the right edge (Canva 124.9 / 549.0); Issued and Due at 159.6 and 177.1 (159.6 / 177.1), ending on the same edge; the vendor name at x 38.5, level with the title; BILL TO and PAYABLE TO at 247.9 with the first lines at 269.0 and the 18.2 pt pitch after them (247.9 / 269.0 / 287.2 / 305.4), the columns at x 38.5, 211.4 and 380; the table heading 32.3 pt above the first row baseline (32.3) and the first band 8.85 pt under it; rows of 32 pt in `#F7F7F8` on the 1st, 3rd, 5th row, the description 9.5 pt inside the band's edge; qty centred on x 355.1 (355.5); the hairlines `#E7E1E6` at 0.75 pt from x 31.5 to 549.5; AMOUNT DUE ending on x 535.4 (535.4); the footer lines at 746.9 / 762.2 / 777.5 with the second column at x 197.8 while the address is short (746.9 / 762.2 / 777.5, 197.8). Every colour is one of the design's (a test scans the sources); the type is Lustria 25.18 / 12.65 / 11.68 / 14.6 pt at the measured tracking and Jost at the sizes of the design. + +**Intentional deviations, and why:** +- **The marble photograph** is not bundled (stock media). Both bands are full-bleed, 64 pt at the top and 130 pt at the foot, filled with a soft grey sheen (a static Svg gradient from `#F4F3F5` through `#ECEAEE` back to `#F4F3F5`), on every page. The flow starts at y 86 (under the band and the continuation line) and stops 12 pt above the foot band; Letter keeps the same band, so its missing 50 pt come out of the gaps. +- **Now is drawn in Jost.** Jost sets about 12% narrower than Now, so the body roles carry 0.10 to 0.13 em of tracking on top of the design's 0.04 em; that brings a string such as "Description of service goes here" to the Canva width (180 pt). The right-aligned tracked lines are drawn one tracking step into the margin, so their ink ends on the edge. +- **Header.** The number goes on its own line ("Invoice No. AP/2026-001") between the title and Issued, so a "TAX INVOICE" title never meets a long number. The title shrinks (to 13 pt at the least) when the stack is narrow; a number too wide for the stack shrinks, then splits at `@ . / -`. Due shows "On Receipt" when the invoice has no due date. A cancelled invoice has a red CANCELLED line above the number and its sentence at the foot. +- **Left corner.** The design leaves it empty: it holds the vendor name in Lustria caps (level with the title), or the opt-in logo. The logo is off by default (the template has no placeholder), 150 x 50 at x 38.5 with its bottom edge on y 136; a wordmark replaces the name, a mark keeps it under the mark. +- **Parties band.** Three columns: BILL TO (name, address, GSTIN, place of supply, PO), PAYABLE TO (the account holder, then the bank lines as "label: value", the account number as "A/c No.") and SUPPLIER (GSTIN and PAN). A column with nothing to show is left out and the next one moves up; the band grows with the tallest one. +- **Table.** HOURS / PRICE / TOTAL read Qty / Rate / Total. The rate keeps its unit on its own line ("₹1,200.00/hr") while the column can grow to take it (the column then widens, taking the room from the description); qty stays centred, the rate and total are right-aligned (9.5 pt inside the edge) so Indian-format numbers line up, which Canva does not do (it centres them). HSN/SAC is an 8.5 pt sub-line under the description; wrapped descriptions grow the row (the zebra fill is the row's own). +- **Totals.** The stack (SUBTOTAL, DISCOUNT, TAXABLE VALUE, CGST / SGST / IGST) is Lustria 9 pt caps labels with the values right-aligned, over AMOUNT DUE in the design's 14.6 pt (the rupee sign comes from the fallback font). The amount in words and the reverse-charge line are at the left, level with the first row; they never reach the stack. +- **Closing.** The notes (when 90 pt tall or less) sit at the left and the signature (the user's image, or the signatory and "Computer-generated invoice; no signature is required.") at the right, above the foot band, never inside it. Longer notes flow as a block of their own before the closing; a block that would start with less than a line of room starts on the next page. +- **Foot band.** The vendor name and address (column 1, x 40.7, up to 190 pt wide, five lines on A4 and four on Letter, an ellipsis after that) and the phone and e-mail (column 2, one fitted line each, shrunk before an ellipsis) are page art, printed on every page. Column 2 stays at x 197.8 while the address is short and moves right of column 1 when it is not. A name or address with one unbreakable word is fitted to one line. The full supplier identity is in the parties band, so nothing is lost to the cap. The "Generated with Voiced" line and the page label sit inside the band, 14 pt off the page foot. +- **Margins.** 31.5 pt left and 45.8 pt right (the design's asymmetry), 86 pt at the top (the logo slot's top), 24 pt plus the 118 pt reserve at the foot; the narrow and normal presets replace the sides and the foot margin with 36 and 54 pt (the content then stops higher above the band) and the top stays at its 86 pt floor. Elastic gaps (Letter, and A4 when a one-page invoice overflows by less than the slack): header to hairline (36.8 to 14 pt), the hairlines' spacing, hairline to table heading (50.2 to 22), row padding (down to 3.5 pt a side), last row to the totals and the totals to the signature. diff --git a/docs/templates/02-highlighter.md b/docs/templates/02-highlighter.md index 964ee9b..739c7d9 100644 --- a/docs/templates/02-highlighter.md +++ b/docs/templates/02-highlighter.md @@ -155,3 +155,19 @@ Voiced fields with no Canva slot, and where they go: ## Similarity to other templates Closest siblings are 7 and 11 (Poppins, a colour-filled header on a boxed grid table, boxed totals) and 8 (filled blue header plus an icon contact row). Only 2 has underlined fill-in fields, a detached header bar and 3pt frames. It is unrelated to the in-app template and to format.pdf/4.pdf. If 7 and 11 are built too, share one 'boxed grid table' component with accent colour and frame weight as parameters. + +## Implementation notes + +Built as `src/pdf/templates/highlighter/`: a standalone template (`tokens.ts` the type roles, palette and Canva distances, `plan.ts` the pure planner, `Layout.tsx` the body, `highlighter.tsx` the definition) on the shared blocks and the Carbon icon paths (`ContactIcon`). Measured against `2.pdf` with `npm run templates:compare -- highlighter --rows=3` (images in `/tmp/invoice-c6b/`, left Canva, right ours; `--numbers` prints the baselines). The Canva page is a blank form, so the comparison sample here fills the fields and rows; every distance below was measured with the design's own content shape (no due date, no address, a two-line payment block). + +**Matches the design (measured, A4):** the title baseline 98.0 at x 60.3 (98.0 / 60.3); the fields at 132.2 / 166.3 / 200.4 (132.2 / 166.0 / 200.4) with their label at x 60.3 and the value 5 pt after it on a 0.75 pt `#423869` underline ending at x 266; "Payment Method:" at 162.9 and the bold bank lines at 185.1 and the 16.5 pt pitch (162.9 / 185.1 / 201.6), x 352.5; the table bar 38.4 pt tall (frame included) with the Qty and Price headings centred on x 277.4 and 360.8 (277.4 / 360.8) and the Total heading 1.9 pt right of the design's (the design sets it 2.4 pt left of its column centre); a 4.5 pt gap under the bar, the body's own 3 pt frame, hairlines between the rows and columns; rows of 30.65 pt; the totals box one price-column wide plus one total column, its total label cell yellow; the sign-off and its ruled lines 212.3 pt wide ending 1.5 pt inside the frame's edge. Every colour is one of the design's four (a test scans the sources); the type is Poppins 400/700 and Montserrat 400 at the design's sizes. + +**Intentional deviations, and why:** +- **Content box.** The 3 pt strokes are centred on x 59.6 and 536.0, so the frames reach 1.5 pt outside them: the content box is the frame's outer edge (58.1 to 537.5, 479.4 wide), and everything is measured from it. Margins are 58.1 / 57.8 pt at the sides, 50 pt at the top (the title's line box is the design's 64 pt zone) and 36 pt plus the footer reserve at the foot; the narrow and normal presets replace the margins with 36 and 54 pt, and the frame, the table columns and the totals box follow the content width. +- **Fields.** Date, Invoice number, a Due date field in the same style (only when the invoice has one), then Billed to with the client's name on its underline; the address, GSTIN, place of supply and PO number are 11 pt lines under it. A value that does not fit its underline wraps (the underline moves down) or, for an unbreakable token, shrinks and then splits at `@ . / -`. Pitch is a steady 34.1 pt (the design's is 33.8 / 34.4). The fields and the payment block sit side by side and the table starts under the taller one, at least 18 pt below it (the design leaves 64 pt, which Letter and a longer client block take back, down to 14 pt). +- **Top right.** The vendor's name (Poppins Bold 13), GSTIN and PAN, right-aligned in x 352 to 536, or the opt-in logo (off by default; 150 x 50, right-aligned, its top on y 50) above them; a wordmark replaces the name, a mark keeps it under the mark. The zone is 64 pt tall and grows with a logo, which moves the rest down. A cancelled invoice has a red CANCELLED line there and its sentence at the foot. +- **Payment block.** The account holder's name is added as its own line, and the bank lines read "Bank Name : ...", "Account No : ..." (then Branch and IFSC). +- **Table.** The design's rows are empty; ours carry the item: description left (9 pt in) with an HSN/SAC sub-line, qty centred, price and total right-aligned 9 pt inside their cells (the unit stays on the rate's line, "₹1,200.00/hr", while the column can take it). The form look pads with empty rows to 8 on A4 and 7 on Letter, but the pad rows give way first: they are dropped before the sign-off would have to move to a second page, and a long invoice has none. The 3 pt frame is closed at the foot after the last row only; where a table breaks onto a second page the page ends on the row's hairline (the frame's sides run to it), and the next page repeats the bar and the body's top rule. +- **Totals.** A stack of two-cell rows (label cell | value cell) lined up with the price and total columns: Subtotal, Discount, Taxable value, CGST / SGST / IGST ("CGST @ 9% :") with 0.75 pt rules between the rows and the 3 pt frame around them, and the "total :" row with the yellow label cell and a bold value. When a label needs more room than the price column has, the box grows to the left. The amount in words and the reverse-charge line are at its left, level with its first row. +- **Sign-off.** THANK YOU and the phone, e-mail and address with Carbon icons (the design's own Material paths are not used, so the app keeps one icon set); the phone and e-mail are one fitted line each (a wrapped phone number can break after a bracket), the address wraps. The ruled lines carry the notes, one wrapped line per ruled line (three lines at least; more than six lines and the notes flow as a block of their own before the sign-off), and the signature block (the image or "Computer-generated invoice; no signature is required.", the rule, the name and designation) takes the last one. The sign-off sits at the foot of the last page. +- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): fields to table bar (down to 14 pt), the row padding (down to 4.5 pt a side) and the totals to the sign-off (24 to 12 pt). The type is never scaled. diff --git a/public/fonts/lustria/Lustria-Regular.ttf b/public/fonts/lustria/Lustria-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ce9c41dc986373733bee5eeb3a3f54fcfa87c1a7 GIT binary patch literal 37400 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 MWo@Mj1*`D?0VkCqSpWb4 literal 0 HcmV?d00001 diff --git a/scripts/templates/compare.mjs b/scripts/templates/compare.mjs index b3f632f..24c4612 100644 --- a/scripts/templates/compare.mjs +++ b/scripts/templates/compare.mjs @@ -1,20 +1,25 @@ // 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 [-- monolith cobalt-stripe] -// Output: /tmp/invoice-c6a/ (override with --out=DIR). With --numbers it also prints text baselines (Canva vs ours) for tuning. +// Option --rows=N overrides the sample's item count (fewer rows keep every gap at its nominal size). Output: /tmp/invoice-c6b/ (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-c6a"; +const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c6b"; 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, monolith: 3, "cobalt-stripe": 8 }; +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, marble: 7, highlighter: 3 }; 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 : ["monolith", "cobalt-stripe"]; +const targets = ids.length ? ids : ["monolith", "cobalt-stripe", "marble", "highlighter"]; +// Templates with no Canva logo placeholder: the logo shows only when the user turns it on. +const OPT_IN = ["linea", "purple-pop", "marble", "highlighter"]; +// Of those, the ones whose compare images show the opt-in logo (Linea stays logo-free, like format.pdf). +const LOGO_ON = ["purple-pop"]; +const LOGO_SAMPLES_ON = ["purple-pop", "marble", "highlighter"]; 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"; @@ -39,26 +44,39 @@ async function main() { const no = PDF_NO[id]; const canvaBytes = readFileSync(path.join(CANVA_DIR, `${no}.pdf`)); const canva = await rasterize(canvaBytes, 1, SCALE); - const rows = ROWS[id] ?? 5; + const rows = Number(argv.find((a) => a.startsWith("--rows="))?.slice(7)) || (ROWS[id] ?? 5); 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 }); const render = async (spec, size = "A4") => { - // Purple Pop has no placeholder, so its logo samples turn the opt-in on (Linea's compare stays logo-free, like format.pdf). - const prefs = r.prefsFor(size, { templateId: id, ...(id === "purple-pop" && spec.logo && spec.logo !== "none" ? { logo: { show: true } } : {}) }); + // Templates with no placeholder turn the opt-in on for their logo samples (Linea's compare stays logo-free, like format.pdf; + // Marble and Highlighter compare logo-free against the Canva page and show the logo in the -logo-* samples). + const on = spec.logo && spec.logo !== "none" && (LOGO_ON.includes(id) || (LOGO_SAMPLES_ON.includes(id) && spec.logoSample)); + const prefs = r.prefsFor(size, { templateId: id, ...(on ? { logo: { show: true } } : {}) }); return r.renderFixture(spec, prefs); }; - // Templates with no Canva placeholder show a logo only when the user turns it on. - const OPT_IN = ["linea", "purple-pop"]; const withLogo = await render(sample({ logo: "real" })); await side(canva, await rasterize(withLogo.result.bytes, 1, SCALE), `compare-${id}.png`); const noLogo = await render(sample({ registered: false, logo: "none" })); await side(canva, await rasterize(noLogo.result.bytes, 1, SCALE), `compare-${id}-nologo.png`); if (numbers) { - const a = await textItems(canvaBytes); - const b = await textItems(withLogo.result.bytes); + // Tracked text comes out of pdf.js one glyph run at a time: join runs on the same baseline that touch. + const merge = (items) => { + const out = []; + for (const it of items) { + const prev = out[out.length - 1]; + if (prev && Math.abs(prev.baseline - it.baseline) < 0.3 && it.x - (prev.x + prev.w) < 4 && it.x >= prev.x) { + prev.str += it.str.length > 1 || prev.str.length > 1 ? ` ${it.str}` : it.str; + prev.w = Math.round((it.x + it.w - prev.x) * 10) / 10; + } else out.push({ ...it }); + } + return out; + }; + // The sample without GST data (noLogo), like the Canva page: one row per item, no sub-lines. + const a = merge(await textItems(canvaBytes)); + const b = merge(await textItems(noLogo.result.bytes)); console.log(`--- ${id}: Canva (${a.length}) vs ours (${b.length}); baseline y, x`); for (const it of a) console.log(` canva ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`); for (const it of b) console.log(` ours ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`); @@ -72,18 +90,19 @@ async function main() { // Dark-surface templates: the white knockout variant (a logo with alpha) next to the chip fallback (no alpha) above. const logoKinds = DARK.includes(id) ? ["square", "wide", "knockout"] : ["square", "wide"]; for (const logo of logoKinds) { - const x = await render(sample({ logo })); + const x = await render(sample({ logo, logoSample: true })); writeFileSync(path.join(OUT, `${id}-A4-logo-${logo}.png`), (await rasterize(x.result.bytes, 1, SCALE)).toBuffer("image/png")); } const sixty = await render({ name: "s60", items: 60, signature: true }); for (let p = 1; p <= Math.min(2, sixty.result.pages); p++) writeFileSync(path.join(OUT, `${id}-A4-60rows-p${p}.png`), (await rasterize(sixty.result.bytes, p, SCALE)).toBuffer("image/png")); - const letter = await render(sample({ logo: "real" }), "LETTER"); + const letter = await render(sample({ logo: "real", logoSample: true }), "LETTER"); writeFileSync(path.join(OUT, `${id}-Letter-tax.png`), (await rasterize(letter.result.bytes, 1, SCALE)).toBuffer("image/png")); const long = await render({ name: "long", items: 4, signature: true, logo: "real", + logoSample: true, vendor: { vendorAddress: LONG, vendorEmail: `${TOKEN}@example-studios-india.co.in`, vendorName: TOKEN }, invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` }, }); diff --git a/src/pdf/fonts/register.test.ts b/src/pdf/fonts/register.test.ts index 85346ae..8f6ece4 100644 --- a/src/pdf/fonts/register.test.ts +++ b/src/pdf/fonts/register.test.ts @@ -19,6 +19,7 @@ describe("font manifest", () => { "IBM Plex Sans", "Inter", "Jost", + "Lustria", "Montserrat", "Noto Sans Devanagari", "Noto Sans Kannada", diff --git a/src/pdf/fonts/roles.ts b/src/pdf/fonts/roles.ts index fdc3da0..ce73313 100644 --- a/src/pdf/fonts/roles.ts +++ b/src/pdf/fonts/roles.ts @@ -15,6 +15,7 @@ export type FaceKey = | "Inter" | "DM Sans" | "Jost" + | "Lustria" // Licensed faces of the Canva designs; SUBSTITUTES maps each to the bundled look-alike. | "Now" | "Gotham" diff --git a/src/pdf/quirks.test.tsx b/src/pdf/quirks.test.tsx index dbcc4dd..313ef26 100644 --- a/src/pdf/quirks.test.tsx +++ b/src/pdf/quirks.test.tsx @@ -21,6 +21,7 @@ import { renderCore } from "./render/core"; import { loadCanvas, inkPixels } from "./testing/rasterize"; import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs"; import { FONTS_DIR, setupPdfTest } from "./testing/setup"; +import { stack } from "./fonts/register"; setupPdfTest(); @@ -301,4 +302,12 @@ describe("react-pdf 4.9.0 quirks the engine relies on", () => { r.layout.forEach((p) => expect(byId(p, "label")[0].lines).toHaveLength(0)); }); }); + it("13. fontkit caches glyph objects per font for the whole process, and pdfkit creates a composite's component glyphs at embed time with no code points: Poppins' colon makes the period glyph keep codePoints [] (so a later document's '.' is not extracted and textkit's glyph-to-string mapping slips)", async () => { + // Must stay the first test that sets Poppins Regular in this file: any earlier '.' in that face would create the period glyph normally. + const poppins = { fontFamily: stack("Poppins"), fontSize: 10, lineHeight: "12pt", fontWeight: 400 }; + await render(doc(h(Text, { style: poppins }, "Bank : State"))); + const data = (Font as unknown as { getFont: (d: object) => { data: { glyphForCodePoint: (cp: number) => { codePoints: number[] } } } }).getFont({ fontFamily: "Poppins", fontWeight: 400, fontStyle: "normal" }).data; + expect(data.glyphForCodePoint(0x2e).codePoints).toEqual([]); + // The Highlighter and Marble tests and the standalone sweep warm Poppins with a document that has amounts (period and comma) first, for this reason. + }); }); diff --git a/src/pdf/templates/band/band.test.tsx b/src/pdf/templates/band/band.test.tsx index 67af4c6..ad6ab4e 100644 --- a/src/pdf/templates/band/band.test.tsx +++ b/src/pdf/templates/band/band.test.tsx @@ -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", "monolith", "cobalt-stripe"]); + expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]); }); it("both are Montserrat throughout, and their slots are the measured Canva ones", () => { diff --git a/src/pdf/templates/catalog.test.ts b/src/pdf/templates/catalog.test.ts index cb64d58..b9d81a7 100644 --- a/src/pdf/templates/catalog.test.ts +++ b/src/pdf/templates/catalog.test.ts @@ -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(12); + expect(TEMPLATE_CATALOG).toHaveLength(14); }); it("groups the C4 templates into the Ledger and Band families", () => { @@ -20,8 +20,8 @@ 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"]); + it("lists the standalone templates, Monolith, Cobalt Stripe, Marble and Highlighter, last", () => { + expect(TEMPLATE_CATALOG.filter((e) => e.family === "standalone").map((e) => e.id)).toEqual(["monolith", "cobalt-stripe", "marble", "highlighter"]); expect(FAMILY_LABELS.standalone).toBe("Standalone"); }); diff --git a/src/pdf/templates/catalog.ts b/src/pdf/templates/catalog.ts index f3ed010..e2be57d 100644 --- a/src/pdf/templates/catalog.ts +++ b/src/pdf/templates/catalog.ts @@ -36,6 +36,8 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [ { 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" }, + { id: "marble", name: "Marble", family: "standalone", thumbnail: "/templates/marble.png", logoOptIn: true }, + { id: "highlighter", name: "Highlighter", family: "standalone", thumbnail: "/templates/highlighter.png", logoOptIn: true }, ]; /** The entry for an id; an unknown id falls back to Classic, like getTemplate. */ diff --git a/src/pdf/templates/classic/classic.test.tsx b/src/pdf/templates/classic/classic.test.tsx index 4346ce2..6330bff 100644 --- a/src/pdf/templates/classic/classic.test.tsx +++ b/src/pdf/templates/classic/classic.test.tsx @@ -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", "monolith", "cobalt-stripe"]); + expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]); expect(getTemplate("classic")).toBe(classic); expect(getTemplate("no-such-template")).toBe(classic); expect(getTemplate(undefined)).toBe(classic); diff --git a/src/pdf/templates/cobalt/cobalt.test.tsx b/src/pdf/templates/cobalt/cobalt.test.tsx index dc6f2a3..3966d04 100644 --- a/src/pdf/templates/cobalt/cobalt.test.tsx +++ b/src/pdf/templates/cobalt/cobalt.test.tsx @@ -64,12 +64,12 @@ async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Pro 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", () => { + it("is registered before Marble and Highlighter, 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(Object.keys(TEMPLATES).slice(-3, -2)).toEqual(["cobalt-stripe"]); expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); }); diff --git a/src/pdf/templates/harness.sweep.standalone.test.tsx b/src/pdf/templates/harness.sweep.standalone.test.tsx index 848deec..1428a6a 100644 --- a/src/pdf/templates/harness.sweep.standalone.test.tsx +++ b/src/pdf/templates/harness.sweep.standalone.test.tsx @@ -1,8 +1,18 @@ -import { expect, it } from "vitest"; +import { beforeAll, expect, it } from "vitest"; +import { prefsFor, renderFixture } from "./harness"; 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. +// Monolith, Cobalt Stripe, Marble and Highlighter: the same sweep as the other templates, in a second file so the two run side by side. +// +// Poppins Regular is warmed with a document that has amounts first. fontkit caches glyph objects per font for the process and +// pdfkit creates a composite's components (the period inside Poppins' colon) at embed time with no code points: Monolith, which +// sets ':' in Poppins Regular but amounts in Medium, would leave the period unextractable for Highlighter's amounts +// (src/pdf/quirks.test.tsx, quirk 13). +beforeAll(async () => { + await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" })); +}); + describeSweep((id) => STANDALONE_SWEEP.includes(id)); it("the standalone sweep names registered templates only", () => { diff --git a/src/pdf/templates/harness.sweep.ts b/src/pdf/templates/harness.sweep.ts index 939971b..87c121f 100644 --- a/src/pdf/templates/harness.sweep.ts +++ b/src/pdf/templates/harness.sweep.ts @@ -4,7 +4,7 @@ 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"]; +export const STANDALONE_SWEEP = ["monolith", "cobalt-stripe", "marble", "highlighter"]; /** 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 { diff --git a/src/pdf/templates/highlighter/Layout.tsx b/src/pdf/templates/highlighter/Layout.tsx new file mode 100644 index 0000000..32e1245 --- /dev/null +++ b/src/pdf/templates/highlighter/Layout.tsx @@ -0,0 +1,575 @@ +import { Image, View } from "@react-pdf/renderer"; +import type { ReactNode } from "react"; +import { colsDetailed, pinned } from "../../engine/columns"; +import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, 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 { Cell, Row } from "../../blocks/Row"; +import { SafeTxt } from "../../blocks/SafeTxt"; +import { pdfFlags } from "../../blocks/pdfProps"; +import { Slot } from "../../blocks/slots"; +import { TableGuard } from "../../blocks/TableGuard"; +import { Txt } from "../../blocks/Txt"; +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 type { CellPart, TableCellValue } from "../../blocks/ItemsTable"; +import { LABEL_GAP, NOTES_FLOW_GAP, paymentLines, planHighlighter, REVERSE_GAP, signatureOptions, taxLabel, type HighlighterPlan } from "./plan"; +import { BLACK, INDIGO, YELLOW, type HighlighterVariant } from "./tokens"; + +/** Layout-tree ids of the blocks that are Highlighter's own (the harness ids are in classic/ids.ts). */ +export const PAY_ID = "highlighter-pay"; +export const PAD_PREFIX = "highlighter-pad-"; + +/** A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */ +const NOTES_HOLD = 70; + +interface Ctx { + model: TemplateProps["model"]; + plan: HighlighterPlan; + v: HighlighterVariant; + logo: PlacedLogo | null; +} + +function Spacer({ h }: { h: number }) { + return h > 0 ? : null; +} + +// --- header ----------------------------------------------------------------------------------------------------- + +function Title({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const h = plan.head; + return ( + + + {model.docTitle} + + + ); +} + +/** The vendor at the top right: the logo (right-aligned at the slot's top), the name, GSTIN and PAN. */ +function Vendor({ ctx }: { ctx: Ctx }) { + const { model, plan, v, logo } = ctx; + const h = plan.head; + const r = v.roles; + const s = model.supplier; + return ( + + + {logo ? : null} + {h.showName ? ( + + {s.name} + + ) : null} + + {s.pan ? ( + + {`PAN: ${s.pan}`} + + ) : null} + {model.cancelled ? ( + + CANCELLED + + ) : null} + + + ); +} + +function HeaderRow({ ctx }: { ctx: Ctx }) { + const { plan, v } = ctx; + return ( + + + <Vendor ctx={ctx} /> + </Row> + ); +} + +// --- fields and payment ----------------------------------------------------------------------------------------- + +function Field({ ctx, index }: { ctx: Ctx; index: number }) { + const { plan, v } = ctx; + const f = v.fields; + const spec = plan.fields.specs[index]; + const labelW = plan.fields.labelW[index]; + const valueW = f.w - labelW - f.labelGap; + return ( + <View style={{ marginTop: index > 0 ? f.rowGap : 0 }}> + <Row widths={[{ fixed: labelW }, { fixed: valueW }]} gap={f.labelGap} width={f.w}> + <Txt role={v.roles.label} color={v.ink} width={labelW}> + {spec.label} + </Txt> + <View style={{ ...pinned(valueW), borderBottomWidth: v.hair, borderBottomColor: INDIGO } as never}> + <AvailableWidthProvider width={valueW - f.valuePad}> + <View style={{ marginLeft: f.valuePad }}> + <SafeTxt role={v.roles.value} color={v.ink}> + {spec.value} + </SafeTxt> + </View> + </AvailableWidthProvider> + </View> + </Row> + </View> + ); +} + +function Fields({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const r = v.roles; + const c = model.client; + const f = v.fields; + return ( + <View id={META_ID} style={pinned(f.w) as never}> + {plan.fields.specs.map((_, i) => ( + <Field key={i} ctx={ctx} index={i} /> + ))} + {plan.fields.subH > 0 ? ( + <View style={{ ...pinned(f.w), marginTop: f.subGap } as never}> + <AvailableWidthProvider width={f.w}> + {c.addressLines.map((line, i) => ( + <SafeTxt key={i} role={r.sub} color={v.ink}> + {line} + </SafeTxt> + ))} + <Slot.Gstin model={model} party="client" role={r.sub} color={v.ink} /> + <Slot.PlaceOfSupply model={model} role={r.sub} color={v.ink} /> + {c.poNumber ? <SafeTxt role={r.sub} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null} + </AvailableWidthProvider> + </View> + ) : null} + </View> + ); +} + +function Payment({ ctx }: { ctx: Ctx }) { + const { model, v } = ctx; + const r = v.roles; + const lines = paymentLines(model); + if (lines.length === 0) return <View />; + const w = v.pay.w; + return ( + <View id={PAY_ID} style={{ ...pinned(w), marginTop: v.pay.top } as never}> + <AvailableWidthProvider width={w}> + <Txt role={r.payHead} color={v.ink} width={w}> + Payment Method: + </Txt> + {lines.map((line, i) => ( + <SafeTxt key={i} role={r.payLine} color={v.ink}> + {line} + </SafeTxt> + ))} + </AvailableWidthProvider> + </View> + ); +} + +// --- table ------------------------------------------------------------------------------------------------------ + +const partsOf = (value: TableCellValue): CellPart[] => (Array.isArray(value) ? (value.length ? value : [""]) : [value]); + +/** One table line inside the 3 pt frame: the frame's sides are the line's own borders, the dividers are its cells'. */ +function Line({ + ctx, + id, + last, + flags, + minH, + children, +}: { + ctx: Ctx; + id: string; + last: boolean; + flags: ReturnType<typeof pdfFlags>; + minH?: number; + children: ReactNode; +}) { + const { plan, v } = ctx; + return ( + <View + id={id} + {...flags} + style={ + { + ...pinned(plan.W), + borderLeftWidth: v.frame, + borderRightWidth: v.frame, + borderLeftColor: v.ink, + borderRightColor: v.ink, + borderBottomWidth: last ? v.frame : v.hair, + borderBottomColor: last ? v.ink : INDIGO, + } as never + } + > + <Row widths={plan.columns} width={plan.innerW} align="stretch" style={minH ? { minHeight: minH } : undefined}> + {children} + </Row> + </View> + ); +} + +function colInner(ctx: Ctx, i: number): number { + const { plan, v } = ctx; + const w = plan.table.widths[i]; + const last = i === plan.columns.length - 1; + return Math.max(0, w - plan.padLeft[i] - (plan.padRight[i] - (last ? 0 : v.hair)) - (last ? 0 : v.hair)); +} + +function BodyCell({ ctx, i, children }: { ctx: Ctx; i: number; children?: ReactNode }) { + const { plan, v } = ctx; + const last = i === plan.columns.length - 1; + return ( + <Cell + padding={{ top: plan.padY, bottom: plan.padY, left: plan.padLeft[i], right: plan.padRight[i] - (last ? 0 : v.hair) }} + borderRight={last ? 0 : v.hair} + style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }} + > + {children} + </Cell> + ); +} + +function DataRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) { + const { plan, v } = ctx; + const r = v.roles; + const cells = plan.rows[index]; + return ( + <Line ctx={ctx} id={`${TABLE_ROW_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })}> + {cells.map((value, i) => ( + <BodyCell key={i} ctx={ctx} i={i}> + {partsOf(value).map((part, li) => { + const text = typeof part === "string" ? part : part.text; + const role = typeof part === "string" ? r.tableBody : (part.role as typeof r.tableBody); + return i === 0 && typeof part === "string" ? ( + <SafeTxt key={li} role={role} color={v.ink}> + {text} + </SafeTxt> + ) : ( + <Txt key={li} role={role} color={v.ink} width={colInner(ctx, i)} align={plan.align[i]}> + {text} + </Txt> + ); + })} + </BodyCell> + ))} + </Line> + ); +} + +function PadRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) { + const { plan, v } = ctx; + return ( + <Line ctx={ctx} id={`${PAD_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })} minH={plan.padRowH - v.hair}> + {plan.columns.map((_, i) => ( + <BodyCell key={i} ctx={ctx} i={i} /> + ))} + </Line> + ); +} + +/** The header group, repeated on every page: the yellow bar, the gap, and the body's 3 pt top rule. */ +function HeaderGroup({ ctx }: { ctx: Ctx }) { + const { plan, v } = ctx; + const t = v.table; + const r = v.roles; + const fr = v.frame; + return ( + <View {...pdfFlags({ fixed: true })} style={pinned(plan.W) as never}> + <View + id={TABLE_HEAD_ID} + style={ + { + ...pinned(plan.W), + backgroundColor: YELLOW, + borderWidth: fr, + borderColor: v.ink, + } as never + } + > + <Row widths={plan.columns} width={plan.innerW} align="stretch"> + {plan.headerCells.map((text, i) => { + const last = i === plan.columns.length - 1; + return ( + <Cell + key={i} + padding={{ top: t.barPadTop, bottom: t.barPadTop, left: 0, right: 0 }} + borderRight={last ? 0 : v.hair} + style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }} + > + <Txt role={r.tableHead} color={v.ink} width={Math.max(0, plan.table.widths[i] - (last ? 0 : v.hair))} align="center"> + {text} + </Txt> + </Cell> + ); + })} + </Row> + </View> + <Spacer h={t.bodyGap} /> + <View style={{ ...pinned(plan.W), height: fr, backgroundColor: v.ink } as never} /> + </View> + ); +} + +function Table({ ctx }: { ctx: Ctx }) { + const { plan } = ctx; + const n = plan.rows.length; + return ( + <AvailableWidthProvider width={plan.W}> + <View style={pinned(plan.W) as never}> + <HeaderGroup ctx={ctx} /> + {plan.rows.map((_, i) => ( + <DataRow key={i} ctx={ctx} index={i} lastRow={i === n - 1 && plan.padRows === 0} /> + ))} + {Array.from({ length: plan.padRows }, (_, i) => ( + <PadRow key={i} ctx={ctx} index={i} lastRow={i === plan.padRows - 1} /> + ))} + <Tail ctx={ctx} /> + </View> + </AvailableWidthProvider> + ); +} + +// --- totals ----------------------------------------------------------------------------------------------------- + +function TotalRow({ ctx, label, amount, total }: { ctx: Ctx; label: string; amount: string; total?: boolean }) { + const { plan, v } = ctx; + const t = plan.tail; + const r = v.roles; + const o = v.totals; + const pad = { paddingTop: o.padY, paddingBottom: o.padY }; + return ( + <View style={{ flexDirection: "row", ...pinned(t.boxW - 2 * v.frame), ...(total ? {} : { borderBottomWidth: v.hair, borderBottomColor: INDIGO }) } as never}> + <View + style={ + { + ...pinned(t.labelW), + ...pad, + paddingLeft: o.labelPad, + borderRightWidth: v.frame, + borderRightColor: v.ink, + ...(total ? { backgroundColor: YELLOW } : {}), + } as never + } + > + <Txt role={r.totalLabel} color={BLACK} width={t.labelW - v.frame - o.labelPad}> + {label} + </Txt> + </View> + <View style={{ ...pinned(t.valueW), ...pad, paddingRight: o.valuePad } as never}> + <Txt role={total ? r.totalStrong : r.totalValue} color={v.ink} width={t.valueW - o.valuePad} align="right"> + {amount} + </Txt> + </View> + </View> + ); +} + +function TotalsBox({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const t = plan.tail; + return ( + <View style={{ ...pinned(t.boxW), borderWidth: v.frame, borderColor: v.ink } as never}> + <AvailableWidthProvider width={t.boxW - 2 * v.frame}> + {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={taxLabel} renderRow={(row) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} /> + <TotalRow ctx={ctx} label="total :" amount={model.totals.totalText} total /> + </AvailableWidthProvider> + </View> + ); +} + +function Tail({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const t = plan.tail; + const r = v.roles; + const o = v.totals; + return ( + <View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}> + <Spacer h={o.gap} /> + <Row widths={[{ fill: true }, { fixed: t.boxW }]} width={plan.W}> + <View style={{ ...pinned(t.wordsW), marginLeft: v.inset, paddingTop: t.leftH ? o.wordsTop : 0 } as never}> + <AvailableWidthProvider width={t.wordsW}> + <Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} /> + {t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.words} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null} + </AvailableWidthProvider> + </View> + <TotalsBox ctx={ctx} /> + </Row> + </View> + ); +} + +// --- closing ---------------------------------------------------------------------------------------------------- + +/** Notes in flow mode (too long for the ruled lines): a label held with the first line, every line unbreakable. */ +function NotesFlow({ 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.wordsLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}> + Notes: + </Txt> + <SafeTxt role={r.note} color={v.ink}> + {lines[0]} + </SafeTxt> + </View> + {lines.slice(1).map((line, i) => ( + <SafeTxt key={i} role={r.note} color={v.ink} wrap={false}> + {line} + </SafeTxt> + ))} + </AvailableWidthProvider> + </View> + ); +} + +function SignOff({ ctx }: { ctx: Ctx }) { + const { plan, v } = ctx; + const r = v.roles; + const cl = v.closing; + const c = plan.closing; + const w = c.leftW - v.inset; + return ( + <View style={{ ...pinned(c.leftW), paddingLeft: v.inset, paddingTop: cl.leftTop } as never}> + <AvailableWidthProvider width={w}> + <Txt role={r.thanks} color={v.ink} width={w}> + THANK YOU + </Txt> + <View style={{ marginTop: c.contacts.length ? cl.thanksGap : 0 }}> + {c.contacts.map((x, i) => ( + <View key={i} style={{ marginTop: i > 0 ? cl.contactGap : 0 }}> + <Row widths={[{ fixed: cl.iconSize }, { fill: true }]} gap={cl.iconGap} width={w}> + <View style={{ marginTop: 0.6 }}> + <ContactIcon name={x.icon} size={cl.iconSize} color={v.ink} /> + </View> + {x.icon === "location" ? ( + <SafeTxt role={r.contact} color={BLACK}> + {x.text} + </SafeTxt> + ) : ( + // A phone number or e-mail is an identifier: one fitted line (shrunk, then split at "@ . / -"), never a wrapped one. + <IdText role={r.contact} color={BLACK}> + {x.text} + </IdText> + )} + </Row> + </View> + ))} + </View> + </AvailableWidthProvider> + </View> + ); +} + +/** The design's ruled lines: the notes written on them (a line per wrapped line), then the signature on the last one. */ +function Ruled({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const r = v.roles; + const cl = v.closing; + const c = plan.closing; + const opts = signatureOptions(v); + return ( + <View style={pinned(cl.ruledW) as never}> + <AvailableWidthProvider width={cl.ruledW}> + {Array.from({ length: c.ruledRows }, (_, i) => ( + <View + key={i} + style={ + { + ...pinned(cl.ruledW), + minHeight: cl.ruledH, + paddingTop: cl.ruledH - v.hair - (r.note.leading as number), + borderBottomWidth: v.hair, + borderBottomColor: INDIGO, + } as never + } + > + {!c.notesFlow && c.ruled[i] !== undefined ? ( + <FitText role={r.note} color={v.ink} width={cl.ruledW - 2}> + {c.ruled[i]} + </FitText> + ) : null} + </View> + ))} + <Slot.Signatory model={model} width={cl.ruledW} {...opts} /> + </AvailableWidthProvider> + </View> + ); +} + +function Closing({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const cl = v.closing; + const c = plan.closing; + return ( + <> + <BottomSpacer closingH={plan.closingH} /> + <View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}> + <AvailableWidthProvider width={plan.W}> + <Spacer h={plan.gaps.close} /> + <Row widths={[{ fixed: c.leftW }, { fixed: cl.ruledW }]} gap={cl.colGap} width={plan.W - v.rightInset}> + <SignOff ctx={ctx} /> + <Ruled ctx={ctx} /> + </Row> + {model.cancelled ? ( + <Txt role={v.roles.note} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset }}> + {CANCELLED_SENTENCE} + </Txt> + ) : null} + </AvailableWidthProvider> + </View> + </> + ); +} + +// --- the layout ------------------------------------------------------------------------------------------------- + +export function HighlighterBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: HighlighterVariant }) { + const plan = planHighlighter(model, frame, v, logo, includesName({ model, prefs })); + const ctx: Ctx = { model, plan, v, logo }; + const notesFlow: ReactNode = !plan.closing.notesFlow ? null : ( + <> + <TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} /> + <View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset }}> + <NotesFlow ctx={ctx} width={plan.W - v.inset - v.rightInset} /> + </View> + </> + ); + return ( + <> + <FirstPageHeader pad={frame.pad}> + <HeaderRow ctx={ctx} /> + <Spacer h={v.fields.top} /> + <Row widths={[{ fixed: plan.fields.leftW }, { fixed: v.pay.w + v.rightInset }]} width={plan.W}> + <View style={{ ...pinned(plan.fields.leftW), paddingLeft: v.inset } as never}> + <Fields ctx={ctx} /> + </View> + <Payment ctx={ctx} /> + </Row> + </FirstPageHeader> + + <Spacer h={plan.gaps.bar} /> + + <TableGuard {...{ minPresenceAhead: plan.guardH }} /> + <Table ctx={ctx} /> + {notesFlow} + <Closing ctx={ctx} /> + </> + ); +} diff --git a/src/pdf/templates/highlighter/highlighter.test.tsx b/src/pdf/templates/highlighter/highlighter.test.tsx new file mode 100644 index 0000000..f6bc782 --- /dev/null +++ b/src/pdf/templates/highlighter/highlighter.test.tsx @@ -0,0 +1,425 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { beforeAll, 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 { parsePdfText } from "../../testing/pdfText"; +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 { OPTIN_SLOTS } from "../slots.optin"; +import { SLOTS } from "../slots.generated"; +import { PAD_PREFIX, PAY_ID } from "./Layout"; +import { highlighter } from "./highlighter"; +import { planHighlighter } from "./plan"; +import { BLACK, HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INDIGO, INK, YELLOW } from "./tokens"; + +ensureSetup(); + +// fontkit caches glyph objects per font for the process, and pdfkit creates a composite's components (the period and comma inside +// Poppins' colon and semicolon) at embed time with no code points (src/pdf/quirks.test.tsx, quirk 13). A first render without any +// period or comma, such as the 3-row sample at 100.00 an hour, then makes a later wrapped client name overflow its box by 3.6 pt +// (textkit's glyph-to-string mapping slips). The tests run in a fixed order, so warm Poppins with the harness's default invoice, +// whose amounts have both. +beforeAll(async () => { + await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" })); +}); + +const V = HIGHLIGHTER_VARIANT; +const slot = OPTIN_SLOTS.highlighter!; +type R = Awaited<ReturnType<typeof renderFixture>>; +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: 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; h: 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, h: l.h }); + }); + return out; +}; +const contentRight = (r: R) => { + const f = frameForPrefs(r.prefs); + return f.page.width - f.pad.right; +}; +const optIn = (size: "A4" | "LETTER" = "A4") => prefsFor(size, { templateId: "highlighter", logo: { show: true } }); +const hits = (a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5; + +/** The Canva sample's shape: no due date, no address, the payment block's two lines, nothing the design has no slot for. */ +const NAMES = ["Brand consultation", "Logo design", "Website design"]; +const 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 })); +async function canva(size: "A4" | "LETTER" = "A4"): Promise<{ r: R; items: Array<{ str: string; x0: number; x1: number; bottom: number }> }> { + const fixture = buildFixture({ name: "canva", registered: false, logo: "none", items: PLAIN_ITEMS, invoice: { dueDate: "", notes: "", clientName: "Olivia Wilson", clientAddress: "", clientGstin: "" } }); + fixture.model.bank = { accountName: "Wealth", lines: [{ label: "Bank", value: "Wealth Banking" }, { label: "Account No.", value: "123 4567 8901" }] }; + fixture.model.client.posLabel = null; + const prefs = prefsFor(size, { templateId: "highlighter" }); + const result = await renderInvoicePdf({ model: fixture.model, prefs }); + const pdf = await parsePdfText(result.bytes); + return { r: { fixture, prefs, result, pdf } as R, items: pdf.pages[0].items }; +} +const find = (items: Array<{ str: string; x0: number; x1: number; bottom: number }>, text: string) => items.find((i) => i.str.trim().startsWith(text))!; + +/** RGB of pixels of page `page` at 4x. */ +async function colourAt(bytes: Uint8Array, points: Array<[number, number]>, page = 1): Promise<number[][]> { + const lib = await loadCanvas(); + if (!lib) return []; + const png = await pagePng(lib, bytes, page, 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 and the logo opt-in", () => { + expect(getTemplate("highlighter")).toBe(highlighter); + expect(highlighter.family).toBe("standalone"); + expect(catalogEntry("highlighter").name).toBe("Highlighter"); + expect(catalogEntry("highlighter").logoOptIn).toBe(true); + expect(existsSync(path.join(process.cwd(), "public", highlighter.thumbnail)), highlighter.thumbnail).toBe(true); + expect(Object.keys(TEMPLATES).at(-1)).toBe("highlighter"); + expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); + }); + + it("is Poppins 400/700 and Montserrat 400 only, in the design's four colours", () => { + for (const [name, token] of Object.entries(HIGHLIGHTER_ROLES)) { + expect(["Poppins", "Montserrat"], name).toContain(token.face); + expect([400, 700], name).toContain(token.weight); + if (token.face === "Montserrat") expect(token.weight, name).toBe(400); + } + const dir = path.join(process.cwd(), "src/pdf/templates/highlighter"); + const palette = [INK, INDIGO, YELLOW, BLACK].map((c) => c.toLowerCase()); + 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) => !palette.includes(c.toLowerCase())), f).toEqual([]); + } + expect(highlighter.decor.ink).toBe(INK); + }); +}); + +describe("page geometry", () => { + it("template margins put the 3 pt frame's outer edge on the content box (479.4 pt wide on A4)", () => { + expect(marginsFor(highlighter, "template")).toEqual({ top: 50, right: 57.8, bottom: 36, left: 58.1 }); + expect(marginsFor(highlighter, "narrow").left).toBe(36); + expect(frameForPrefs(prefsFor("A4", { templateId: "highlighter" })).contentWidth).toBeCloseTo(479.4, 0); + }); + + it("Letter keeps the physical margins and takes the missing height from the gaps and the empty rows", () => { + const a4 = frameForPrefs(prefsFor("A4", { templateId: "highlighter" })); + const letter = frameForPrefs(prefsFor("LETTER", { templateId: "highlighter" })); + expect(letter.pad).toEqual(a4.pad); + expect(letter.heightDeficit).toBeCloseTo(49.89, 2); + }); +}); + +describe("header and fields", () => { + it("matches the design: title baseline 98 at x 60.3, the fields at the 34 pt pitch from 132.2, the payment block at 162.9 / 185.1", async () => { + const { items } = await canva(); + const title = find(items, "Invoice"); + expect(title.bottom).toBeCloseTo(98.0, 0); + expect(title.x0).toBeCloseTo(60.3, 0); + expect(find(items, "Date:").bottom).toBeCloseTo(132.2, 0); + expect(find(items, "Invoice number:").bottom).toBeCloseTo(166.0, 0); + expect(find(items, "Billed to:").bottom).toBeCloseTo(200.4, 0); + expect(find(items, "Billed to:").x0).toBeCloseTo(60.3, 0); + const pay = find(items, "Payment Method:"); + expect(pay.bottom).toBeCloseTo(162.9, 0); + expect(pay.x0).toBeCloseTo(352.5, 0); + expect(find(items, "Account Name").bottom).toBeCloseTo(185.1, 0); + expect(find(items, "Account Name").bottom + 16.5).toBeCloseTo(find(items, "Bank Name").bottom, 0); + }); + + it("each field's value sits on a 0.75 pt indigo underline 5 pt after its label, ending at x 266 (the first three underlines at y 137.4 / 170.4 / 204.8)", async () => { + const { r } = await canva(); + const meta = byId(r, META_ID); + const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - 19.25) < 0.3 && Math.abs(n.box.x + n.box.w - 266) < 0.6 && n.box.y >= meta.box.y - 0.1); + expect(rules.length).toBeGreaterThanOrEqual(3); + const ys = [...new Set(rules.map((n) => Math.round((n.box.y + n.box.h) * 10) / 10))].sort((a, b) => a - b); + // The design's pitch is 33.8 / 34.4 pt; ours is a steady 34.1. + for (const [got, want] of [[ys[0], 137.8], [ys[1], 170.8], [ys[2], 205.2]] as const) expect(Math.abs(got - want), `underline ${got} vs ${want}`).toBeLessThan(1.3); + }); + + it("a long invoice number, client name or reference wraps inside its underline and the payment block stays beside it", async () => { + const r = await renderFixture({ name: "t", items: 3, logo: "none", invoice: { number: "AB/2026-27/0012345/REV-3/LONG-REFERENCE-NUMBER", clientName: "The Board of Trustees of the Mahatma Gandhi Memorial Institute of Education" } }, prefsFor("A4", { templateId: "highlighter" })); + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + const meta = byId(r, META_ID); + const pay = byId(r, PAY_ID); + expect(hits(meta.box, pay.box)).toBe(false); + expect(meta.box.x + meta.box.w).toBeLessThanOrEqual(pay.box.x + 0.5); + }); + + it("TAX INVOICE never reaches the vendor stack, on either size", async () => { + for (const size of ["A4", "LETTER"] as const) { + const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorName: "Arunachalam Padmanabhan Ramasubramanian Voice Studios Pvt" } }, prefsFor(size, { templateId: "highlighter" })); + const title = textLines(byId(r, TITLE_ID)); + const supplier = textLines(byId(r, SUPPLIER_ID)); + expect(Math.max(...title.map((l) => l.right)), size).toBeLessThan(Math.min(...supplier.map((l) => l.x)) - 4); + expect(r.result.issues.filter((i) => i.severity === "error"), size).toEqual([]); + } + }); + + it("a cancelled invoice is marked in the vendor stack and closes with its sentence", async () => { + const r = await renderFixture(FIXTURES.find((f) => f.name === "cancelled")!, prefsFor("A4", { templateId: "highlighter" })); + expect(textLines(byId(r, SUPPLIER_ID)).some((l) => l.text === "CANCELLED")).toBe(true); + expect(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(" ")).toContain("This invoice has been cancelled."); + }); +}); + +describe("logo", () => { + it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the top right", () => { + expect(SLOTS.highlighter).toBeNull(); + expect(highlighter.logo.slot).toBe(slot); + expect(highlighter.logo.optIn).toBe(true); + expect(highlighter.logo.defaultOn).toBe(false); + expect(slot.lockup).toMatchObject({ x: 386, y: 50, w: 150, h: 50 }); + expect(highlighter.logo.box).toEqual({ w: 150, h: 50 }); + }); + + it("without the opt-in no logo is drawn, even when the invoice has one", async () => { + const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "highlighter" })); + expect(imageBoxes(r)).toHaveLength(0); + }); + + it("every opted-in logo fits the 150 x 50 slot, ends on the right edge at the slot's top, and the fields flow below it", 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 }, optIn(size)); + const imgs = imageBoxes(r); + expect(imgs, `${size}/${logo}`).toHaveLength(1); + const [img] = imgs; + expect(img.box.w).toBeLessThanOrEqual(150.01); + expect(img.box.h).toBeLessThanOrEqual(50.01); + expect(img.box.x + img.box.w, `${size}/${logo} right edge`).toBeCloseTo(contentRight(r) - V.rightInset, 0); + expect(img.box.y, `${size}/${logo} top`).toBeCloseTo(frameForPrefs(r.prefs).pad.top, 0); + expect(byId(r, SUPPLIER_ID).box.y + byId(r, SUPPLIER_ID).box.h, `${size}/${logo}`).toBeLessThanOrEqual(byId(r, META_ID).box.y + 0.5); + expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${logo}`).toEqual([]); + } + } + }); + + it("a tall logo is held to the slot's 50 pt 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: optIn() }); + 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(50.01); + }); + + it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => { + const stack = (r: R) => textLines(byId(r, SUPPLIER_ID)).map((l) => l.text); + expect(stack(await renderFixture({ name: "o", items: 2, logo: "real" }, optIn()))).not.toContain("Arun P"); + expect(stack(await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()))).toContain("Arun P"); + }); +}); + +describe("table", () => { + it("the bar is #FFE535 inside a 3 pt ink frame, 38.4 pt tall, with the headings centred over the design's columns (Qty x 277.4, Price x 360.8)", async () => { + const { r, items } = await canva(); + const bar = byId(r, TABLE_HEAD_ID); + expect(bar.box.h).toBeCloseTo(V.table.barH, 0); + expect(bar.box.x).toBeCloseTo(58.1, 0); + expect(bar.box.x + bar.box.w).toBeCloseTo(537.5, 0); + const centre = (t: string) => { + const i = find(items, t); + return (i.x0 + i.x1) / 2; + }; + expect(centre("Qty")).toBeCloseTo(277.4, 0); + expect(centre("Price")).toBeCloseTo(360.8, 0); + const [fill, frame, divider] = await colourAt(r.result.bytes, [[200, bar.box.y + 3 + 3], [200, bar.box.y + 1.5], [245.5, bar.box.y + 5]]); + expect(near(fill, YELLOW), `bar ${fill}`).toBe(true); + expect(near(frame, INK, 10), `frame ${frame}`).toBe(true); + expect(near(divider, INDIGO, 40), `divider ${divider}`).toBe(true); + }); + + it("the body follows 4.5 pt under the bar with its own 3 pt frame; dividers are hairlines, the sides and the last row's bottom are the frame", async () => { + const { r } = await canva(); + const bar = byId(r, TABLE_HEAD_ID).box; + const row0 = byId(r, `${TABLE_ROW_PREFIX}0`).box; + expect(row0.y - (bar.y + bar.h)).toBeCloseTo(V.table.bodyGap + V.frame, 0); + expect(row0.x).toBeCloseTo(58.1, 0); + expect(row0.w).toBeCloseTo(479.4, 0); + const pads = onPage(r, 0).filter((n) => n.id?.startsWith(PAD_PREFIX)); + expect(pads.length).toBeGreaterThan(0); + const last = pads[pads.length - 1].box; + const [side, hair, bottom] = await colourAt(r.result.bytes, [[59.6, row0.y + 10], [150, row0.y + row0.h - 0.4], [150, last.y + last.h - 1.5]]); + expect(near(side, INK, 10), `side ${side}`).toBe(true); + expect(near(hair, INDIGO, 60), `hairline ${hair}`).toBe(true); + expect(near(bottom, INK, 10), `bottom frame ${bottom}`).toBe(true); + }); + + it("the form look pads to 8 empty rows on A4 and 7 on Letter while the page holds them, and to none for a long invoice", async () => { + const a4 = await canva("A4"); + const rowsA4 = onPage(a4.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length; + expect(rowsA4).toBe(8); + const letter = await canva("LETTER"); + expect(onPage(letter.r, 0).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX) || n.id?.startsWith(PAD_PREFIX)).length).toBe(7); + const many = await renderFixture({ name: "m", items: 12 }, prefsFor("A4", { templateId: "highlighter" })); + expect(nodes(many.result.core.layout).filter((n) => n.id?.startsWith(PAD_PREFIX))).toHaveLength(0); + // A GST invoice with its longer stack drops pad rows rather than spill the sign-off onto a second page when one page can hold it. + const gst = await renderFixture({ name: "g", items: 2, logo: "none", vendor: { vendorEmail: "", vendorPhone: "" }, invoice: { clientAddress: "" } }, prefsFor("A4", { templateId: "highlighter" })); + expect(gst.result.pages).toBe(1); + }); + + it("description left (9 pt in), qty centred, price and total right-aligned 9 pt inside their cells", async () => { + const { r } = await canva(); + const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false); + const row = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)); + const left = frameForPrefs(r.prefs).pad.left + V.frame; + expect(row[0].x).toBeCloseTo(left + V.table.descPad, 0); + const amount = row[row.length - 1]; + expect(amount.right).toBeCloseTo(frameForPrefs(r.prefs).pad.left + plan.W - V.frame - V.table.numPad, 0); + }); + + it("repeats the bar on every continuation page, never leaves it alone, and closes the frame after the last row", async () => { + const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "highlighter" })); + expect(r.result.pages).toBeGreaterThan(1); + for (let p = 0; p < r.result.pages; p++) { + const rows = onPage(r, p).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX)).length; + const heads = onPage(r, p).filter((n) => n.id === TABLE_HEAD_ID).length; + if (rows > 0) expect(heads, `page ${p + 1}`).toBe(1); + if (heads > 0) expect(rows, `page ${p + 1}`).toBeGreaterThan(0); + } + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + }); +}); + +describe("totals and closing", () => { + it("the totals box lines up with the table's price and total columns; only the total row's label cell is yellow", async () => { + const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "highlighter" })); + const last = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID)); + const totals = byId(r, TOTALS_ID, last); + const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false); + const frame = frameForPrefs(r.prefs); + const box = onPage(r, last).filter((n) => n.type === "view" && n.box.y > totals.box.y && Math.abs(n.box.w - plan.tail.boxW) < 0.5).sort((a, b) => a.box.y - b.box.y)[0]; + expect(box.box.x + box.box.w).toBeCloseTo(frame.pad.left + plan.W, 0); + const lines = textLines(totals); + expect(lines.map((l) => l.text)).toEqual(expect.arrayContaining(["Subtotal :", "Taxable value :", "CGST @ 9% :", "SGST @ 9% :", "total :"])); + const totalLabel = lines.find((l) => l.text === "total :")!; + const subLabel = lines.find((l) => l.text === "Subtotal :")!; + const [yellow, white] = await colourAt(r.result.bytes, [[totalLabel.x - 3, totalLabel.y + 8], [subLabel.x - 3, subLabel.y + 8]], last + 1); + expect(near(yellow, YELLOW), `total label ${yellow}`).toBe(true); + expect(near(white, "#FFFFFF"), `subtotal label ${white}`).toBe(true); + expect(lines.find((l) => l.text === r.fixture.model.totals.totalText)!.right).toBeCloseTo(box.box.x + box.box.w - V.frame - V.totals.valuePad, 0); + }); + + it("the amount in words stands at the left of the box, top-aligned with its first row, and never meets it, even with a long amount", async () => { + const r = await renderFixture({ name: "t", items: [{ id: 1, description: "Retainer", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 98765432.1, sortOrder: 0, hsnSac: "998397" }], logo: "none", invoice: { discount: 5000 } }, prefsFor("A4", { templateId: "highlighter" })); + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + const at = r.result.core.layout.findIndex((p) => nodes([p]).some((n) => n.id === TOTALS_ID)); + const totals = byId(r, TOTALS_ID, at); + const words = onPage(r, at).find((n) => n.id === SLOT_ID.amountInWords)!; + const box = onPage(r, at).filter((n) => n.type === "view" && n.box.y >= totals.box.y && n.box.h > 100).sort((a, b) => b.box.x - a.box.x)[0]; + expect(words.box.x + words.box.w).toBeLessThanOrEqual(box.box.x + 0.5); + }); + + it("the sign-off is THANK YOU and the contact rows with icons at the left, the ruled lines at the right (212 pt, notes on them, the signature on the last)", async () => { + const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true, invoice: { notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, prefsFor("A4", { templateId: "highlighter" })); + const last = r.result.pages - 1; + const closing = byId(r, CLOSING_ID, last); + const lines = textLines(closing).map((l) => l.text); + expect(lines).toEqual(expect.arrayContaining(["THANK YOU", "9738284459", "arunakira113@gmail.com", "Payment due within 30 days."])); + expect(onPage(r, last).filter((n) => n.type === "svg" && n.box.y >= closing.box.y)).toHaveLength(3); + const sign = byId(r, SLOT_ID.signatory, last); + expect(sign.box.w).toBeCloseTo(V.closing.ruledW, 0); + expect(sign.box.x + sign.box.w).toBeCloseTo(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right - V.rightInset, 0); + const note = textLines(closing).find((l) => l.text === "Payment due within 30 days.")!; + expect(closing.box.x + note.x - closing.box.x).toBeGreaterThan(300); + }); + + it("long terms flow as a block of their own and keep the ruled lines bounded", async () => { + const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "highlighter" })); + const plan = planHighlighter(r.fixture.model, frameForPrefs(r.prefs), V, null, false); + expect(plan.closing.notesFlow).toBe(true); + 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("a long address wraps in the contact row and pushes nothing into the ruled lines", async () => { + const r = await renderFixture(FIXTURES.find((f) => f.name === "long-address")!, prefsFor("A4", { templateId: "highlighter" })); + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + const closing = byId(r, CLOSING_ID, r.result.pages - 1); + const left = textLines(closing).filter((l) => l.x < 300); + const right = textLines(closing).filter((l) => l.x >= 300); + for (const a of left) for (const b of right) expect(hits({ x: a.x, y: a.y, w: a.right - a.x, h: a.h }, { x: b.x, y: b.y, w: b.right - b.x, h: b.h }), `${a.text} / ${b.text}`).toBe(false); + }); +}); + +describe("the GST slots", () => { + it("every GST slot has a home on a tax invoice, and the supplier's identifiers are on page 1 whatever the length", async () => { + const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "highlighter" })); + 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(onPage(r, 0).some((n) => n.id === `${SLOT_ID.gstin}-supplier`)).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:", "CGST@9%:", "SGST@9%:", "HSN/SAC:998397", "PLACEOFSUPPLY", "PAN:CPOPP7388K"]) 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: "highlighter" })); + 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: "highlighter" })); + expect(r.result.issues.filter((i) => i.severity === "error").map((i) => `${i.code}: ${i.message}`), `${size}/${name}`).toEqual([]); + } + } + }); + + it("the header's three columns never overlap, whatever the vendor and client", async () => { + for (const size of ["A4", "LETTER"] as const) { + const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor(size, { templateId: "highlighter" })); + const boxes = [byId(r, TITLE_ID), byId(r, SUPPLIER_ID), byId(r, META_ID), byId(r, PAY_ID)]; + for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) expect(hits(boxes[i].box, boxes[j].box), `${size}: ${boxes[i].id} / ${boxes[j].id}`).toBe(false); + } + }); +}); + +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: "highlighter" }); + const frame = frameForPrefs(prefs); + // Logo-less (the opt-in is off), so the plan made here is the one the layout made. + const r = await renderFixture({ ...spec, logo: "none" } as FixtureSpec, prefs); + const heights = (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 = planHighlighter(r.fixture.model, frame, V, null, false); + expect(plan.tailH - heights(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01); + expect(plan.tailH - heights(TOTALS_ID)).toBeLessThanOrEqual(24); + expect(plan.closingH - heights(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01); + expect(plan.closingH - heights(CLOSING_ID)).toBeLessThanOrEqual(24); + }); + } + } +}); + +describe("design docs", () => { + it("records its implementation notes", () => { + const text = readFileSync(path.join(process.cwd(), "docs/templates/02-highlighter.md"), "utf8"); + expect(text).toContain("## Implementation notes"); + }); +}); diff --git a/src/pdf/templates/highlighter/highlighter.tsx b/src/pdf/templates/highlighter/highlighter.tsx new file mode 100644 index 0000000..acc534f --- /dev/null +++ b/src/pdf/templates/highlighter/highlighter.tsx @@ -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 { HighlighterBody } from "./Layout"; +import { HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INK } from "./tokens"; + +export { HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT } from "./tokens"; + +const slot = slotFor("highlighter"); +/** The opt-in logo area (docs/templates/02-highlighter.md: the empty top right, 150 x 50, right-aligned at the vendor name). */ +const area = slot?.lockup ?? { x: 386, y: 50, w: 150, h: 50 }; + +const HighlighterLayout: ComponentType<TemplateProps> = (props) => <HighlighterBody {...props} variant={HIGHLIGHTER_VARIANT} />; +HighlighterLayout.displayName = "HighlighterLayout"; + +export const highlighter: TemplateDefinition = { + ...catalogEntry("highlighter"), + version: 1, + roles: HIGHLIGHTER_ROLES, + margins: HIGHLIGHTER_VARIANT.margins, + marginFloor: HIGHLIGHTER_VARIANT.marginFloor, + decor: { ink: INK }, + chrome: { + color: INK, + rule: null, + role: HIGHLIGHTER_ROLES.footer, + leftWidth: 150, + }, + logo: { + box: { w: area.w, h: area.h }, + stackBelowTextWidth: 0, + defaultOn: false, + optIn: catalogEntry("highlighter").logoOptIn === true, + slotWidth: () => area.w, + slot, + available: () => ({ w: area.w, h: area.h }), + }, + Layout: HighlighterLayout, +}; diff --git a/src/pdf/templates/highlighter/plan.ts b/src/pdf/templates/highlighter/plan.ts new file mode 100644 index 0000000..5ed7a1b --- /dev/null +++ b/src/pdf/templates/highlighter/plan.ts @@ -0,0 +1,305 @@ +import type { ColSpec } from "../../engine/columns"; +import type { PageFrame } from "../../engine/geometry"; +import { drawnText, getMeasurer, measureToken } from "../../engine/measure"; +import { textHeight, textLines } 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 { resolveFace, type TypeToken } from "../../fonts/roles"; +import type { PlacedLogo } from "../contract"; +import { CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan"; +import { leading, INDIGO, type HighlighterVariant } 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; +export const LABEL_GAP = 3; + +/** 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))); +} + +/** Tax rows read "CGST @ 9% :" like the design's "total :". */ +export const taxLabel = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate} :` : `${label} :`); + +export function signatureOptions(v: HighlighterVariant): SignatoryOptions { + const r = v.roles; + return { roles: { name: r.signName, designation: r.signDesignation, fine: r.fine }, align: "right", rule: { width: v.hair, color: INDIGO }, color: v.ink, imageHeight: v.closing.signatureImageH }; +} + +/** The table's description (+ HSN/SAC sub-line), qty, rate and amount cells. */ +export function tableRows(model: RenderModel, v: HighlighterVariant, 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 [desc, item.qtyText, rateParts, item.amountText]; + }); +} + +/** The payment block: "Bank Name : ...", one bold line per bank field (the design's "label : value" pairs). */ +export function paymentLines(model: RenderModel): string[] { + if (!model.bank) return []; + const names: Record<string, string> = { Bank: "Bank Name", "Account No.": "Account No" }; + return [`Account Name : ${model.bank.accountName}`, ...model.bank.lines.map((l) => `${names[l.label] ?? l.label} : ${l.value}`)]; +} + +export interface FieldSpec { + label: string; + value: string; +} + +/** The underlined fields in the design's order (Date, Invoice number), plus the Due date, then Billed to. */ +export function fieldSpecs(model: RenderModel): FieldSpec[] { + const out: FieldSpec[] = [ + { label: "Date:", value: model.meta.date }, + { label: "Invoice number:", value: model.meta.number }, + ]; + if (model.meta.dueDate) out.push({ label: "Due date:", value: model.meta.dueDate }); + out.push({ label: "Billed to:", value: model.client.name }); + return out; +} + +/** The note lines wrapped to the ruled lines' width: one string per drawn line. */ +export function ruledNoteLines(model: RenderModel, role: TypeToken, width: number): string[] { + const m = getMeasurer(); + return model.notesLines.flatMap((n) => m.wrapLines(drawnText(role, n), width, { face: resolveFace(role.face), weight: role.weight, italic: false, size: role.size, letterSpacing: (role.tracking ?? 0) * role.size })); +} + +/** The contact rows of the sign-off, with the address as one wrapping line. */ +export function contactRows(model: RenderModel): Array<{ icon: "phone" | "email" | "location"; text: string }> { + const s = model.supplier; + const out: Array<{ icon: "phone" | "email" | "location"; text: string }> = []; + if (s.phone) out.push({ icon: "phone", text: s.phone }); + if (s.email) out.push({ icon: "email", text: s.email }); + const address = s.addressLines.join(", "); + if (address.trim()) out.push({ icon: "location", text: address }); + return out; +} + +export interface HighlighterPlan { + v: HighlighterVariant; + W: number; + head: { titleSize: number; titleW: number; stackW: number; showName: boolean; nameH: number; idsH: number; stackH: number; h: number }; + fields: { specs: FieldSpec[]; labelW: number[]; valueW: number; heights: number[]; fieldsH: number; subLines: number; subH: number; leftW: number; payLines: string[]; payH: number; h: number }; + columns: ColSpec[]; + headerCells: string[]; + rows: TableCellValue[][]; + align: Array<"left" | "center" | "right">; + padLeft: number[]; + padRight: number[]; + padY: number; + cw: { qty: number; rate: number; amount: number }; + innerW: number; + table: ItemsTablePlan; + padRows: number; + padRowH: number; + groupH: number; + guardH: number; + tail: { boxW: number; labelW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rows: number; boxH: number; wordsW: number; wordsH: number; reverseH: number; leftH: number; body: number }; + closing: { notes: string[]; ruledRows: number; ruled: string[]; notesFlow: boolean; notesFlowH: number; leftW: number; contacts: Array<{ icon: "phone" | "email" | "location"; text: string; h: number }>; leftH: number; rightH: number; signH: number; h: number }; + gaps: { bar: number; close: number }; + tailH: number; + closingH: number; +} + +/** + * Everything the layout must know before rendering: the title fitted to its zone, the underlined fields and the payment + * block, the table (its columns widen only where an amount needs it; the form look pads to 8 empty rows on A4 and 7 on Letter + * while they fit), the totals stack and the heights of the tail and the closing block, each a hair high rather than low. + */ +export function planHighlighter(model: RenderModel, frame: PageFrame, v: HighlighterVariant, logo: PlacedLogo | null, includesName: boolean): HighlighterPlan { + const m = getMeasurer(); + const r = v.roles; + const W = frame.contentWidth; + const s = model.supplier; + const c = model.client; + const hd = v.head; + const fr = v.frame; + + // --- header: the title at the left, the vendor's logo, name and identifiers at the right -------------------------------------- + const stackW = Math.min(hd.stackW, Math.max(80, W - hd.minTitleW - hd.gap - v.inset - v.rightInset)); + const titleW = Math.max(hd.minTitleW, W - stackW - v.rightInset - hd.gap - v.inset); + const tFit = fitSize(r.title, model.docTitle, titleW); + const showName = !(logo && includesName) && s.name.trim() !== ""; + const nameH = showName ? textHeight(r.vendorName, s.name, stackW) : 0; + const gstinLines = slotHeights.gstinLines(model, "supplier"); + const idsH = (gstinLines ? idLines(r.vendorId, GSTIN_SAMPLE, stackW) * leading(r.vendorId) : 0) + (s.pan ? idLines(r.vendorId, `PAN: ${s.pan}`, stackW) * leading(r.vendorId) : 0); + const cancelH = model.cancelled ? leading(r.vendorName) : 0; + const stackH = (logo ? logo.h : 0) + nameH + idsH + cancelH; + const headH = Math.max(hd.zoneH, stackH); + + // --- fields (underlined) and the payment block --------------------------------------------------------------------------------- + const specs = fieldSpecs(model); + const f = v.fields; + const fieldLabelW = specs.map((sp) => Math.ceil(measureToken(m, r.label, sp.label)) + 1); + const valueWs = fieldLabelW.map((lw) => f.w - lw - f.labelGap); + const heights = specs.map((sp, i) => textLines(r.value, sp.value, valueWs[i] - f.valuePad) * leading(r.value) + v.hair); + const fieldsRows = heights.reduce((a, h) => a + h, 0) + f.rowGap * (specs.length - 1); + const leftW = W - v.pay.w - v.rightInset; + const subW = f.w; + let subH = 0; + for (const l of c.addressLines) subH += textHeight(r.sub, l, subW); + subH += slotHeights.gstinLines(model, "client") * leading(r.sub) + slotHeights.placeOfSupplyLines(model, r.sub, subW) * leading(r.sub); + if (c.poNumber) subH += textHeight(r.sub, `PO: ${c.poNumber}`, subW); + const fieldsH = fieldsRows + (subH > 0 ? f.subGap + subH : 0); + const payLines = paymentLines(model); + const payW = Math.min(v.pay.w, W); + const payH = payLines.length ? v.pay.top + leading(r.payHead) + payLines.reduce((h, x) => h + textHeight(r.payLine, x, payW), 0) : 0; + const blocksH = Math.max(fieldsH, payH); + + // --- table ----------------------------------------------------------------------------------------------------------------- + const t = v.table; + const innerW = W - 2 * fr; + const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? 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 + v.hair + 6; + const shortRate = model.items.map((i) => { + const cell = rateCell(i.rateText, 10000, r.tableBody); + return typeof cell === "string" ? cell : "—"; + }); + const cw = { + qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)), + rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))), + amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair), + }; + const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }]; + const headerCells = ["Description", "Qty", "Price", "Total"]; + const align: HighlighterPlan["align"] = ["left", "center", "right", "right"]; + // Each cell but the last ends in a 0.75 pt rule that is part of its width. + const padLeft = [t.descPad, 0, 0, 0]; + const padRight = [8 + v.hair, v.hair, t.numPad + v.hair, t.numPad]; + const rows = tableRows(model, v, cw.rate - t.numPad - v.hair - 2); + const n = rows.length; + const baseLines = leading(r.tableBody); + const padYn = (t.rowH - v.hair - baseLines) / 2; + const planTable = (padY: number, tailH: number) => + planItemsTable({ columns, gap: 0, width: innerW, header: headerCells, rows, measurer: m, roles: { head: r.tableHead, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY: 0, border: v.hair, headBorder: 0, tailH, contentH: frame.contentHeight }); + const groupH = t.barH + t.bodyGap + fr; + // The last row's bottom edge is the frame (3 pt) instead of a hairline. + const lastExtra = fr - v.hair; + + // --- totals: a stack of two-cell rows, the Total row's label cell yellow; 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, taxLabel); + const labels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label), "total :"]; + const amounts = [...stack.map((x) => x.amount), ...taxTexts.map((x) => x.amount)]; + const labelNeed = Math.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 2 * o.labelPad + fr; + const valueNeed = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)), measureToken(m, r.totalStrong, tt.totalText))) + o.valuePad + o.labelPad + 2; + // The box lines up with the table's columns: its label cell is the price column wide, its value cell the rest (the design's + // box starts 1.5 pt outside the price column's left rule). + const boxW0 = fr + cw.amount + cw.rate + o.boxOutside; + const labelW = Math.max(cw.rate, labelNeed); + const valueW = Math.max(boxW0 - 2 * fr - cw.rate, valueNeed); + const boxW = labelW + valueW + 2 * fr; + const totalRows = stack.length + slotRows + 1; + const boxH = 2 * fr + totalRows * o.rowH + (totalRows - 1) * v.hair; + const tailLeft = Math.max(0, W - boxW); + const wordsW = Math.max(60, tailLeft - o.wordsGap - v.inset); + const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel }); + const reverseH = slotHeights.reverseCharge(model, wordsW, r.words); + const leftH = wordsH || reverseH ? o.wordsTop + wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0; + const body = Math.max(boxH, leftH); + const tailH = o.gap + body; + + // --- closing: THANK YOU and the contact rows at the left, the ruled lines (notes, then the signature) at the right ----------- + const cl = v.closing; + const closeLeftW = W - v.rightInset - cl.ruledW - cl.colGap; + const textW = Math.max(60, closeLeftW - v.inset - cl.iconSize - cl.iconGap); + const contacts = contactRows(model).map((x) => ({ ...x, h: Math.max(cl.iconSize, (x.icon === "location" ? textLines(r.contact, x.text, textW) : idLines(r.contact, x.text, textW)) * leading(r.contact)) })); + const leftContentH = leading(r.thanks) + (contacts.length ? cl.thanksGap + contacts.reduce((h, x) => h + x.h, 0) + cl.contactGap * (contacts.length - 1) : 0); + const leftH2 = cl.leftTop + leftContentH; + const ruled = ruledNoteLines(model, r.note, cl.ruledW - 2); + const notesFlow = ruled.length > cl.ruledMax; + const ruledRows = notesFlow ? cl.ruledMin : Math.max(cl.ruledMin, ruled.length); + const signH = signatoryHeight(model, cl.ruledW, signatureOptions(v)); + const rightH = ruledRows * cl.ruledH + signH; + const notesFlowH = notesFlow ? leading(r.wordsLabel) + LABEL_GAP + model.notesLines.reduce((h, l) => h + textHeight(r.note, l, W - v.inset - v.rightInset), 0) : 0; + const cancelledH = model.cancelled ? 10 + textHeight(r.note, CANCELLED_SENTENCE, W) : 0; + const bandH = Math.max(leftH2, rightH); + const closingFor = (g: { close: number }) => g.close + bandH + cancelledH; + + // --- pad rows, gaps: the form look pads to N rows while the page holds them; the gaps and row padding give back Letter's height ----- + const gapBarNominal = Math.max(v.gaps.bar.nominal, f.designH - blocksH); + const ranges0 = { bar: { nominal: gapBarNominal, min: v.gaps.bar.min }, close: v.gaps.close }; + const table0 = planTable(padYn, 0); + const dataH = table0.rowHeights.reduce((a, x) => a + x, 0); + const padRowH = t.rowH; + const notesBlock = notesFlow ? NOTES_FLOW_GAP + notesFlowH : 0; + const headTop = headH + f.top; + const nominalNoClosing = (pad: number) => headTop + ranges0.bar.nominal + blocksH + groupH + dataH + pad * padRowH + lastExtra + tailH + notesBlock; + let padRows = Math.max(0, t.padTo[frame.page.id] - n); + const rowPadRange = (pad: number) => ({ nominal: 2 * padYn * (n + pad), min: 2 * t.padYMin * (n + pad) }); + // What the gaps can give back while the table and totals stay on page 1 (the sign-off's own gap does not help them). + const slackBefore = (pad: number) => ranges0.bar.nominal - ranges0.bar.min + rowPadRange(pad).nominal - rowPadRange(pad).min; + const closeNominal = ranges0.close.nominal; + const closeSlack = ranges0.close.nominal - ranges0.close.min; + const closingAt = closingFor({ close: closeNominal }); + // The pad rows are decoration, so they give way first: the most of them that still lets the whole invoice (sign-off included) + // sit on one page; failing that, the most that lets the table and totals sit on page 1 with only the sign-off moving on. + const wanted = padRows; + const fitsAll = (pad: number) => nominalNoClosing(pad) + closingAt - frame.contentHeight <= slackBefore(pad) + closeSlack; + const fitsBefore = (pad: number) => nominalNoClosing(pad) - frame.contentHeight <= slackBefore(pad); + while (padRows > 0 && !fitsAll(padRows)) padRows--; + if (!fitsAll(padRows)) { + padRows = wanted; + while (padRows > 0 && !fitsBefore(padRows)) padRows--; + } + const ranges = { ...ranges0, rowPad: rowPadRange(padRows) }; + const closingNominal = closingFor({ close: ranges.close.nominal }); + const nominalTotal = nominalNoClosing(padRows) + closingNominal; + const slackAll = slackBefore(padRows) + ranges.close.nominal - ranges.close.min; + const overflow = nominalTotal - frame.contentHeight; + const overflowBeforeClosing = nominalTotal - closingNominal - frame.contentHeight; + const air = (over: number, slack: number) => Math.min(slack, over + FIT_AIR); + // 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 (then its own gap stays nominal). + const { close: closeRange, ...beforeRanges } = ranges; + let g: Record<"bar" | "rowPad" | "close", number>; + if (overflow > 0 && overflow <= slackAll) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slackAll))); + else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore(padRows)) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore(padRows)))), close: closeRange.nominal }; + else g = elastic(ranges, frame.heightDeficit); + const rowCount = n + padRows; + const padY = rowCount ? g.rowPad / (2 * rowCount) : padYn; + const closingH = closingFor({ close: g.close }); + const tailHigh = o.gap + body; + const table = planTable(padY, tailHigh + lastExtra); + const guardH = groupH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (n === 1 ? tailHigh : 0); + + return { + v, + W, + head: { titleSize: tFit.size, titleW, stackW, showName, nameH, idsH, stackH, h: headH }, + fields: { specs, labelW: fieldLabelW, valueW: f.w, heights, fieldsH, subLines: subH > 0 ? 1 : 0, subH, leftW, payLines, payH, h: blocksH }, + columns, + headerCells, + rows, + align, + padLeft, + padRight, + padY, + cw, + innerW, + table, + padRows, + padRowH: baseLines + 2 * padY + v.hair, + groupH, + guardH, + tail: { boxW, labelW, valueW, stack, slotRows, rows: totalRows, boxH, wordsW, wordsH, reverseH, leftH, body }, + closing: { notes: model.notesLines, ruledRows, ruled, notesFlow, notesFlowH, leftW: closeLeftW, contacts, leftH: leftH2, rightH, signH, h: closingH }, + gaps: { bar: g.bar, close: g.close }, + tailH: tailHigh, + closingH, + }; +} diff --git a/src/pdf/templates/highlighter/tokens.ts b/src/pdf/templates/highlighter/tokens.ts new file mode 100644 index 0000000..3173c2a --- /dev/null +++ b/src/pdf/templates/highlighter/tokens.ts @@ -0,0 +1,168 @@ +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 M = "Montserrat" as const; + +/** Near-black ink: the title, labels, table heads, the 3 pt frames and the icons. */ +export const INK = "#1C2120"; +/** Deep indigo hairlines: the field underlines, the table grid and the ruled lines. */ +export const INDIGO = "#423869"; +/** Signal yellow: the table header bar and the total cell (the template's accent). */ +export const YELLOW = "#FFE535"; +/** The contact lines and the total label are pure black in the design. */ +export const BLACK = "#000000"; + +/** + * Highlighter's type (docs/templates/02-highlighter.md): Poppins Bold for the title, table heads, bank lines, total + * and THANK YOU; Poppins Regular for the field labels and the table; Montserrat Regular for the contact lines. + * Poppins ascent 1.05, descent 0.35; Montserrat 0.968 / 0.251. A text line's baseline sits `ascent x size` under its top. + */ +export const HIGHLIGHTER_ROLES = defineRoles({ + /** "Invoice" / "Tax Invoice": 45.68 pt, tracked -0.062 em; the line box is the design's 64 pt tall zone. */ + title: { face: P, weight: 700, size: 45.68, leading: 64, tracking: -0.062, minSize: 22 }, + vendorName: { face: P, weight: 700, size: 13, leading: 18.5, minSize: 9 }, + vendorId: { face: P, weight: 400, size: 10, leading: 14, minSize: 7 }, + /** "Date:", "Invoice number:", "Billed to:". The line box ends where the field's underline begins. */ + label: { face: P, weight: 400, size: 13, leading: 18.5, tracking: -0.028 }, + value: { face: P, weight: 400, size: 13, leading: 18.5, minSize: 9 }, + /** The client's address lines under the Billed to field. */ + sub: { face: P, weight: 400, size: 11, leading: 15, minSize: 8 }, + payHead: { face: P, weight: 400, size: 13, leading: 23.25 }, + payLine: { face: P, weight: 700, size: 12, leading: 16.5, minSize: 8 }, + tableHead: { face: P, weight: 700, size: 13, leading: 20.3 }, + tableBody: { face: P, weight: 400, size: 11, leading: 15 }, + tableSub: { face: P, weight: 400, size: 9, leading: 12 }, + totalLabel: { face: P, weight: 700, size: 12, leading: 17.7 }, + totalValue: { face: P, weight: 400, size: 12, leading: 17.7 }, + totalStrong: { face: P, weight: 700, size: 12, leading: 17.7 }, + words: { face: P, weight: 400, size: 10.5, leading: 14 }, + wordsLabel: { face: P, weight: 700, size: 10.5, leading: 14 }, + note: { face: P, weight: 400, size: 10.5, leading: 14 }, + thanks: { face: P, weight: 700, size: 25.01, leading: 30 }, + contact: { face: M, weight: 400, size: 11.67, leading: 16, minSize: 8 }, + signName: { face: P, weight: 700, size: 10.5, leading: 14.5 }, + signDesignation: { face: P, weight: 400, size: 9.5, leading: 13 }, + fine: { face: P, weight: 400, size: 8, leading: 11 }, + footer: { face: P, weight: 400, size: 7 }, +}); + +export type HighlighterRoles = typeof HIGHLIGHTER_ROLES; + +/** + * Canva A4 numbers. The content box is the frame's outer edge (x 58.1 to 537.5, 479.4 wide: the design's strokes are + * centred on x 59.6 / 536.0, so the 3 pt frames reach 1.5 pt outside them); y values are the design's distances turned into + * flow gaps. Everything is relative to the content box, so the margin presets and Letter reuse the same tokens. + */ +export interface HighlighterVariant { + roles: HighlighterRoles; + ink: string; + margins: Sides; + marginFloor: Sides; + /** Text left of the frame's outer edge, and the right-hand column's inset from the right edge. */ + inset: number; + rightInset: number; + /** The 3 pt frame and the 0.75 pt hairlines. */ + frame: number; + hair: number; + head: { + /** The title's and the vendor stack's zone (the design's y 50 to 114). */ + zoneH: number; + /** The vendor stack's width (x 352 to 536). */ + stackW: number; + /** Space between the title and the stack at its narrowest. */ + gap: number; + minTitleW: number; + }; + fields: { + /** Title zone bottom to the first field. */ + top: number; + /** Width of the label + underline row (x 60.3 to 266). */ + w: number; + /** Label to the underline's start, and one field's bottom to the next field's top. */ + labelGap: number; + rowGap: number; + /** The underline's text starts this far in. */ + valuePad: number; + /** Billed-to underline to the first address line. */ + subGap: number; + /** The design's distance from the first field's top to the table bar's top (the empty space the bar leaves). */ + designH: number; + }; + pay: { w: number; top: number }; + table: { + /** The bar's outer height (frame included) and the gap under it to the body's top rule. */ + barH: number; + barPadTop: number; + bodyGap: number; + descPad: number; + numPad: number; + cols: { qty: number; rate: number; amount: number }; + /** Row height for one line, hairline included (the design's 30.65 pt pitch), and the empty rows the form look pads to. */ + rowH: number; + padTo: { A4: number; LETTER: number }; + padYMin: number; + }; + totals: { + /** The gap under the table, the cell height inside the frame and how far the box's outer edge reaches left of the price column. */ + gap: number; + rowH: number; + padY: number; + boxOutside: number; + labelPad: number; + valuePad: number; + wordsGap: number; + wordsTop: number; + }; + closing: { + ruledW: number; + colGap: number; + thanksGap: number; + contactGap: number; + iconSize: number; + iconGap: number; + /** The ruled lines: row height, the text's padding above its baseline, and the lines the form always draws. */ + ruledH: number; + ruledMin: number; + ruledMax: number; + leftTop: number; + signatureImageH: number; + }; + gaps: { + /** Fields and payment block bottom to the table bar (the design leaves 64 pt). */ + bar: GapRange; + /** The totals to the sign-off. */ + close: GapRange; + }; +} + +export const HIGHLIGHTER_VARIANT: HighlighterVariant = { + roles: HIGHLIGHTER_ROLES, + ink: INK, + margins: { top: 50, right: 57.8, bottom: 36, left: 58.1 }, + marginFloor: { top: 36, right: 36, bottom: 30, left: 36 }, + inset: 2.2, + rightInset: 1.5, + frame: 3, + hair: 0.75, + head: { zoneH: 64, stackW: 183.5, gap: 12, minTitleW: 120 }, + fields: { top: 4.55, w: 205.7, labelGap: 5, rowGap: 14.85, valuePad: 3, subGap: 8, designH: 149.65 }, + pay: { w: 183.5, top: 30.7 }, + table: { + barH: 38.4, + barPadTop: 6.05, + bodyGap: 4.5, + descPad: 9, + numPad: 9, + cols: { qty: 64, rate: 102.8, amount: 121.9 }, + rowH: 30.65, + padTo: { A4: 8, LETTER: 7 }, + padYMin: 4.5, + }, + totals: { gap: 4.5, rowH: 25.1, padY: 3.7, boxOutside: 1.5, labelPad: 7, valuePad: 9, wordsGap: 8, wordsTop: 3 }, + closing: { ruledW: 212.3, colGap: 24, thanksGap: 22.6, contactGap: 6.5, iconSize: 14.75, iconGap: 7.55, ruledH: 29.25, ruledMin: 3, ruledMax: 6, leftTop: 3.6, signatureImageH: 36 }, + gaps: { bar: { nominal: 18, min: 14 }, close: { nominal: 24, min: 12 } }, +}; + +export const leading = (t: TypeToken) => t.leading as number; diff --git a/src/pdf/templates/marble/Layout.tsx b/src/pdf/templates/marble/Layout.tsx new file mode 100644 index 0000000..67e0b72 --- /dev/null +++ b/src/pdf/templates/marble/Layout.tsx @@ -0,0 +1,392 @@ +import { Image, View } from "@react-pdf/renderer"; +import type { ReactNode } from "react"; +import { pinned, type ColSpec } 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 { fitSize, 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 type { TypeToken } from "../../fonts/roles"; +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 { LABEL_GAP, NOTES_FLOW_GAP, PARTY_GUTTER, payableLines, planMarble, REVERSE_GAP, signatureOptions, stepOf, taxLabel, type MarblePlan } from "./plan"; +import { RULE, ZEBRA, type MarbleVariant } from "./tokens"; + +/** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */ +const NOTES_HOLD = 70; + +/** Layout-tree ids of the blocks that are Marble's own (the harness ids are in classic/ids.ts). */ +export const IDS_ID = "marble-ids"; +export const PAYABLE_ID = "marble-payable"; +export const BILL_ID = "marble-bill"; + +interface Ctx { + model: TemplateProps["model"]; + plan: MarblePlan; + v: MarbleVariant; + logo: PlacedLogo | null; +} + +function Spacer({ h }: { h: number }) { + return h > 0 ? <View style={{ height: h }} /> : null; +} + +function Rule({ ctx }: { ctx: Ctx }) { + return <AccentRule width={ctx.plan.W} thickness={ctx.v.rule} color={RULE} />; +} + +/** + * One right-aligned line of tracked text. textkit adds the tracking after the last glyph too, so the box is + * widened by one step and pulled back with a negative margin: the ink then ends on the edge. + */ +function RightText({ role, color, width, children }: { role: TypeToken; color: string; width: number; children: string }) { + const step = stepOf(role); + const fit = fitSize(role, children, width + step); + if (!fit.fits) { + return ( + <IdText role={role} color={color} width={width} align="right" fill> + {children} + </IdText> + ); + } + const sized = { ...role, size: fit.size }; + const s = stepOf(sized); + return ( + <Txt role={sized} color={color} width={width + s} align="right" style={{ marginRight: -s }}> + {children} + </Txt> + ); +} + +// --- header ----------------------------------------------------------------------------------------------------- + +/** The vendor name (Lustria caps) or its logo; the design leaves this corner empty. */ +function Brand({ ctx }: { ctx: Ctx }) { + const { model, plan, v, logo } = ctx; + const h = plan.head; + const hd = v.head; + return ( + <View id={SUPPLIER_ID} style={{ ...pinned(h.leftW), marginLeft: v.inset.left } as never}> + {logo ? ( + <View style={{ ...pinned(h.leftW), height: Math.max(hd.logoH, h.logoTop + logo.h) } as never}> + <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: h.logoTop, objectFit: "contain" } as never} /> + </View> + ) : null} + {h.showName ? ( + <View style={{ ...pinned(h.leftW), marginTop: logo ? hd.nameGap : hd.nameTop } as never}> + <AvailableWidthProvider width={h.leftW}> + <SafeTxt role={v.roles.brand} color={v.ink}> + {model.supplier.name} + </SafeTxt> + </AvailableWidthProvider> + </View> + ) : null} + </View> + ); +} + +function Stack({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const h = plan.head; + const r = v.roles; + return ( + <View style={{ ...pinned(h.stackW), paddingTop: v.head.titleTop } as never}> + <FitText id={TITLE_ID} role={{ ...r.title, size: h.titleSize }} color={v.ink} width={h.stackW + stepOf({ ...r.title, size: h.titleSize })} align="right" style={{ marginRight: -stepOf({ ...r.title, size: h.titleSize }) }}> + {model.docTitle} + </FitText> + <View id={META_ID} style={{ ...pinned(h.stackW), marginTop: v.head.titleGap } as never}> + <AvailableWidthProvider width={h.stackW}> + {h.lines.map((l, i) => ( + <RightText key={i} role={r.meta} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} width={h.stackW}> + {l.text} + </RightText> + ))} + </AvailableWidthProvider> + </View> + </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 }}> + <Brand ctx={ctx} /> + <Stack ctx={ctx} /> + </Row> + ); +} + +// --- parties ---------------------------------------------------------------------------------------------------- + +function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) { + const { model, v } = ctx; + const r = v.roles; + const c = model.client; + return ( + <View id={BILL_ID} style={pinned(width) as never}> + <AvailableWidthProvider width={width}> + <Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}> + Bill 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 PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) { + const { model, v } = ctx; + const r = v.roles; + return ( + <View id={PAYABLE_ID} style={pinned(width) as never}> + <AvailableWidthProvider width={width}> + <Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}> + Payable to: + </Txt> + {payableLines(model).map((line, i) => ( + <SafeTxt key={i} role={r.body} color={v.ink}> + {line} + </SafeTxt> + ))} + </AvailableWidthProvider> + </View> + ); +} + +function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) { + const { model, v } = ctx; + const r = v.roles; + const s = model.supplier; + return ( + <View id={IDS_ID} style={pinned(width) as never}> + <AvailableWidthProvider width={width}> + <Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.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> + ); +} + +function Parties({ ctx }: { ctx: Ctx }) { + const { plan, v } = ctx; + const cols = plan.info.cols; + const text = cols.map((w) => Math.max(40, w - PARTY_GUTTER)); + const blocks: ReactNode[] = [<ClientBlock key="a" ctx={ctx} width={text[0]} />]; + let k = 1; + if (plan.info.hasBank) blocks.push(<PayableBlock key="b" ctx={ctx} width={text[k++]} />); + if (plan.info.hasIds) blocks.push(<IdsBlock key="c" ctx={ctx} width={text[k++]} />); + const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w })); + return ( + <View style={{ ...pinned(plan.W), paddingLeft: v.inset.left } as never}> + <Row widths={specs} width={plan.W - v.inset.left}> + {blocks} + </Row> + </View> + ); +} + +// --- totals ----------------------------------------------------------------------------------------------------- + +function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) { + const { plan, v } = ctx; + const t = plan.tail; + const r = v.roles; + const labelW = t.boxW - t.valueW; + return ( + <Row widths={[{ fixed: labelW }, { fixed: t.valueW }]} width={t.boxW} style={v.totals.rowGap ? { marginBottom: v.totals.rowGap } : undefined}> + <Txt role={r.totalLabel} color={v.ink} width={labelW}> + {label} + </Txt> + <RightText role={r.totalValue} color={v.ink} width={t.valueW}> + {amount} + </RightText> + </Row> + ); +} + +function TotalsBox({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const t = plan.tail; + return ( + <View style={pinned(t.boxW) as never}> + <AvailableWidthProvider width={t.boxW}> + {t.stack.map((row, i) => ( + <StackRow 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={taxLabel} renderRow={(row) => <StackRow ctx={ctx} label={row.label} amount={row.amount} />} /> + <View style={{ marginTop: v.totals.dueGap, ...pinned(t.boxW) } as never}> + <RightText role={{ ...v.roles.due, size: t.dueSize }} color={v.ink} width={t.boxW}> + {t.dueText} + </RightText> + </View> + </AvailableWidthProvider> + </View> + ); +} + +function Tail({ ctx }: { ctx: Ctx }) { + const { model, plan, v } = ctx; + const t = plan.tail; + const r = v.roles; + const o = v.totals; + return ( + <View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}> + <Spacer h={plan.gaps.tail} /> + <Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={o.leftGap} width={plan.W - o.rightInset}> + <View style={{ ...pinned(t.leftW), marginLeft: v.inset.left } as never}> + <AvailableWidthProvider width={t.leftW}> + <Slot.AmountInWords model={model} role={r.small} labelRole={r.smallLabel} 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} + </AvailableWidthProvider> + </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.smallLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}> + Notes: + </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 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}> + <Spacer h={plan.gaps.close} /> + <Row widths={[{ fill: true }, { fixed: cl.signW }]} gap={cl.gap} width={plan.W - v.totals.rightInset}> + <View style={{ ...pinned(c.notesW), marginLeft: v.inset.left } as never}>{c.notesBeside ? <Notes ctx={ctx} width={c.notesW} /> : null}</View> + <Slot.Signatory model={model} width={cl.signW} {...opts} /> + </Row> + {model.cancelled ? ( + <Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset.left }}> + {CANCELLED_SENTENCE} + </Txt> + ) : null} + </AvailableWidthProvider> + </View> + </> + ); +} + +// --- the layout ------------------------------------------------------------------------------------------------- + +export function MarbleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MarbleVariant }) { + const plan = planMarble(model, frame, v, logo, includesName({ model, prefs })); + const ctx: Ctx = { model, plan, v, logo }; + const r = v.roles; + const t = v.table; + const notesFlow: ReactNode = + plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : ( + <> + {/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */} + <TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} /> + <View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset.left }}> + <Notes ctx={ctx} width={plan.W - v.inset.left - v.totals.rightInset} /> + </View> + </> + ); + return ( + <> + <FirstPageHeader pad={frame.pad}> + <HeaderRow ctx={ctx} /> + <Spacer h={plan.gaps.headRule} /> + <Rule ctx={ctx} /> + <Spacer h={plan.gaps.infoTop} /> + <Parties 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={0} + border={0} + headBorder={0} + headColor={v.ink} + rowStyle={(i) => (i % 2 === 0 ? { backgroundColor: ZEBRA } : {})} + headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)} + headerTail={<Spacer h={t.headGap} />} + headerTailH={t.headGap} + tailH={plan.tailH} + tail={<Tail ctx={ctx} />} + /> + </View> + {notesFlow} + <Closing ctx={ctx} /> + </> + ); +} diff --git a/src/pdf/templates/marble/marble.test.tsx b/src/pdf/templates/marble/marble.test.tsx new file mode 100644 index 0000000..ac2ac28 --- /dev/null +++ b/src/pdf/templates/marble/marble.test.tsx @@ -0,0 +1,483 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { FONT_MANIFEST } from "../../fonts/manifest"; +import { familySources } from "../../fonts/stack"; +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 { OPTIN_SLOTS } from "../slots.optin"; +import { SLOTS } from "../slots.generated"; +import { IDS_ID, PAYABLE_ID } from "./Layout"; +import { marble } from "./marble"; +import { planMarble } from "./plan"; +import { MARBLE_ROLES, MARBLE_VARIANT, RULE, ZEBRA } from "./tokens"; + +ensureSetup(); + +const V = MARBLE_VARIANT; +const slot = OPTIN_SLOTS.marble!; +type R = Awaited<ReturnType<typeof renderFixture>>; +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: 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; h: 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, h: l.h }); + }); + return out; +}; +const squash = (s: string) => s.replace(/\s+/g, ""); +/** Text items of page `p` whose text, spaces removed, is `text`. */ +const items = (r: R, text: string, p = 0) => r.pdf.pages[p].items.filter((i) => squash(i.str) === squash(text)); +const contentRight = (r: R) => { + const f = frameForPrefs(r.prefs); + return f.page.width - f.pad.right; +}; +const optIn = (size: "A4" | "LETTER" = "A4") => prefsFor(size, { templateId: "marble", logo: { show: true } }); +/** The Canva sample's shape: three rows, no GST data, no due date. */ +const PLAIN: FixtureSpec = { + name: "plain", + items: ["Brand consultation", "Logo design", "Website design"].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 `page` at 4x. */ +async function colourAt(bytes: Uint8Array, points: Array<[number, number]>, page = 1): Promise<number[][]> { + const lib = await loadCanvas(); + if (!lib) return []; + const png = await pagePng(lib, bytes, page, 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 and the logo opt-in", () => { + expect(getTemplate("marble")).toBe(marble); + expect(marble.family).toBe("standalone"); + expect(catalogEntry("marble").name).toBe("Marble"); + expect(catalogEntry("marble").logoOptIn).toBe(true); + expect(existsSync(path.join(process.cwd(), "public", marble.thumbnail)), marble.thumbnail).toBe(true); + expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["marble", "highlighter"]); + expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); + }); + + it("sets Lustria for the tracked caps and Now (drawn in Jost) for the text, in the design's palette only", () => { + for (const [name, token] of Object.entries(MARBLE_ROLES)) { + expect(["Lustria", "Now"], name).toContain(token.face); + expect(token.weight, name).toBe(400); + } + const lustria = FONT_MANIFEST.families.find((f) => f.family === "Lustria")!; + expect(lustria.license).toBe("OFL-1.1"); + expect(lustria.fonts.map((f) => f.file)).toEqual(["Lustria-Regular.ttf"]); + // Lustria has no italic: the registration reuses the upright file, so a fallback stack never fails to resolve one. + expect(familySources(lustria).map((s) => `${s.fontWeight}/${s.fontStyle}`)).toEqual(["400/normal", "400/italic"]); + const dir = path.join(process.cwd(), "src/pdf/templates/marble"); + const palette = ["#000000", RULE, ZEBRA, "#F4F3F5", "#ECEAEE"].map((c) => c.toLowerCase()); + 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) => !palette.includes(c.toLowerCase())), f).toEqual([]); + } + expect(marble.decor.ink).toBe("#000000"); + }); + + it("the tracked rupee amounts come from the fallback font: every amount extracts with its sign", async () => { + const r = await renderFixture({ name: "t", items: 3 }, prefsFor("A4", { templateId: "marble" })); + const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join(""); + expect(flat).toContain("₹"); + expect(r.pdf.raw).not.toContain("/BaseFont /Helvetica"); + }); +}); + +describe("page geometry", () => { + it("template margins, floors and the bands", () => { + expect(marginsFor(marble, "template")).toEqual({ top: 86, right: 45.8, bottom: 24, left: 31.5 }); + expect(marginsFor(marble, "narrow").left).toBe(36); + expect(marginsFor(marble, "narrow").top).toBe(86); + // The flow starts under the 64 pt top band (and the continuation text) and stops above the 130 pt foot band. + const frame = frameForPrefs(prefsFor("A4", { templateId: "marble" })); + expect(frame.pad.top).toBeGreaterThanOrEqual(V.bands.top + 18); + expect(frame.page.height - frame.pad.bottom).toBeLessThanOrEqual(frame.page.height - V.bands.bottom - 10); + }); + + it("Letter keeps the physical margins and takes the missing height from the gaps", () => { + const a4 = frameForPrefs(prefsFor("A4", { templateId: "marble" })); + const letter = frameForPrefs(prefsFor("LETTER", { templateId: "marble" })); + expect(letter.pad).toEqual(a4.pad); + expect(letter.heightDeficit).toBeCloseTo(49.89, 2); + }); + + it("both bands are on every page, full bleed", async () => { + const r = await renderFixture({ name: "rows", items: 40, signature: true }, prefsFor("A4", { templateId: "marble" })); + expect(r.result.pages).toBeGreaterThan(1); + for (let p = 0; p < r.result.pages; p++) { + const decor = onPage(r, p).filter((n) => n.decor && n.type === "svg"); + const boxes = decor.map((n) => n.box); + expect(boxes.some((b) => b.y === 0 && Math.abs(b.h - V.bands.top) < 0.01 && b.w >= 595), `page ${p + 1} top band`).toBe(true); + expect(boxes.some((b) => Math.abs(b.y + b.h - 841.89) < 0.5 && Math.abs(b.h - V.bands.bottom) < 0.01), `page ${p + 1} foot band`).toBe(true); + } + }); +}); + +describe("header", () => { + it("matches the design: title baseline 124.9, then the number, Issued and Due lines at the 17.5 pt pitch, all ending on the right edge", async () => { + const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); + const right = contentRight(r); + const title = items(r, "INVOICE")[0]; + expect(title.bottom).toBeCloseTo(124.9, 0); + expect(title.x1).toBeCloseTo(right, 0); + const number = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("InvoiceNo."))!; + const issued = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("Issued:"))!; + const due = r.pdf.pages[0].items.find((i) => squash(i.str).startsWith("Due:"))!; + for (const [what, it, baseline] of [["number", number, 142.1], ["issued", issued, 159.6], ["due", due, 177.1]] as const) { + expect(it.bottom, what).toBeCloseTo(baseline, 0); + expect(it.x1, what).toBeCloseTo(right, 0); + } + expect(squash(due.str)).toBe("Due:OnReceipt"); + }); + + it("the vendor name stands in the empty left corner, level with the title, in Lustria caps", async () => { + const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); + const name = items(r, "ARUN P")[0]; + expect(name.bottom).toBeCloseTo(124.9, 0); + expect(name.x0).toBeCloseTo(38.5, 0); + }); + + it("TAX INVOICE and a long invoice number never reach the vendor name", 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/REV-3/LONG-REFERENCE-NUMBER" } }, prefsFor(size, { templateId: "marble" })); + const supplier = textLines(byId(r, SUPPLIER_ID)); + const stack = [...textLines(byId(r, TITLE_ID)), ...textLines(byId(r, META_ID))]; + expect(Math.max(...supplier.map((l) => l.right)), size).toBeLessThan(Math.min(...stack.map((l) => l.x)) - 10); + expect(r.result.issues.filter((i) => i.severity === "error"), size).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: "marble" })); + expect(textLines(byId(r, META_ID)).some((l) => l.text === "CANCELLED")).toBe(true); + expect(squash(r.pdf.pages[r.result.pages - 1].items.map((i) => i.str).join(""))).toContain(squash("This invoice has been cancelled.")); + }); +}); + +describe("parties band", () => { + it("BILL TO and the first lines sit where the design has them (heading 247.9, lines at the 18.2 pt pitch from 269.0)", async () => { + const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); + const heading = items(r, "BILL TO:")[0]; + expect(heading.bottom).toBeCloseTo(247.9, 0); + expect(heading.x0).toBeCloseTo(38.5, 0); + const first = items(r, "Acme Learning Pvt Ltd")[0]; + expect(first.bottom).toBeCloseTo(269.0, 0); + const pay = items(r, "PAYABLE TO:")[0]; + expect(pay.bottom).toBeCloseTo(247.9, 0); + expect(pay.x0).toBeCloseTo(211.4, 0); + }); + + it("the supplier's GSTIN and PAN are in the third column, and the hairlines are the mauve-grey 0.75 pt rules", async () => { + const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" })); + const ids = textLines(byId(r, IDS_ID)).map((l) => l.text); + expect(ids).toEqual(expect.arrayContaining(["SUPPLIER:", "GSTIN: 29AAAAA0000A1Z5", "PAN: CPOPP7388K"])); + expect(textLines(byId(r, PAYABLE_ID)).map((l) => l.text)).toContain("PAYABLE TO:"); + const plan = planMarble(r.fixture.model, frameForPrefs(r.prefs), V, null, false); + const rules = onPage(r, 0).filter((n) => n.type === "view" && Math.abs(n.box.h - V.rule) < 0.01 && Math.abs(n.box.w - plan.W) < 0.5); + expect(rules.length).toBeGreaterThanOrEqual(2); + const [c] = await colourAt(r.result.bytes, [[300, rules[0].box.y + 0.375]]); + expect(near(c, RULE, 14), `rule ${c}`).toBe(true); + }); +}); + +describe("logo", () => { + it("is an opt-in slot (the design has no placeholder): off by default, 150 x 50 at the left, bottom edge on the title's baseline", () => { + expect(SLOTS.marble).toBeNull(); + expect(marble.logo.slot).toBe(slot); + expect(marble.logo.optIn).toBe(true); + expect(marble.logo.defaultOn).toBe(false); + expect(slot.lockup).toMatchObject({ x: 38.5, y: 86, w: 150, h: 50 }); + expect(marble.logo.box).toEqual({ w: 150, h: 50 }); + }); + + it("without the opt-in no logo is drawn, even when the invoice has one", async () => { + const r = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "marble" })); + expect(imageBoxes(r)).toHaveLength(0); + expect(items(r, "ARUN P")).toHaveLength(1); + }); + + it("every opted-in logo fits the 150 x 50 slot, starts at the text inset and rests on its bottom edge", 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 }, optIn(size)); + const imgs = imageBoxes(r); + expect(imgs, `${size}/${logo}`).toHaveLength(1); + const [img] = imgs; + expect(img.box.w).toBeLessThanOrEqual(150.01); + expect(img.box.h).toBeLessThanOrEqual(50.01); + expect(img.box.x, `${size}/${logo} left`).toBeCloseTo(frameForPrefs(r.prefs).pad.left + V.inset.left, 0); + expect(img.box.y + img.box.h, `${size}/${logo} bottom`).toBeCloseTo(frameForPrefs(r.prefs).pad.top + 50, 0); + // The logo box and the supplier block never reach the title stack. + const stack = byId(r, META_ID); + expect(byId(r, SUPPLIER_ID).box.x + byId(r, SUPPLIER_ID).box.w).toBeLessThanOrEqual(stack.box.x + 0.5); + expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${logo}`).toEqual([]); + } + } + }); + + it("a tall logo is held to the slot's 50 pt 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: optIn() }); + 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(50.01); + }); + + it("a wordmark replaces the vendor name text; a mark keeps it under the mark", async () => { + const header = (r: R) => r.pdf.pages[0].items.filter((i) => squash(i.str) === "ARUNP" && i.bottom < 200); + const wide = await renderFixture({ name: "o", items: 2, logo: "real" }, optIn()); + expect(header(wide)).toHaveLength(0); + const square = await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()); + const [name] = header(square); + expect(name).toBeTruthy(); + expect(name.bottom - 12).toBeGreaterThan(imageBoxes(square)[0].box.y + imageBoxes(square)[0].box.h - 1); + }); +}); + +describe("table", () => { + it("zebra rows on the 1st, 3rd ... (0-based even) in #F7F7F8, white between, no rule or fill on the heading", async () => { + const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); + const row = (i: number) => byId(r, `${TABLE_ROW_PREFIX}${i}`).box; + const head = byId(r, TABLE_HEAD_ID).box; + const pts: Array<[number, number]> = [[34, row(0).y + 2], [34, row(1).y + 2], [34, row(2).y + 2], [34, head.y + 4]]; + const colours = await colourAt(r.result.bytes, pts); + if (colours.length === 0) return; + expect(near(colours[0], ZEBRA, 4), `row 1 ${colours[0]}`).toBe(true); + expect(near(colours[1], "#FFFFFF", 4), `row 2 ${colours[1]}`).toBe(true); + expect(near(colours[2], ZEBRA, 4), `row 3 ${colours[2]}`).toBe(true); + expect(near(colours[3], "#FFFFFF", 4), `heading ${colours[3]}`).toBe(true); + }); + + it("rows are 32 pt, the first band starts 8.85 pt under the heading and qty is centred where the design centres it (x 355.5)", async () => { + const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); + const head = byId(r, TABLE_HEAD_ID).box; + const r0 = byId(r, `${TABLE_ROW_PREFIX}0`).box; + const r1 = byId(r, `${TABLE_ROW_PREFIX}1`).box; + expect(r0.h).toBeCloseTo(32, 0); + expect(r1.y - r0.y).toBeCloseTo(32, 0); + expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0); + const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!; + expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0); + expect(r0.x).toBeCloseTo(31.5, 0); + }); + + it("amounts are right-aligned 9.5 pt inside the edge so the rupee figures line up", async () => { + const r = await renderFixture(PLAIN, prefsFor("A4", { templateId: "marble" })); + const right = contentRight(r); + for (let i = 0; i < 3; i++) { + const lines = textLines(byId(r, `${TABLE_ROW_PREFIX}${i}`)); + 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); + } + }); + + it("the rate keeps its unit on one line while the column can grow to take it, and an HSN/SAC code is a small sub-line", async () => { + const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" })); + const row0 = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).map((l) => l.text); + expect(row0).toContain("₹1,200.00/hr"); + expect(row0).toContain("HSN/SAC: 998397"); + }); + + it("repeats the heading on every continuation page and never leaves it alone", async () => { + const r = await renderFixture({ name: "rows", items: 40 }, prefsFor("A4", { templateId: "marble" })); + expect(r.result.pages).toBeGreaterThan(1); + for (let p = 0; p < r.result.pages; p++) { + const rows = onPage(r, p).filter((n) => n.id?.startsWith(TABLE_ROW_PREFIX)).length; + const heads = onPage(r, p).filter((n) => n.id === TABLE_HEAD_ID).length; + if (rows > 0) expect(heads, `page ${p + 1}`).toBe(1); + if (heads > 0) expect(rows, `page ${p + 1}`).toBeGreaterThan(0); + } + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + }); +}); + +describe("totals, closing and the foot band", () => { + it("AMOUNT DUE ends on the totals edge (x 535.4), under a stack of SUBTOTAL and the tax rows", async () => { + const r = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "marble" })); + const totals = byId(r, TOTALS_ID, r.result.pages - 1); + const lines = textLines(totals); + const due = lines.find((l) => /^AMOUNT DUE:/.test(l.text))!; + expect(due.right).toBeCloseTo(contentRight(r) - V.totals.rightInset, 0); + const labels = lines.map((l) => l.text); + expect(labels).toEqual(expect.arrayContaining(["SUBTOTAL", "TAXABLE VALUE", "CGST 9%", "SGST 9%"])); + expect(due.y).toBeGreaterThan(lines.find((l) => l.text === "SGST 9%")!.y); + }); + + it("the totals stack, the words and the reverse-charge line never overlap, even with a long amount", async () => { + const r = await renderFixture({ name: "t", items: [{ id: 1, description: "Retainer", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 98765432.1, sortOrder: 0, hsnSac: "998397" }], logo: "none", invoice: { discount: 5000 } }, prefsFor("A4", { templateId: "marble" })); + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + const totals = byId(r, TOTALS_ID, r.result.pages - 1); + const lines = textLines(totals); + for (let i = 0; i < lines.length; i++) + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + const overlap = Math.min(a.right, b.right) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5; + expect(overlap, `${a.text} / ${b.text}`).toBe(false); + } + }); + + it("the signature sits in the flow above the foot band, never inside it", async () => { + for (const size of ["A4", "LETTER"] as const) { + const r = await renderFixture({ name: "c", items: 3, logo: "none", signature: true }, prefsFor(size, { templateId: "marble" })); + const last = r.result.pages - 1; + const sign = byId(r, SLOT_ID.signatory, last); + expect(sign, size).toBeTruthy(); + const frame = frameForPrefs(r.prefs); + expect(sign.box.y + sign.box.h, size).toBeLessThanOrEqual(frame.page.height - V.bands.bottom + 0.5); + expect(byId(r, CLOSING_ID, last).box.x + byId(r, CLOSING_ID, last).box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5); + } + }); + + it("the foot band carries the vendor's name and address in column 1 (x 40.7) and the phone and e-mail in column 2 (x 197.8 while the address is short)", async () => { + const short = await renderFixture({ ...PLAIN, vendor: { vendorAddress: "123 Anywhere St.\nAny City, ST 12345", vendorName: "Olivia Wilson", vendorPhone: "123-456-7890", vendorEmail: "hello@reallygreatsite.com" } }, prefsFor("A4", { templateId: "marble" })); + const name = items(short, "Olivia Wilson").filter((i) => i.bottom > 700)[0]; + expect(name.x0).toBeCloseTo(40.7, 0); + expect(name.bottom).toBeCloseTo(746.9, 0); + expect(items(short, "123 Anywhere St.")[0].bottom).toBeCloseTo(762.2, 0); + expect(items(short, "Any City, ST 12345")[0].bottom).toBeCloseTo(777.5, 0); + const phone = items(short, "123-456-7890")[0]; + expect(phone.x0).toBeCloseTo(197.8, 0); + expect(phone.bottom).toBeCloseTo(746.9, 0); + // A long address takes the first column's full width and the second column moves right of it. + const long = await renderFixture({ ...PLAIN }, prefsFor("A4", { templateId: "marble" })); + expect(items(long, "9738284459")[0].x0).toBeGreaterThan(197.8 + 20); + }); + + it("a short invoice with the sample's data fits one A4 page by giving back the gaps and row padding", 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: "marble" })); + expect(r.result.pages).toBe(1); + expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]); + }); +}); + +describe("the GST slots", () => { + it("every GST slot has a home on a tax invoice, and the supplier's identifiers are on page 1 whatever the length", async () => { + const r = await renderFixture({ name: "t", items: 60, signature: true }, prefsFor("A4", { templateId: "marble" })); + 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(onPage(r, 0).some((n) => n.id === IDS_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", "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: "marble" })); + 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: "marble" })); + expect(r.result.issues.filter((i) => i.severity === "error"), `${size}/${name}`).toEqual([]); + } + } + }); + + it("the foot band's text stays inside the band whatever the address, and its two columns never overlap", async () => { + for (const size of ["A4", "LETTER"] as const) { + const r = await renderFixture({ name: "long", items: 3, vendor: { vendorAddress: Array.from({ length: 8 }, (_, i) => `Address line ${i + 1} of a very long Indian address with a locality name`).join("\n"), vendorEmail: "arun.padmanabhan.voiceover.studio.bangalore@examplestudios-india.co.in", vendorName: "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890" } }, prefsFor(size, { templateId: "marble" })); + const frame = frameForPrefs(r.prefs); + const decorText = onPage(r, 0).filter((n) => n.decor && n.type === "text" && n.box.y > frame.page.height - V.bands.bottom); + const lines = decorText.flatMap((n) => textLines(n)); + expect(lines.length).toBeGreaterThan(2); + for (const l of lines) { + expect(l.y + l.h, `${size} "${l.text}" bottom`).toBeLessThanOrEqual(frame.page.height - 12); + expect(l.right, `${size} "${l.text}" right`).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 1); + } + for (let i = 0; i < lines.length; i++) + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + const hit = Math.min(a.right, b.right) - Math.max(a.x, b.x) > 0.5 && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > 0.5; + expect(hit, `${size}: "${a.text}" / "${b.text}"`).toBe(false); + } + } + }); + + 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: "marble" })); + const plan = planMarble(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(squash(r.pdf.pages.map((p) => p.items.map((i) => i.str).join("")).join(""))).toContain("Note18"); + }); + + it("a long client name and the supplier columns stay in their own columns", async () => { + const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor("A4", { templateId: "marble" })); + const a = textLines(byId(r, "marble-bill")); + const b = textLines(byId(r, PAYABLE_ID)); + expect(Math.max(...a.map((l) => l.right))).toBeLessThan(Math.min(...b.map((l) => l.x))); + }); +}); + +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: "marble" }); + const frame = frameForPrefs(prefs); + // Logo-less (the opt-in is off), 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 = planMarble(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/01-marble.md"), "utf8"); + expect(text).toContain("## Implementation notes"); + }); +}); diff --git a/src/pdf/templates/marble/marble.tsx b/src/pdf/templates/marble/marble.tsx new file mode 100644 index 0000000..cca3124 --- /dev/null +++ b/src/pdf/templates/marble/marble.tsx @@ -0,0 +1,146 @@ +import type { ComponentType } from "react"; +import { FitText } from "../../blocks/FitText"; +import type { DecorItem } from "../../blocks/FirstPageHeader"; +import { Txt } from "../../blocks/Txt"; +import { fullBleedBand, type LinearGradientSpec } from "../../decor/FullBleedBand"; +import { getMeasurer, measureToken } from "../../engine/measure"; +import { textLines } from "../../engine/textplan"; +import type { PageFrame } from "../../engine/geometry"; +import type { RenderModel } from "../../model/build"; +import { catalogEntry } from "../catalog"; +import type { TemplateDefinition, TemplateProps } from "../contract"; +import { slotFor } from "../slots.optin"; +import { MarbleBody } from "./Layout"; +import { BAND_FROM, BAND_TO, INK, MARBLE_ROLES, MARBLE_VARIANT, leading } from "./tokens"; + +export { MARBLE_ROLES, MARBLE_VARIANT } from "./tokens"; + +const V = MARBLE_VARIANT; +const slot = slotFor("marble"); +/** The opt-in logo area (docs/templates/01-marble.md: header-left, 150 x 50, bottom edge on the title's baseline). */ +const area = slot?.lockup ?? { x: 38.5, y: 86, w: 150, h: 50 }; + +/** A soft sheen standing in for the marble photograph (which is stock media and not bundled). */ +const SHEEN: LinearGradientSpec = { + x1: 0, + y1: 0, + x2: 1, + y2: 1, + stops: [ + { offset: 0, color: BAND_FROM }, + { offset: 0.55, color: BAND_TO }, + { offset: 1, color: BAND_FROM }, + ], +}; + +/** Lines of the first footer column, each with the share of the line budget it may take (an ellipsis after that). */ +function footerLines(texts: string[], width: number, budget: number): Array<{ text: string; maxLines: number; fit: boolean }> { + const out: Array<{ text: string; maxLines: number; fit: boolean }> = []; + const m = getMeasurer(); + let left = budget; + for (const text of texts) { + if (left <= 0) break; + // A word wider than the column cannot wrap (textkit never breaks inside a word): that line is fitted to the width instead. + const fit = Math.max(...text.split(" ").map((w) => measureToken(m, MARBLE_ROLES.contact, w))) > width; + const take = fit ? 1 : Math.min(textLines(MARBLE_ROLES.contact, text, width), left); + out.push({ text, maxLines: take, fit }); + left -= take; + } + return out; +} + +/** + * The every-page art: the top band, and the foot band with the vendor's contact columns. It is decor (fixed, page space), so + * the address is capped at a few lines and the phone and e-mail are one fitted line each; the supplier's identifiers are + * printed in the flow (the "Supplier" column of the parties band). + */ +function everyPage(frame: PageFrame, model: RenderModel): DecorItem[] { + const r = MARBLE_ROLES.contact; + const f = V.footer; + const { width: pw, height: ph } = frame.page; + const bottom = V.bands.bottom; + const items: DecorItem[] = [fullBleedBand(frame, { top: 0, height: V.bands.top, fill: SHEEN }), fullBleedBand(frame, { bottom: 0, height: bottom, fill: SHEEN })]; + const x1 = frame.pad.left + (f.col1 - 31.5); + const texts = [model.supplier.name, ...model.supplier.addressLines].filter((t) => t.trim() !== ""); + const natural = Math.max(0, ...texts.map((t) => Math.ceil(measureToken(getMeasurer(), r, t)) + 1)); + const col1W = Math.min(f.col1MaxW, Math.max(60, natural)); + const y = ph - bottom + f.top; + const budget = frame.page.id === "A4" ? f.maxLines : f.maxLines - 1; + const lines = footerLines(texts, col1W, budget); + items.push({ + x: x1, + y, + width: col1W, + height: lines.reduce((h, l) => h + l.maxLines * leading(r), 0), + children: ( + <> + {lines.map((l, i) => + l.fit ? ( + <FitText key={i} role={r} color={INK} width={col1W}> + {l.text} + </FitText> + ) : ( + <Txt key={i} role={r} color={INK} width={col1W} maxLines={l.maxLines}> + {l.text} + </Txt> + ), + )} + </> + ), + }); + const x2 = Math.max(frame.pad.left + (f.col2 - 31.5), x1 + col1W + f.colGap); + const col2W = Math.max(80, pw - frame.pad.right - x2); + const contacts = [model.supplier.phone, model.supplier.email].filter((t) => t.trim() !== ""); + if (contacts.length > 0) { + items.push({ + x: x2, + y, + width: col2W, + height: contacts.length * leading(r), + children: ( + <> + {contacts.map((t, i) => ( + <FitText key={i} role={r} color={INK} width={col2W}> + {t} + </FitText> + ))} + </> + ), + }); + } + return items; +} + +const MarbleLayout: ComponentType<TemplateProps> = (props) => <MarbleBody {...props} variant={MARBLE_VARIANT} />; +MarbleLayout.displayName = "MarbleLayout"; + +export const marble: TemplateDefinition = { + ...catalogEntry("marble"), + version: 1, + roles: MARBLE_ROLES, + margins: V.margins, + marginFloor: V.marginFloor, + decor: { ink: INK, everyPage }, + chrome: { + color: INK, + rule: null, + role: MARBLE_ROLES.footer, + leftWidth: 150, + footerName: false, + // The foot band is 130 pt; the content stops 12 pt above it, and the footer line sits inside the band, 14 pt off the page foot. + reserve: V.bands.bottom + 12 - V.margins.bottom, + bottom: 14, + // The continuation text on pages 2+ sits under the top band. + bandInset: { left: 7, right: 0 }, + }, + logo: { + box: { w: area.w, h: area.h }, + stackBelowTextWidth: 0, + defaultOn: false, + optIn: catalogEntry("marble").logoOptIn === true, + slotWidth: () => area.w, + slot, + available: () => ({ w: area.w, h: area.h }), + }, + Layout: MarbleLayout, +}; diff --git a/src/pdf/templates/marble/plan.ts b/src/pdf/templates/marble/plan.ts new file mode 100644 index 0000000..631023c --- /dev/null +++ b/src/pdf/templates/marble/plan.ts @@ -0,0 +1,277 @@ +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 MarbleVariant } 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 = 3; +/** Right gutter of a parties column: the text never reaches the next column. */ +export const PARTY_GUTTER = 12; +/** The GST-free sample's due line when the invoice has no due date. */ +/** Widest the rate column grows to keep the unit on the rate's own line. */ +const RATE_MAX = 112; +export const ON_RECEIPT = "On Receipt"; + +/** Tracking after the last glyph: right-aligned tracked text is drawn this far into the margin to end on the edge. */ +export const stepOf = (t: TypeToken) => (t.tracking ?? 0) * t.size; + +/** "Tax" rows read "CGST 9%" like the rest of the invoice's labels. */ +export const taxLabel = (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))); +} + +export interface StackLine { + kind: "cancelled" | "number" | "issued" | "due"; + text: string; +} + +/** The right-hand stack under the title: the number on its own line (the title stays short), then the design's Issued and Due. */ +export function stackLines(model: RenderModel): StackLine[] { + const out: StackLine[] = []; + if (model.cancelled) out.push({ kind: "cancelled", text: "CANCELLED" }); + out.push({ kind: "number", text: `${model.meta.numberLabel} ${model.meta.number}` }); + out.push({ kind: "issued", text: `Issued: ${model.meta.date}` }); + out.push({ kind: "due", text: `Due: ${model.meta.dueDate ?? ON_RECEIPT}` }); + return out; +} + +export function signatureOptions(v: MarbleVariant): 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 }; +} + +/** The table's description (+ HSN/SAC sub-line), rate, qty and amount cells. */ +export function tableRows(model: RenderModel, v: MarbleVariant, 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 [desc, item.qtyText, rateParts, item.amountText]; + }); +} + +/** The payable-to column: the account holder, then "label: value" per bank field. */ +export function payableLines(model: RenderModel): string[] { + // "A/c No." keeps an 11-digit account number on its label's line in the 160 pt column. + return model.bank ? [model.bank.accountName, ...model.bank.lines.map((l) => `${l.label === "Account No." ? "A/c No." : l.label}: ${l.value}`)] : []; +} + +export interface MarblePlan { + v: MarbleVariant; + W: number; + head: { + titleSize: number; + stackW: number; + lines: StackLine[]; + leftW: number; + showName: boolean; + nameH: number; + /** Where the logo sits inside the 50 pt slot box, and the name below it. */ + logoTop: number; + leftH: number; + stackH: number; + h: number; + }; + info: { cols: number[]; hasBank: boolean; hasIds: boolean; clientH: number; bankH: number; idsH: number; h: number }; + columns: ColSpec[]; + headerCells: string[]; + rows: TableCellValue[][]; + align: Array<"left" | "center" | "right">; + padLeft: number[]; + padRight: number[]; + padY: number; + cw: { qty: number; rate: number; amount: number }; + table: ItemsTablePlan; + guardH: number; + tail: { boxW: number; labelW: number; valueW: number; leftW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackH: number; dueText: string; dueSize: number; wordsH: number; reverseH: number; leftH: number; body: number }; + closing: { notes: string[]; notesBeside: boolean; notesH: number; flowH: number; signH: number; notesW: number; bandH: number; h: number }; + gaps: { headRule: number; infoTop: number; infoBottom: number; band: number; tail: number; close: number }; + tailH: number; + closingH: number; +} + +/** + * Everything the layout must know before rendering: the right-hand stack (the title fitted to it), the parties + * columns, the table columns, the totals stack and the heights of the tail and the closing block (their + * minPresenceAhead values), each a hair high rather than low. + */ +export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVariant, logo: PlacedLogo | null, includesName: boolean): MarblePlan { + const m = getMeasurer(); + const r = v.roles; + const W = frame.contentWidth; + const s = model.supplier; + const c = model.client; + const hd = v.head; + + // --- header: the vendor (or its logo) at the left, the title and the number / issued / due lines at the right ------------- + const lines = stackLines(model); + const widthOf = (role: TypeToken, text: string) => Math.ceil(measureToken(m, role, text)) + 1; + const natural = Math.max(widthOf(r.title, model.docTitle), ...lines.map((l) => widthOf(r.meta, l.text))); + const stackW = Math.min(Math.max(hd.minStackW, natural), Math.floor(W * hd.maxShare)); + const titleFit = fitSize(r.title, model.docTitle, stackW + stepOf(r.title)); + let linesH = 0; + for (const l of lines) linesH += idLines(r.meta, l.text, stackW + stepOf(r.meta)) * leading(r.meta); + const stackH = hd.titleTop + leading(r.title) + hd.titleGap + linesH; + const leftW = Math.max(hd.minNameW, W - stackW - hd.colGap - v.inset.left); + const showName = !(logo && includesName) && s.name.trim() !== ""; + const nameH = showName ? textHeight(r.brand, s.name, leftW) : 0; + const logoBox = logo ? hd.logoH : 0; + const logoTop = logo ? Math.max(0, logo.slotOffset?.y ?? hd.logoH - logo.h) : 0; + const leftH = logo ? Math.max(logoBox, logoTop + logo.h) + (showName ? hd.nameGap + nameH : 0) : showName ? hd.nameTop + nameH : 0; + const headH = Math.max(stackH, leftH); + + // --- parties band: BILL TO, PAYABLE TO (bank) and the supplier's identifiers, in the design's three columns ----------------- + const gstinLines = slotHeights.gstinLines(model, "supplier"); + const hasIds = gstinLines + (s.pan ? 1 : 0) > 0; + const payable = payableLines(model); + const hasBank = payable.length > 0; + const colW = [v.parties.colA, v.parties.colB, W - v.inset.left - v.inset.right - v.parties.colA - v.parties.colB]; + const present = [true, hasBank, hasIds]; + const widths = colW.filter((_, i) => present[i]); + const textW = (i: number) => Math.max(40, widths[i] - PARTY_GUTTER); + let k = 0; + const clientW = textW(k++); + const bankW = hasBank ? textW(k++) : 0; + const idsW = hasIds ? textW(k++) : 0; + const head = leading(r.heading) + v.parties.headingGap; + let clientH = head + 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 bankH = hasBank ? head + payable.reduce((h, x) => h + textHeight(r.body, x, bankW), 0) : 0; + const idsH = hasIds ? head + (gstinLines ? idLines(r.body, GSTIN_SAMPLE, idsW) * leading(r.body) : 0) + (s.pan ? idLines(r.body, `PAN: ${s.pan}`, idsW) * leading(r.body) : 0) : 0; + const infoH = Math.max(clientH, bankH, idsH); + + // --- table ----------------------------------------------------------------------------------------------------------- + const t = v.table; + const need = (texts: string[], head: string, pad: number, headStep: number) => + Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6; + const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—")); + // The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour". + const shortRate = model.items.map((i) => { + const cell = rateCell(i.rateText, 10000, r.tableBody); + return typeof cell === "string" ? cell : "—"; + }); + const cw = { + qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)), + rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))), + amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)), + }; + const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }]; + const headerCells = ["Description", "Qty", "Rate", "Total"]; + const align: MarblePlan["align"] = ["left", "center", "right", "right"]; + const padLeft = [t.descInset, 0, 0, 0]; + // The tracking after a right-aligned cell's last glyph is part of its width: take it off the padding so the ink ends where the design puts it. + const bodyStep = stepOf(r.tableBody); + const padRight = [8, 0, t.ratePad - bodyStep, t.numPad - bodyStep]; + const rows = tableRows(model, v, cw.rate - t.ratePad - 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: 0, border: 0, headBorder: 0, headerTailH: t.headGap, tailH, contentH: frame.contentHeight }); + + // --- totals: the stack (small caps labels, values at the right), then AMOUNT DUE; 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, taxLabel); + 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.ceil(Math.max(...labels.map((l) => measureToken(m, r.totalLabel, l)))) + 16; + const valueW = Math.ceil(Math.max(...amounts.map((x) => measureToken(m, r.totalValue, x)))) + 4; + const dueText = `Amount due: ${tt.totalText}`; + const dueFit = fitSize(r.due, dueText, 10000); + const dueW = Math.ceil(dueFit.width) + 4; + const boxW = Math.max(o.boxW, labelW + valueW, dueW); + const leftW0 = Math.max(0, W - o.rightInset - boxW - o.leftGap - v.inset.left); + const stackRowsH = (stack.length + slotRows) * leading(r.totalLabel) + o.rowGap * (stack.length + slotRows); + const stackH2 = stackRowsH + o.dueGap + leading(r.due); + const wordsW = Math.min(o.wordsMax, leftW0); + const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.small, labelRole: r.smallLabel }); + const reverseH = slotHeights.reverseCharge(model, wordsW, r.small); + const wordsBlockH = wordsH || reverseH ? wordsH + (reverseH ? (wordsH ? REVERSE_GAP : 0) + reverseH : 0) : 0; + const body = Math.max(stackH2, wordsBlockH); + + // --- closing: the notes (when short) beside the signature, above the foot band --------------------------------------------- + const cl = v.closing; + const notes = model.notesLines; + const notesW = Math.max(100, W - cl.signW - cl.gap - v.inset.left); + const notesH = notes.length ? leading(r.smallLabel) + LABEL_GAP + notes.reduce((h, l) => h + textHeight(r.small, l, notesW), 0) : 0; + const notesBeside = notes.length === 0 || notesH <= cl.notesBesideMax; + const flowH = notesBeside ? 0 : notesH; + const signH = signatoryHeight(model, cl.signW, signatureOptions(v)); + const bandH = Math.max(notesBeside ? notesH : 0, signH); + const cancelledH = model.cancelled ? 10 + textHeight(r.small, CANCELLED_SENTENCE, W) : 0; + const closingFor = (g: { close: number }) => g.close + bandH + cancelledH; + + // --- gaps: the empty band under the hairlines 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 tailNominal = ranges.tail.nominal + body; + const closingNominal = closingFor({ close: ranges.close.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 + ranges.headRule.nominal + v.rule + ranges.infoTop.nominal + infoH + ranges.infoBottom.nominal + v.rule + ranges.band.nominal + tableTotal + tailNominal + notesBlock + closingNominal; + const slackBefore = ranges.headRule.nominal - ranges.headRule.min + ranges.infoTop.nominal - ranges.infoTop.min + ranges.infoBottom.nominal - ranges.infoBottom.min + ranges.band.nominal - ranges.band.min + ranges.tail.nominal - ranges.tail.min + ranges.rowPad.nominal - ranges.rowPad.min; + const slack = slackBefore + ranges.close.nominal - ranges.close.min; + 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 (its own gap, which cannot help page 1, stays nominal). + 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, room: number) => Math.min(room, over + FIT_AIR); + const { close: closeRange, ...beforeRanges } = ranges; + let g: Record<"headRule" | "infoTop" | "infoBottom" | "band" | "tail" | "rowPad" | "close", number>; + if (overflow > 0 && overflow <= slack) g = elastic(ranges, Math.max(frame.heightDeficit, air(overflow, slack))); + else if (overflowBeforeClosing > 0 && overflowBeforeClosing <= slackBefore) g = { ...elastic(beforeRanges, Math.max(frame.heightDeficit, air(overflowBeforeClosing, slackBefore))), close: closeRange.nominal }; + else g = elastic(ranges, frame.heightDeficit); + const padY = n ? g.rowPad / (2 * n) : padYn; + const tailH = g.tail + body; + const closingH = closingFor({ close: g.close }); + 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: titleFit.size, stackW, lines, leftW, showName, nameH, logoTop, leftH, stackH, h: headH }, + info: { cols: widths, hasBank, hasIds, clientH, bankH, idsH, h: infoH }, + columns, + headerCells, + rows, + align, + padLeft, + padRight, + padY, + cw, + table, + guardH, + tail: { boxW, labelW: boxW - valueW, valueW, leftW: wordsW, stack, slotRows, stackH: stackH2, dueText, dueSize: dueFit.size, wordsH, reverseH, leftH: wordsBlockH, body }, + closing: { notes, notesBeside, notesH, flowH, signH, notesW, bandH, h: closingH }, + gaps: { headRule: g.headRule, infoTop: g.infoTop, infoBottom: g.infoBottom, band: g.band, tail: g.tail, close: g.close }, + tailH, + closingH, + }; +} diff --git a/src/pdf/templates/marble/tokens.ts b/src/pdf/templates/marble/tokens.ts new file mode 100644 index 0000000..b3f344e --- /dev/null +++ b/src/pdf/templates/marble/tokens.ts @@ -0,0 +1,156 @@ +import type { Sides } from "../../engine/geometry"; +import { defineRoles, type TypeToken } from "../../fonts/roles"; +import type { GapRange } from "../neutral/variant"; + +const L = "Lustria" as const; +const N = "Now" as const; + +export const INK = "#000000"; +/** Hairlines around the parties band. */ +export const RULE = "#E7E1E6"; +/** Zebra rows. */ +export const ZEBRA = "#F7F7F8"; +/** The flat/gradient stand-in for the marble photograph: a light warm grey with a soft sheen. */ +export const BAND_FROM = "#F4F3F5"; +export const BAND_TO = "#ECEAEE"; + +/** + * Marble's type (docs/templates/01-marble.md): Lustria in tracked caps for the title, headings, table heads and the + * amount due; "Now" (drawn in Jost, the bundled look-alike) for everything else. Jost sets about 12% narrower than Now, + * so every Now role carries the tracking that restores the Canva widths (0.10 to 0.13 em on top of the design's + * 0.04 em). Lustria ascent 0.955, descent 0.327; Jost 1.07 / 0.375. + */ +export const MARBLE_ROLES = defineRoles({ + /** "INVOICE" / "TAX INVOICE": 25.18 pt, tracked 0.192 em, shrunk to the room the stack has. */ + title: { face: L, weight: 400, size: 25.18, leading: 25.2, tracking: 0.192, caps: true, minSize: 13 }, + /** The vendor's name at the header's left (the design leaves it empty). */ + brand: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true, minSize: 9 }, + meta: { face: N, weight: 400, size: 11.55, leading: 17.5, tracking: 0.105, minSize: 8 }, + /** BILL TO:, PAYABLE TO:, SUPPLIER:. */ + heading: { face: L, weight: 400, size: 12.65, leading: 18.2, tracking: 0.11, caps: true }, + body: { face: N, weight: 400, size: 11.55, leading: 18.2, tracking: 0.105, minSize: 8 }, + tableHead: { face: L, weight: 400, size: 11.68, leading: 16, tracking: 0.192, caps: true }, + tableBody: { face: N, weight: 400, size: 10.57, leading: 17.4, tracking: 0.102 }, + tableSub: { face: N, weight: 400, size: 8.5, leading: 11.5, tracking: 0.1 }, + totalLabel: { face: L, weight: 400, size: 9, leading: 16, tracking: 0.192, caps: true }, + totalValue: { face: N, weight: 400, size: 10.57, leading: 16, tracking: 0.102 }, + /** AMOUNT DUE: 14.6 pt, tracked 0.192 em. */ + due: { face: L, weight: 400, size: 14.6, leading: 20, tracking: 0.192, caps: true, minSize: 10 }, + small: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13 }, + smallLabel: { face: L, weight: 400, size: 9, leading: 15.3, tracking: 0.192, caps: true }, + signName: { face: N, weight: 400, size: 10.57, leading: 15, tracking: 0.102 }, + signDesignation: { face: N, weight: 400, size: 9.6, leading: 14, tracking: 0.13 }, + fine: { face: N, weight: 400, size: 8, leading: 11, tracking: 0.1 }, + /** The footer band's contact lines. */ + contact: { face: N, weight: 400, size: 9.6, leading: 15.3, tracking: 0.13, minSize: 7 }, + footer: { face: N, weight: 400, size: 7 }, +}); + +export type MarbleRoles = typeof MARBLE_ROLES; + +/** + * Canva A4 numbers: the rules and zebra bands start at x 31.5 and end at 549.8 (the text is inset 7 and 9.5 pt), + * the right-hand column ends at 549.0; y values are the design's distances turned into flow gaps. + */ +export interface MarbleVariant { + roles: MarbleRoles; + ink: string; + margins: Sides; + marginFloor: Sides; + /** Text inset inside the rules, left (parties) and right. */ + inset: { left: number; right: number }; + /** The top band's height and the bottom band's (A4 and Letter share the bottom band; the footer text is capped by lines). */ + bands: { top: number; bottom: number }; + head: { + /** Title line box below the header's top (the logo slot's top). */ + titleTop: number; + /** Title box bottom to the first line of the stack. */ + titleGap: number; + /** The widest share of the content width the right-hand stack may take. */ + maxShare: number; + minStackW: number; + /** Vendor name top below the header's top when there is no logo; below the logo box otherwise. */ + nameTop: number; + nameGap: number; + minNameW: number; + /** Space between the left column and the stack at its narrowest. */ + colGap: number; + /** The logo slot's box height. */ + logoH: number; + }; + rule: number; + parties: { colA: number; colB: number; headingGap: number }; + gaps: { + /** Header bottom to the first hairline. */ + headRule: GapRange; + /** Hairline to the first heading, and the last info line to the second hairline. */ + infoTop: GapRange; + infoBottom: GapRange; + /** Second hairline to the table heading. */ + band: GapRange; + /** Last table row to the totals. */ + tail: GapRange; + /** The totals (or the notes) to the signature band. */ + close: GapRange; + }; + table: { + /** Heading box bottom to the first zebra band. */ + headGap: number; + descInset: number; + headDescInset: number; + cols: { qty: number; rate: number; amount: number }; + numPad: number; + /** Right padding of the rate column (the unit is part of the rate, so it gets less than the amounts). */ + ratePad: number; + padY: (rows: number) => number; + padYMin: number; + }; + totals: { boxW: number; rowGap: number; dueGap: number; rightInset: number; leftGap: number; wordsMax: number }; + closing: { signW: number; gap: number; notesBesideMax: number; signatureImageH: number }; + footer: { + /** Footer text left edge, from the page's left edge (A4 Canva: x 40.7) and the second column's left (x 197.8). */ + col1: number; + col2: number; + colGap: number; + col1MaxW: number; + /** Band top to the first line box. */ + top: number; + /** Lines the first column may take (name plus address) on A4 and Letter. */ + maxLines: number; + }; +} + +export const MARBLE_VARIANT: MarbleVariant = { + roles: MARBLE_ROLES, + ink: INK, + margins: { top: 86, right: 45.8, bottom: 24, left: 31.5 }, + marginFloor: { top: 86, right: 31.5, bottom: 24, left: 31.5 }, + inset: { left: 7, right: 0.8 }, + bands: { top: 64, bottom: 130 }, + head: { titleTop: 14.85, titleGap: 3.7, maxShare: 0.7, minStackW: 170, nameTop: 26.8, nameGap: 6, minNameW: 90, colGap: 24, logoH: 50 }, + rule: 0.75, + parties: { colA: 172.9, colB: 168.6, headingGap: 2.6 }, + gaps: { + headRule: { nominal: 36.8, min: 14 }, + infoTop: { nominal: 16.05, min: 10 }, + infoBottom: { nominal: 11.56, min: 8 }, + band: { nominal: 50.2, min: 22 }, + tail: { nominal: 14, min: 6 }, + close: { nominal: 14, min: 8 }, + }, + table: { + headGap: 8.85, + descInset: 9.5, + headDescInset: 7.3, + cols: { qty: 84, rate: 72.9, amount: 74.9 }, + numPad: 9.5, + ratePad: 5, + padY: (rows) => (rows <= 8 ? 7.3 : rows <= 14 ? 6 : 4.5), + padYMin: 3.5, + }, + totals: { boxW: 230, rowGap: 0, dueGap: 10, rightInset: 14.1, leftGap: 16, wordsMax: 280 }, + closing: { signW: 170, gap: 24, notesBesideMax: 90, signatureImageH: 40 }, + footer: { col1: 40.7, col2: 197.8, colGap: 24, col1MaxW: 190, top: 24.7, maxLines: 5 }, +}; + +export const leading = (t: TypeToken) => t.leading as number; diff --git a/src/pdf/templates/monolith/monolith.test.tsx b/src/pdf/templates/monolith/monolith.test.tsx index f43fdb9..2a6376a 100644 --- a/src/pdf/templates/monolith/monolith.test.tsx +++ b/src/pdf/templates/monolith/monolith.test.tsx @@ -49,7 +49,7 @@ describe("registry, catalog and fonts", () => { 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(Object.keys(TEMPLATES).slice(-4, -2)).toEqual(["monolith", "cobalt-stripe"]); expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); }); diff --git a/src/pdf/templates/neutral/neutral.test.tsx b/src/pdf/templates/neutral/neutral.test.tsx index 963e889..be7609e 100644 --- a/src/pdf/templates/neutral/neutral.test.tsx +++ b/src/pdf/templates/neutral/neutral.test.tsx @@ -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", "monolith", "cobalt-stripe"]); + expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split", "monolith", "cobalt-stripe", "marble", "highlighter"]); expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES)); }); diff --git a/src/pdf/templates/northline/northline.test.tsx b/src/pdf/templates/northline/northline.test.tsx index b4e12bb..191af7b 100644 --- a/src/pdf/templates/northline/northline.test.tsx +++ b/src/pdf/templates/northline/northline.test.tsx @@ -62,9 +62,9 @@ describe("registry, catalog and fonts", () => { 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(-4, -2)).toEqual(["purple-pop", "citrus-split"]); + expect(Object.keys(TEMPLATES).slice(-6, -4)).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"]); + expect(TEMPLATE_CATALOG.filter((e) => e.logoOptIn).map((e) => e.id)).toEqual(["linea", "purple-pop", "marble", "highlighter"]); }); it("Gotham tokens carry the Montserrat size factor, and the licensed faces resolve to the bundled look-alikes", () => { diff --git a/src/pdf/templates/registry.ts b/src/pdf/templates/registry.ts index 70f0c4d..b8bb93c 100644 --- a/src/pdf/templates/registry.ts +++ b/src/pdf/templates/registry.ts @@ -8,6 +8,8 @@ import { tealSwoosh } from "./band/teal"; import { citrusSplit } from "./northline/citrus"; import { purplePop } from "./northline/purple"; import { cobaltStripe } from "./cobalt/cobalt"; +import { highlighter } from "./highlighter/highlighter"; +import { marble } from "./marble/marble"; import { monolith } from "./monolith/monolith"; import { monogram } from "./neutral/monogram"; import { serenity } from "./neutral/serenity"; @@ -26,6 +28,8 @@ export const TEMPLATES: Record<string, TemplateDefinition> = { "citrus-split": citrusSplit, monolith, "cobalt-stripe": cobaltStripe, + marble, + highlighter, }; export const DEFAULT_TEMPLATE_ID = "classic";