diff --git a/docs/templates/01-marble.md b/docs/templates/01-marble.md
index 9dc8386..8d561a6 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 reference 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 reference, 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 (reference 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 reference 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. INV/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 reference 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 b21c555..aa23c97 100644
--- a/docs/templates/02-highlighter.md
+++ b/docs/templates/02-highlighter.md
@@ -155,3 +155,19 @@ Voiced fields with no reference 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 reference 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 reference, right ours; `--numbers` prints the baselines). The reference 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 0000000..ce9c41d
Binary files /dev/null and b/public/fonts/lustria/Lustria-Regular.ttf differ
diff --git a/public/fonts/lustria/OFL.txt b/public/fonts/lustria/OFL.txt
new file mode 100644
index 0000000..b530060
--- /dev/null
+++ b/public/fonts/lustria/OFL.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2011, Matthew Desmond (http://www.madtype.com | mattdesmond@gmail.com),with Reserved Font Name Lustria.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/manifest.json b/public/fonts/manifest.json
index dd0d9a0..2e8f457 100644
--- a/public/fonts/manifest.json
+++ b/public/fonts/manifest.json
@@ -281,6 +281,29 @@
}
]
},
+ {
+ "family": "Lustria",
+ "folder": "lustria",
+ "license": "OFL-1.1",
+ "licenseFile": "OFL.txt",
+ "fonts": [
+ {
+ "file": "Lustria-Regular.ttf",
+ "weight": 400,
+ "style": "normal",
+ "sha256": "8b50753779d151674dcc74bdf9cdde1e788d8fb2b9ace8fb183a0def0f7361ce",
+ "fsType": 0,
+ "upm": 1000,
+ "ascent": 955,
+ "descent": -327,
+ "lineGap": 0,
+ "capHeight": 704,
+ "xHeight": 504,
+ "hasRupee": false,
+ "hasTabularNums": false
+ }
+ ]
+ },
{
"family": "Montserrat",
"folder": "montserrat",
diff --git a/scripts/templates/compare.mjs b/scripts/templates/compare.mjs
index acadcfd..0b81303 100644
--- a/scripts/templates/compare.mjs
+++ b/scripts/templates/compare.mjs
@@ -1,20 +1,25 @@
// Side-by-side review of the reference-based templates: the reference 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 (reference 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 (reference 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 reference 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 reference = 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 reference 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 reference 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 reference 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(reference, await rasterize(withLogo.result.bytes, 1, SCALE), `compare-${id}.png`);
const noLogo = await render(sample({ registered: false, logo: "none" }));
await side(reference, 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 reference 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}: reference (${a.length}) vs ours (${b.length}); baseline y, x`);
for (const it of a) console.log(` reference ${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 0adeba2..4ea0f5a 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 reference 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 435a061..c7695e2 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 reference 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 00708f1..49722b4 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 e24ff77..3fc8013 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 (
+
+
+
+
+ );
+}
+
+// --- 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 (
+ 0 ? f.rowGap : 0 }}>
+
+
+ {spec.label}
+
+
+
+
+
+ {spec.value}
+
+
+
+
+
+
+ );
+}
+
+function Fields({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const r = v.roles;
+ const c = model.client;
+ const f = v.fields;
+ return (
+
+ {plan.fields.specs.map((_, i) => (
+
+ ))}
+ {plan.fields.subH > 0 ? (
+
+
+ {c.addressLines.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ {c.poNumber ? {`PO: ${c.poNumber}`} : null}
+
+
+ ) : null}
+
+ );
+}
+
+function Payment({ ctx }: { ctx: Ctx }) {
+ const { model, v } = ctx;
+ const r = v.roles;
+ const lines = paymentLines(model);
+ if (lines.length === 0) return ;
+ const w = v.pay.w;
+ return (
+
+
+
+ Payment Method:
+
+ {lines.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+}
+
+// --- 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;
+ minH?: number;
+ children: ReactNode;
+}) {
+ const { plan, v } = ctx;
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+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 (
+
+ {children}
+ |
+ );
+}
+
+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 (
+
+ {cells.map((value, 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" ? (
+
+ {text}
+
+ ) : (
+
+ {text}
+
+ );
+ })}
+
+ ))}
+
+ );
+}
+
+function PadRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) {
+ const { plan, v } = ctx;
+ return (
+
+ {plan.columns.map((_, i) => (
+
+ ))}
+
+ );
+}
+
+/** 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 (
+
+
+
+ {plan.headerCells.map((text, i) => {
+ const last = i === plan.columns.length - 1;
+ return (
+ |
+
+ {text}
+
+ |
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function Table({ ctx }: { ctx: Ctx }) {
+ const { plan } = ctx;
+ const n = plan.rows.length;
+ return (
+
+
+
+ {plan.rows.map((_, i) => (
+
+ ))}
+ {Array.from({ length: plan.padRows }, (_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+// --- 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 (
+
+
+
+ {label}
+
+
+
+
+ {amount}
+
+
+
+ );
+}
+
+function TotalsBox({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const t = plan.tail;
+ return (
+
+
+ {t.stack.map((row, i) => (
+
+ ))}
+ } />
+
+
+
+ );
+}
+
+function Tail({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const t = plan.tail;
+ const r = v.roles;
+ const o = v.totals;
+ return (
+
+
+
+
+
+
+ {t.reverseH > 0 ? 0 ? REVERSE_GAP : 0 }} /> : null}
+
+
+
+
+
+ );
+}
+
+// --- 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 (
+
+
+
+
+ Notes:
+
+
+ {lines[0]}
+
+
+ {lines.slice(1).map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+
+ THANK YOU
+
+
+ {c.contacts.map((x, i) => (
+ 0 ? cl.contactGap : 0 }}>
+
+
+
+
+ {x.icon === "location" ? (
+
+ {x.text}
+
+ ) : (
+ // A phone number or e-mail is an identifier: one fitted line (shrunk, then split at "@ . / -"), never a wrapped one.
+
+ {x.text}
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
+
+/** 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 (
+
+
+ {Array.from({ length: c.ruledRows }, (_, i) => (
+
+ {!c.notesFlow && c.ruled[i] !== undefined ? (
+
+ {c.ruled[i]}
+
+ ) : null}
+
+ ))}
+
+
+
+ );
+}
+
+function Closing({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const cl = v.closing;
+ const c = plan.closing;
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {model.cancelled ? (
+
+ {CANCELLED_SENTENCE}
+
+ ) : null}
+
+
+ >
+ );
+}
+
+// --- 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 : (
+ <>
+
+
+
+
+ >
+ );
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {notesFlow}
+
+ >
+ );
+}
diff --git a/src/pdf/templates/highlighter/highlighter.test.tsx b/src/pdf/templates/highlighter/highlighter.test.tsx
new file mode 100644
index 0000000..b36c02c
--- /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>;
+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 reference 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 reference(size: "A4" | "LETTER" = "A4"): Promise<{ r: R; items: Array<{ str: string; x0: number; x1: number; bottom: number }> }> {
+ const fixture = buildFixture({ name: "reference", 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 {
+ 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 reference();
+ 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 reference();
+ 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: "Venkatachalam Longsurname 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("Test Vendor");
+ expect(stack(await renderFixture({ name: "o", items: 2, logo: "square" }, optIn()))).toContain("Test Vendor");
+ });
+});
+
+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 reference();
+ 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 reference();
+ 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 reference("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 reference("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 reference();
+ 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", "9000000000", "vendor@example.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:ABCDE1234F"]) expect(flat, word).toContain(word);
+ });
+
+ it("an unregistered invoice carries none of the GST slots", async () => {
+ const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: "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 = (props) => ;
+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 = { 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..7bb39f8
--- /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;
+
+/**
+ * Reference 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 ? : null;
+}
+
+function Rule({ ctx }: { ctx: Ctx }) {
+ return ;
+}
+
+/**
+ * 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 (
+
+ {children}
+
+ );
+ }
+ const sized = { ...role, size: fit.size };
+ const s = stepOf(sized);
+ return (
+
+ {children}
+
+ );
+}
+
+// --- 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 (
+
+ {logo ? (
+
+
+
+ ) : null}
+ {h.showName ? (
+
+
+
+ {model.supplier.name}
+
+
+
+ ) : null}
+
+ );
+}
+
+function Stack({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const h = plan.head;
+ const r = v.roles;
+ return (
+
+
+ {model.docTitle}
+
+
+
+ {h.lines.map((l, i) => (
+
+ {l.text}
+
+ ))}
+
+
+
+ );
+}
+
+function HeaderRow({ ctx }: { ctx: Ctx }) {
+ const { plan } = ctx;
+ return (
+
+
+
+
+ );
+}
+
+// --- parties ----------------------------------------------------------------------------------------------------
+
+function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) {
+ const { model, v } = ctx;
+ const r = v.roles;
+ const c = model.client;
+ return (
+
+
+
+ Bill to:
+
+
+ {c.name}
+
+ {c.addressLines.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ {c.poNumber ? {`PO: ${c.poNumber}`} : null}
+
+
+ );
+}
+
+function PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) {
+ const { model, v } = ctx;
+ const r = v.roles;
+ return (
+
+
+
+ Payable to:
+
+ {payableLines(model).map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+}
+
+function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) {
+ const { model, v } = ctx;
+ const r = v.roles;
+ const s = model.supplier;
+ return (
+
+
+
+ Supplier:
+
+
+ {s.pan ? (
+
+ {`PAN: ${s.pan}`}
+
+ ) : null}
+
+
+ );
+}
+
+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[] = [];
+ let k = 1;
+ if (plan.info.hasBank) blocks.push();
+ if (plan.info.hasIds) blocks.push();
+ const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w }));
+ return (
+
+
+ {blocks}
+
+
+ );
+}
+
+// --- 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 (
+
+
+ {label}
+
+
+ {amount}
+
+
+ );
+}
+
+function TotalsBox({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const t = plan.tail;
+ return (
+
+
+ {t.stack.map((row, i) => (
+
+ ))}
+ } />
+
+
+ {t.dueText}
+
+
+
+
+ );
+}
+
+function Tail({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const t = plan.tail;
+ const r = v.roles;
+ const o = v.totals;
+ return (
+
+
+
+
+
+
+ {t.reverseH > 0 ? 0 ? REVERSE_GAP : 0 }} /> : null}
+
+
+
+
+
+ );
+}
+
+// --- 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 (
+
+
+
+
+ Notes:
+
+
+ {lines[0]}
+
+
+ {lines.slice(1).map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ );
+}
+
+function Closing({ ctx }: { ctx: Ctx }) {
+ const { model, plan, v } = ctx;
+ const cl = v.closing;
+ const c = plan.closing;
+ const opts = signatureOptions(v);
+ return (
+ <>
+
+
+
+
+
+ {c.notesBeside ? : null}
+
+
+ {model.cancelled ? (
+
+ {CANCELLED_SENTENCE}
+
+ ) : null}
+
+
+ >
+ );
+}
+
+// --- 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. */}
+
+
+
+
+ >
+ );
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (i % 2 === 0 ? { backgroundColor: ZEBRA } : {})}
+ headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)}
+ headerTail={}
+ headerTailH={t.headGap}
+ tailH={plan.tailH}
+ tail={}
+ />
+
+ {notesFlow}
+
+ >
+ );
+}
diff --git a/src/pdf/templates/marble/marble.test.tsx b/src/pdf/templates/marble/marble.test.tsx
new file mode 100644
index 0000000..86be38b
--- /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>;
+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 reference 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 {
+ 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, "Sample 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: ABCDE1234F"]));
+ 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, "Sample 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) === "SampleP" && 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, "9000000000")[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: "Sample.longsurname.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 ? (
+
+ {l.text}
+
+ ) : (
+
+ {l.text}
+
+ ),
+ )}
+ >
+ ),
+ });
+ 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) => (
+
+ {t}
+
+ ))}
+ >
+ ),
+ });
+ }
+ return items;
+}
+
+const MarbleLayout: ComponentType = (props) => ;
+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..e893666
--- /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 reference 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;
+
+/**
+ * Reference 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 reference: 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 628e569..f87aca3 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 f22f641..b0127cc 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 cde45d0..5a9a3bf 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 = {
"citrus-split": citrusSplit,
monolith,
"cobalt-stripe": cobaltStripe,
+ marble,
+ highlighter,
};
export const DEFAULT_TEMPLATE_ID = "classic";