Add the Northline template family: Purple Pop and Citrus Split

Purple Pop (cream page, purple pills, asterisk and dome decor, opt-in
logo) and Citrus Split (grey split panel, halftone chevrons, icon
contacts, logo in the measured slot) are rebuilt from the Canva PDFs on
the engine with A4/Letter support, GST slots and zero audit errors. Adds
static DM Sans, Jost and Poppins 800 as look-alikes for the commercial
fonts. The picker now lists ten templates.
This commit is contained in:
2026-10-04 09:56:56 +05:30
parent c95514ee8d
commit d82515feee
40 changed files with 3033 additions and 56 deletions
+19
View File
@@ -216,3 +216,22 @@ US Letter is 612 × 792.
Sibling of 13.pdf. They are from the same Canva family, with the same placeholder data ('Northline Group', 'Amy Fletcher', 'Intelligent Bank'), the same DM Sans plus Gotham type system, the same raster 30% shadow effects, and the same SERVICE/QTY/RATE/AMOUNT table with SUBTOTAL/TAX/TOTAL.
The composition is different: pills and asterisks on cream here, versus a split grey column on blush in 13. Keep both and share the data-mapping code. Not similar to 1–11, format.pdf or the in-app template.
## Implementation notes
Built as `src/pdf/templates/northline/` (the family component shared with Citrus Split: `Layout.tsx` dispatches to `PurpleLayout.tsx`, the plans live in `plan.ts`, tokens in `purple.tsx` and `variant.ts`) on the decor primitives in `src/pdf/decor/` (`Asterisk`, `Underlay`, `CircledIcon`). Measured against `12.pdf` with `npm run templates:compare -- purple-pop` (`/tmp/invoice-c5/compare-purple-pop.png`, left Canva, right ours; `--numbers` prints both sets of baselines).
**Matches the design (measured, A4):** wordmark x 238.8 and 291.6 pt wide (Canva 238.2 / 292.2), right edge on the design's 530.4; INVOICE NO pill right edge 530.9, grey, text tracked 0.13 em; client label, pill and lines at x 67.1; AMOUNT DUE at 71.8; table pills, 46.6 pt header (radius 23.3), 32.7 pt rows (radius 16.35), alternating #EDEDE8 / #FFD80B, flush; SUBTOTAL / TOTAL labels at x 192 and values ending at 522 / 529; the TOTAL pill 369.4 x 41 at x 173.4; rule 1 at 64.8..533.4; footer columns at x 67.4 / 218.5 / 380.7.
**Intentional deviations, and why:**
- **Fonts.** Open Sauce One is not bundled: the wordmark is Poppins 800 at -0.08 em. Gotham is Montserrat with every size multiplied by 0.96 at token definition (`gotham()` in `variant.ts`; a real Gotham imported over the licensed name would need the tokens at factor 1). DM Sans is bundled 400/700 and has the rupee sign; Montserrat and Poppins carry it too, so no amount depends on the Plex fallback.
- **Wordmark and 'Tax'.** The wordmark always reads "Invoice". A tax invoice adds a tracked 14 pt "TAX" above it (right-aligned), not a 61 pt "Tax Invoice". Negative tracking leaves the last glyph's ink 7.5 pt past the line end, so the wordmark Text carries that much right padding; the ink then ends on the design's edge and the overflow audit stays quiet.
- **Vertical rhythm.** The design is generous and a Voiced invoice has more lines (GSTIN, place of supply, tax rows, amount in words, signature, bank lines), so the gaps between blocks are elastic (client 35 to 10, rules 18 to 6, amount band 18 to 6, table 20 to 8, totals 14 to 6; Letter gives back its 49.9 pt from them first). A one-page invoice of two or three plain items fits A4; a tax invoice with a signature and notes puts items and totals on page 1 and the closing block (terms, signature, footer) alone at the foot of page 2, which is how the other families behave too. When the gaps can give back the whole overflow only to keep the totals with the items, the plan does exactly that.
- **Table width.** The narrow table beside the asterisk (x 176.9, the design) is used only for up to three items whose descriptions take at most two lines in its ~100 pt column; everything else uses the full width (x 59.5..539.4) on every page and drops the asterisk, as the spec recommends. The 71.5 pt asterisk is page 1 only and a child of `FirstPageHeader`, positioned from the plan; the dome sits in the left gutter of the totals block and travels with it.
- **Pills.** Header and rows are Views with a flat fill and a radius, laid out through `TableRows`'s `pill` option (cells inset 15 pt inside the pill, the pill indented from the container's left edge). No opacity, so text is never faded.
- **Watermarks.** The two pale asterisks are `decor.underlay` items (fixed, painted before the flow, anchored to the page corners; the off-page part is cut away by the Svg's viewBox so the box always lies on the page, Letter included). No keep-outs: nothing printed over them needs a clear ground. Canva's drop-shadow rasters are dropped.
- **Dates.** "INVOICE DATE : ..." and "DUE DATE : ..." are explicit right-aligned lines (a PO line is added when present); the size shrinks to 10 pt before the amount (32.74 pt, shrinking to 20) loses its 150 pt.
- **Supplier lines.** GSTIN and PAN sit under the vendor name (9 pt, through `Slot.Gstin`); phone, e-mail and address are footer columns B and C with icons (solid discs for phone and e-mail, a ring for the address). The website column has no Voiced data and is dropped; UPI is a bank line.
- **Signature and terms.** The signature takes the 29 pt asterisk's place at the right of the terms row (190 pt wide, so the computer-generated line fits in one line). Terms are one bullet per note line; with more than the signature's height they flow as their own block before the closing block, each bullet unbreakable and the label held with the first.
- **Logo.** No Canva placeholder: opt-in (picker toggle, `prefs.logo.show`), off by default, in the area left of the vendor name (at most 200 x 34 from `slots.optin.ts`). A mark shifts the name right; a wordmark replaces it. The no-logo fallback is the vendor name in DM Sans Bold.
- **Margins.** Top 70 (the design's pill starts at 77; 7 pt go to the page-2+ continuation band), bottom 40 plus the footer reserve, 54.8 left, 52.48 right; floors 36 / 36 / 24 / 36.
+18
View File
@@ -182,3 +182,21 @@ US Letter is 612 × 792.
## Similarity to other templates
Sibling of 12.pdf: the same Canva family, data and type system with a different layout. It is distinct from everything else in the set and from format.pdf and the in-app template; the split grey column is unique to it. It can share the 12/13 data-mapping code, though its pill and label components differ.
## Implementation notes
Built as `src/pdf/templates/northline/` (the family component shared with Purple Pop: `Layout.tsx` dispatches to `CitrusLayout.tsx`, the plans live in `plan.ts`, tokens in `citrus.tsx` and `variant.ts`) on the decor primitives in `src/pdf/decor/` (`Underlay`, `Halftone`, `CircledIcon`). Measured against `13.pdf` with `npm run templates:compare -- citrus-split` (`/tmp/invoice-c5/compare-citrus-split.png`, left Canva, right ours).
**Matches the design (measured, A4):** INVOICE at x 73.3 with its baseline at 109.3 (Canva 109.7); the stripe at x 298.8..535.6; meta labels at x 87.9 / 174.1 / 315.5 / 423.7; table header 47.2 pt, orange SERVICE cell x 84.2..298.6 with the text at 115.9, numeric headings on white; description text at x 99.9 (Canva 100.1); SUBTOTAL / TAX labels at x 320.9 with values ending at 505; TOTAL bar 235 x 47.2 with TOTAL at 316.5 and its value ending at 505; BILLED TO block at x 85.6 beside the totals; PAYMENT METHOD / TERMS at x 81.7, bullets at 89.2 / 102.1.
**Intentional deviations, and why:**
- **Fonts.** Now Bold is drawn in Jost 700 at -0.081 em (Jost has no rupee sign and is used for that one word only; 'INVOICE' comes out 176 pt wide against Canva's 189.7). Gotham is Montserrat at 0.96 of the design size (`gotham()`); DM Sans for labels, names and the words line.
- **The stripe.** A full-height fixed underlay on every page (not Canva's 547 pt rectangle plus a bottom block), anchored to the right margin: `x = page width - right margin - 236.8`. On Letter the extra 16.5 pt go to the left column; Narrow and Normal margins move it with the margin. The three halftone fields are anchored to their page corners and edges, drawn as Svg circles over the stripe, each dot already in the gradient's colour at its x (no gradient object), at 9% opacity.
- **Table.** The three numeric columns add up to exactly 236.8 pt (66.8 / 80 / 90), so every number is printed on the grey whatever the data; the description column takes the rest (214.6 on A4, 231 on Letter). RATE and AMOUNT are right-aligned (the design centres them), the rate's unit goes under it when the short form does not fit, HSN/SAC is a 9 pt line under the description. Zebra rows span the full row width and only show on the left.
- **BILLED TO.** Beside the totals (the design) when the whole invoice fits one page; above the table, under the title, when it does not, so page 1 always names the client. `planCitrus` decides from the nominal height against the page and the gaps' slack.
- **Totals.** 12.5 pt labels (Gotham 700, drawn 0.96) and values; each row is one line (the value column takes only the width the amounts need, so 'TAXABLE VALUE' and 'UTGST @ 14%' never wrap). Amount in words sits under the TOTAL bar in the design's tagline slot, right-aligned; the reverse-charge line follows it.
- **Contacts.** Phone, e-mail and address with icons (a solid orange disc for the phone, rings for e-mail and address), right-aligned with the icons at the stripe's right edge; GSTIN and PAN replace the web line, without an icon. The signature goes under them in the panel column (the design's empty bottom block).
- **Row pitch.** 12.15 pt padding gives the design's 39.3 pt row; on Letter (and when an invoice is a little over a page) the row padding is the elastic part, down to 8 pt, together with the gaps.
- **Header.** Logo in the measured slot (a mark keeps the name, a wordmark replaces the lockup), the name 2+ lines at 17.13 pt; with no logo the name stands at the logo's x, vertically centred on the title. A tax invoice puts a tracked orange "TAX" above the wordmark.
- **Margins.** Top 58, bottom 40 plus the footer reserve, 73.3 left, 59.68 right; floors 36 / 36 / 24 / 36. The standard footer sits over the bottom-left halftone and the stripe.
- **Terms.** Beside the bank lines in the closing block; more than 110 pt of them flow as their own block across the width, each bullet unbreakable.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
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:
https://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.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type)
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.
+91
View File
@@ -1,6 +1,44 @@
{
"version": 1,
"families": [
{
"family": "DM Sans",
"folder": "dm-sans",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "DMSans-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "fae5cfcefb70eaa3bb8fe15d04bcc182c3684af679441d09149c4e4b28f8b909",
"fsType": 0,
"upm": 1000,
"ascent": 992,
"descent": -310,
"lineGap": 0,
"capHeight": 700,
"xHeight": 504,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "DMSans-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "f43632dacd8210c8b546cfe7746a4996811c617e6d3d93eea28698016016c1fa",
"fsType": 0,
"upm": 1000,
"ascent": 992,
"descent": -310,
"lineGap": 0,
"capHeight": 700,
"xHeight": 504,
"hasRupee": true,
"hasTabularNums": false
}
]
},
{
"family": "IBM Plex Mono",
"folder": "ibm-plex-mono",
@@ -205,6 +243,44 @@
}
]
},
{
"family": "Jost",
"folder": "jost",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "Jost-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "8b458be71d5236e58ee7a9660ef6197116ea78c4120fae94305674f5029423e6",
"fsType": 0,
"upm": 1000,
"ascent": 1070,
"descent": -375,
"lineGap": 0,
"capHeight": 700,
"xHeight": 460,
"hasRupee": false,
"hasTabularNums": true
},
{
"file": "Jost-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "1c0db6c9f209c5f1f9ebfddee69dcb7c13b682a3f0a21c6ec9797ae1309ddfa1",
"fsType": 0,
"upm": 1000,
"ascent": 1070,
"descent": -375,
"lineGap": 0,
"capHeight": 700,
"xHeight": 460,
"hasRupee": false,
"hasTabularNums": true
}
]
},
{
"family": "Montserrat",
"folder": "montserrat",
@@ -467,6 +543,21 @@
"xHeight": 558,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "Poppins-ExtraBold.ttf",
"weight": 800,
"style": "normal",
"sha256": "f2ab17c1a63a0ecc12c2461848fc8a469395e3cd2d641803e889c643d9f958e1",
"fsType": 0,
"upm": 1000,
"ascent": 1050,
"descent": -350,
"lineGap": 100,
"capHeight": 709,
"xHeight": 561,
"hasRupee": true,
"hasTabularNums": false
}
]
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

+11 -8
View File
@@ -1,20 +1,20 @@
// Side-by-side review of the Canva-based templates: the Canva original (left) against our render (right), plus
// the multi-page, Letter, long-text and logo-variant samples. Run: npm run templates:compare [-- crimson-grid slate-band]
// Output: /tmp/invoice-c4/ (override with --out=DIR). With --numbers it also prints text baselines (Canva vs ours) for tuning.
// the multi-page, Letter, long-text and logo-variant samples. Run: npm run templates:compare [-- purple-pop citrus-split]
// Output: /tmp/invoice-c5/ (override with --out=DIR). With --numbers it also prints text baselines (Canva vs ours) for tuning.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { CANVA_DIR, loadCanvas, loadRenderer, rasterize, textItems } from "./lib.mjs";
const argv = process.argv.slice(2);
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c4";
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c5";
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 };
const ROWS = { linea: 7, "crimson-grid": 3, "tangerine-ledger": 3, "slate-band": 5, "teal-swoosh": 5, "purple-pop": 3, "citrus-split": 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 : ["crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh"];
const targets = ids.length ? ids : ["purple-pop", "citrus-split"];
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";
@@ -45,10 +45,13 @@ async function main() {
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") => {
const prefs = r.prefsFor(size, { templateId: id });
// 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 } } : {}) });
return r.renderFixture(spec, prefs);
};
// Templates with no Canva placeholder show a logo only when the user turns it on.
const OPT_IN = ["linea", "purple-pop"];
const withLogo = await render(sample({ logo: "real" }));
await side(canva, await rasterize(withLogo.result.bytes, 1, SCALE), `compare-${id}.png`);
const noLogo = await render(sample({ registered: false, logo: "none" }));
@@ -61,8 +64,8 @@ async function main() {
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)}`);
}
if (id === "linea") {
// The opt-in logo band (off by default so Linea keeps matching format.pdf).
if (OPT_IN.includes(id)) {
// The opt-in logo (off by default: Linea keeps matching format.pdf, Purple Pop has no placeholder).
const shown = await r.renderFixture(sample({ logo: "real" }), r.prefsFor("A4", { templateId: id, logo: { show: true } }));
writeFileSync(path.join(OUT, `${id}-A4-logo-optin.png`), (await rasterize(shown.result.bytes, 1, SCALE)).toBuffer("image/png"));
}
+4
View File
@@ -7,6 +7,7 @@ import type { TemplateDefinition } from "./templates/contract";
import { AvailableWidthProvider } from "./blocks/AvailableWidth";
import type { DecorItem } from "./blocks/FirstPageHeader";
import { PageChrome } from "./blocks/PageChrome";
import { Underlay } from "./decor/Underlay";
/** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */
export const FOOTER_RESERVE = 24;
@@ -32,6 +33,7 @@ export function InvoicePage({ frame, model, template, children }: InvoicePagePro
const ruleY = frame.page.height - marginBottom - chromeHeight(chrome.role ?? "footer") - 6.6;
const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`;
const items: DecorItem[] = [...(decor.everyPage?.(frame, model) ?? [])];
const under = decor.underlay?.(frame, model) ?? [];
if (chrome.rule) {
items.push({ x: pad.left, y: ruleY, width: frame.contentWidth, height: 0.6, children: <View style={{ height: 0.6, backgroundColor: chrome.rule }} /> });
}
@@ -56,6 +58,8 @@ export function InvoicePage({ frame, model, template, children }: InvoicePagePro
...(decor.background ? { backgroundColor: decor.background } : {}),
}}
>
{/* Painted first, so everything in the flow sits on top of it. */}
<Underlay items={under} />
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
<PageChrome
frame={chromeFrame}
+53 -34
View File
@@ -1,6 +1,6 @@
import { View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { colsDetailed, type ColSpec } from "../engine/columns";
import { colsDetailed, pinned, type ColSpec } from "../engine/columns";
import { PlanError } from "../engine/errors";
import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "../engine/ids";
import { drawnText, type Measurer } from "../engine/measure";
@@ -37,6 +37,8 @@ export interface TableLook {
headBorder?: number;
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
padRight?: number[];
/** Extra left padding per column, pt (a header or body text that starts inside a filled cell). Counted in widths. */
padLeft?: number[];
faceOverrides?: Readonly<Record<string, string>>;
/** Height of the `headerTail` TableRows draws under the header row (a rule and its spacing); counted in the header height. */
headerTailH?: number;
@@ -108,7 +110,7 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
for (const r of rows) if (r.length !== columns.length) throw new PlanError(`row has ${r.length} cells for ${columns.length} columns`, "invalid-plan");
const { widths } = colsDetailed(width, columns, gap);
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0) - (input.padLeft?.[i] ?? 0)));
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[], py = padY, bd = border) => {
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * py + bd };
@@ -144,10 +146,13 @@ export interface TableRowsProps extends TableLook {
/** Content after the last row, inside the table container (the totals). */
tail?: ReactNode;
headerStyle?: Record<string, unknown>;
rowStyle?: Record<string, unknown>;
/** Style of each body row; a function gets the row index (alternating fills). */
rowStyle?: Record<string, unknown> | ((index: number) => Record<string, unknown>);
/** Style of one header cell by column (a filled cell beside an unfilled one). The cell is as tall as the header row only while the header is one line. */
headCellStyle?: (column: number) => Record<string, unknown> | undefined;
borderColor?: string;
/** Text colour of the header cells. */
headColor?: string;
/** Text colour of the header cells, or per column. */
headColor?: string | ((column: number) => string | undefined);
/**
* Drawn under the header row inside the same fixed group, so it repeats with the header on every page
* (a rule and the gap below it: nothing under a repeated header may depend on a first-page-only margin).
@@ -167,6 +172,12 @@ export interface TableRowsProps extends TableLook {
* width. The header group is fixed (it repeats), so the bleed passes the safe-zone audit.
*/
bleed?: { left: number; width: number; bg: string };
/**
* Rounded "pill" rows (Purple Pop): `width` is the pill's outer width, the cells are laid out `rowInset` inside
* it on both sides, and the pill itself (rowStyle / headerStyle carry its fill and radius) moves `indent` right
* of the container's left edge. Plan with planItemsTable at width - 2 * rowInset.
*/
pill?: { indent: number; inset: number };
}
/** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */
@@ -179,19 +190,20 @@ export function gridPadRight(columns: number, ruleWidth: number): number[] {
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
* the repeated header is positioned against the container's top.
*/
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, grid, bleed, ...look }: TableRowsProps) {
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, headCellStyle, borderColor, headColor, headerTail, grid, bleed, pill, ...look }: TableRowsProps) {
const padX = look.padX ?? 0;
const padY = look.padY ?? 4;
const headPadY = look.headPadY ?? padY;
const border = look.border ?? 0;
const headBorder = look.headBorder ?? border;
const { widths } = colsDetailed(width, columns, gap);
const { widths } = colsDetailed(width - 2 * (pill?.inset ?? 0), columns, gap);
const cellStyle = (extra?: Record<string, unknown>, bd = border) => ({
...(bd ? { borderBottomWidth: bd, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
...extra,
});
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string, isHeader = false) =>
const cells = (role: RoleName | TypeToken, values: TableCellValue[], colorOf?: string | ((column: number) => string | undefined), isHeader = false) =>
values.map((v, i) => {
const color = typeof colorOf === "function" ? colorOf(i) : colorOf;
const edge = grid?.width ?? 0;
const last = i === values.length - 1;
const inner = isHeader ? (grid?.headerColor ?? grid?.color) : grid?.color;
@@ -200,11 +212,11 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
return (
<Cell
key={i}
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX + (look.padLeft?.[i] ?? 0), right: padX + (look.padRight?.[i] ?? 0) }}
borderLeft={grid && i === 0 ? edge : 0}
borderRight={edge}
style={
grid
style={{
...(grid
? {
flexGrow: 1,
flexBasis: "auto",
@@ -212,11 +224,12 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
borderRightColor: last ? outer : inner,
...(isHeader ? { borderTopWidth: edge, borderTopColor: outer } : {}),
}
: undefined
}
: {}),
...(isHeader ? headCellStyle?.(i) : {}),
}}
>
{cellParts(v).map((part, li) => (
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - (look.padLeft?.[i] ?? 0) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
{partText(part)}
</Txt>
))}
@@ -224,6 +237,21 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
);
});
const rowAlign = grid ? ("stretch" as const) : undefined;
const inset = pill?.inset ?? 0;
const cellsWidth = width - 2 * inset;
/** One table line: a plain Row, or for pills an outer box (the fill) around a Row of the inner width. */
const line = (key: string | number, id: string, flags: ReturnType<typeof pdfFlags>, style: Record<string, unknown>, children: ReactNode) =>
pill ? (
<View key={key} id={id} {...flags} style={{ ...pinned(width), marginLeft: pill.indent, paddingLeft: inset, paddingRight: inset, ...style } as never}>
<Row widths={columns} gap={gap} width={cellsWidth} align={rowAlign}>
{children}
</Row>
</View>
) : (
<Row key={key} id={id} widths={columns} gap={gap} width={width} align={rowAlign} {...flags} style={style}>
{children}
</Row>
);
return (
<AvailableWidthProvider width={width}>
@@ -233,30 +261,21 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
{...pdfFlags({ fixed: true })}
style={bleed ? { marginLeft: -bleed.left, width: bleed.width, minWidth: bleed.width, maxWidth: bleed.width, paddingLeft: bleed.left, backgroundColor: bleed.bg } : undefined}
>
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} style={cellStyle(headerStyle, headBorder)}>
{cells(roles.head, header, headColor, true)}
</Row>
{line("head", TABLE_HEAD_ID, {}, cellStyle(headerStyle, headBorder), cells(roles.head, header, headColor, true))}
{headerTail}
</View>
) : (
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} fixed style={cellStyle(headerStyle, headBorder)}>
{cells(roles.head, header, headColor, true)}
</Row>
line("head", TABLE_HEAD_ID, pdfFlags({ fixed: true }), cellStyle(headerStyle, headBorder), cells(roles.head, header, headColor, true))
)}
{rows.map((r, i) =>
line(
i,
`${TABLE_ROW_PREFIX}${i}`,
pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined }),
cellStyle(typeof rowStyle === "function" ? rowStyle(i) : rowStyle),
cells(roles.body, r),
),
)}
{rows.map((r, i) => (
<Row
key={i}
id={`${TABLE_ROW_PREFIX}${i}`}
widths={columns}
gap={gap}
width={width}
align={rowAlign}
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
style={cellStyle(rowStyle)}
>
{cells(roles.body, r)}
</Row>
))}
{tail}
</View>
</AvailableWidthProvider>
+8 -3
View File
@@ -103,15 +103,17 @@ export interface AmountInWordsProps {
/** Without it the label and the words are one italic paragraph (Classic); with it the label is its own line in this role. */
labelRole?: Role;
color?: string;
/** Text alignment; default left (Classic's italic paragraph is always left). */
align?: "left" | "right";
}
function AmountInWords({ model, role = "body", style, labelRole, color }: AmountInWordsProps) {
function AmountInWords({ model, role = "body", style, labelRole, color, align }: AmountInWordsProps) {
if (!model.amountInWords) return null;
if (labelRole) {
return (
<View id={SLOT_ID.amountInWords} style={style as never}>
<SafeTxt role={labelRole} color={color} style={{ marginBottom: 2 }}>Amount in words:</SafeTxt>
<SafeTxt role={role} color={color}>{model.amountInWords}</SafeTxt>
<SafeTxt role={labelRole} color={color} align={align} style={{ marginBottom: 2 }}>Amount in words:</SafeTxt>
<SafeTxt role={role} color={color} align={align}>{model.amountInWords}</SafeTxt>
</View>
);
}
@@ -139,6 +141,9 @@ export const slotHeights = {
/** Lines the place-of-supply slot prints at `width`. */
placeOfSupplyLines: (model: RenderModel, role: Role, width: number): number =>
model.client.posLabel ? textLines(role, `Place of Supply: ${model.client.posLabel}`, width) : 0,
/** The tax rows' label and amount texts as TaxRows prints them (pass the same `format`), for sizing the label and amount columns. */
taxRowTexts: (model: RenderModel, format?: (label: string, rateText: string | undefined) => string): Array<{ label: string; amount: string }> =>
model.totals.taxRows.map((r) => ({ label: (format ?? ((l, rate) => (rate ? `${l} (${rate})` : l)))(r.label, r.rateText), amount: r.amountText })),
/** Whether the item has an HSN/SAC value to print (a code, not the "—" placeholder). */
hasHsn: (value: string): boolean => value !== "—",
/** Number of tax rows (each is one body line plus the template's row padding). */
+78
View File
@@ -0,0 +1,78 @@
import { Path, Svg } from "@react-pdf/renderer";
import type { DecorItem } from "../blocks/FirstPageHeader";
import type { PageFrame } from "../engine/geometry";
/*
* Purple Pop's art, measured on the Canva A4 page (docs/templates/12-purple-pop.md): an 8-arm asterisk normalised
* to a 0-100 box (every arm is 16.5% of the size), a yellow dome, and the huge pale watermark asterisks.
*/
export const ASTERISK_PATH =
"M100 41.7H69.9L91.1 20.6 79.7 8.9 58.3 30.1V0H41.7V30.1L20.6 8.9 8.9 20.6 30.1 41.7H0V58.3H30.1L8.9 79.4 20.6 91.1 41.7 69.9V100H58.3V69.9L79.4 91.1 91.1 79.4 69.9 58.3H100Z";
/** The yellow dome in A4 page coordinates; its box is DOME_BOX. */
export const DOME_PATH =
"M89.7,565.3 C107.7,565.3 122.3,579.9 122.3,597.9 L103.3,597.9 C114.5,603.1 122.3,614.4 122.3,627.6 L57.0,627.6 C57.0,614.4 64.8,603.1 76.1,597.9 L57.0,597.9 C57.0,579.9 71.7,565.3 89.7,565.3 Z";
export const DOME_BOX = { x: 57.0, y: 565.3, w: 65.3, h: 62.3 } as const;
/** A solid asterisk of side `size`, in the flow. */
export function Asterisk({ size, color }: { size: number; color: string }) {
return (
<Svg width={size} height={size} viewBox="0 0 100 100">
<Path d={ASTERISK_PATH} fill={color} />
</Svg>
);
}
/** The dome at its Canva size scaled so it is `width` wide. */
export function Dome({ width, color }: { width: number; color: string }) {
const k = width / DOME_BOX.w;
return (
<Svg width={width} height={DOME_BOX.h * k} viewBox={`${DOME_BOX.x} ${DOME_BOX.y} ${DOME_BOX.w} ${DOME_BOX.h}`}>
<Path d={DOME_PATH} fill={color} fillRule="evenodd" />
</Svg>
);
}
/** The two pale watermarks: 352.4 pt asterisks, one hanging off the top right and one off the bottom left. */
export const WATERMARK = { size: 352.4, topRight: { overhangRight: 12.4, overhangTop: 51.4 }, bottomLeft: { overhangLeft: 58, overhangBottom: 21.55 } } as const;
export type WatermarkCorner = "top-right" | "bottom-left";
/**
* A watermark as a decor item anchored to its page corner. The part that falls off the page is cut away
* with the Svg's viewBox rather than drawn outside the page, so the box always lies on the page (on Letter
* too, where the corner moves with the page).
*/
export function asteriskWatermark(frame: PageFrame, corner: WatermarkCorner, color: string): DecorItem {
const { width: pw, height: ph } = frame.page;
const s = WATERMARK.size;
const pct = (v: number) => (v / s) * 100;
if (corner === "top-right") {
const w = s - WATERMARK.topRight.overhangRight;
const h = s - WATERMARK.topRight.overhangTop;
return {
x: pw - w,
y: 0,
width: w,
height: h,
children: (
<Svg width={w} height={h} viewBox={`${pct(WATERMARK.topRight.overhangRight)} ${pct(WATERMARK.topRight.overhangTop)} ${pct(w)} ${pct(h)}`}>
<Path d={ASTERISK_PATH} fill={color} />
</Svg>
),
};
}
const w = s - WATERMARK.bottomLeft.overhangLeft;
const h = s - WATERMARK.bottomLeft.overhangBottom;
return {
x: 0,
y: ph - h,
width: w,
height: h,
children: (
<Svg width={w} height={h} viewBox={`${pct(WATERMARK.bottomLeft.overhangLeft)} 0 ${pct(w)} ${pct(h)}`}>
<Path d={ASTERISK_PATH} fill={color} />
</Svg>
),
};
}
+6 -3
View File
@@ -25,13 +25,16 @@ export interface CircledIconProps extends ContactIconProps {
ring: number;
/** The icon's share of the diameter, 0..1. */
glyph?: number;
/** A filled disc with the glyph knocked out in `glyphColor` (default white) instead of a ring. */
solid?: boolean;
glyphColor?: string;
}
/**
* An icon inside a ring (Slate Band's footer): one static Svg, ring and glyph together. The glyph sits in a
* translated and scaled static group (verified to work as a static child, unlike inside a render prop).
*/
export function CircledIcon({ name, size, color, ring, glyph = 0.5 }: CircledIconProps) {
export function CircledIcon({ name, size, color, ring, glyph = 0.5, solid = false, glyphColor = "#FFFFFF" }: CircledIconProps) {
const icon = ICONS[name];
const [minX, minY, vw, vh] = icon.viewBox.split(" ").map(Number);
const scale = (size * glyph) / Math.max(vw, vh);
@@ -39,10 +42,10 @@ export function CircledIcon({ name, size, color, ring, glyph = 0.5 }: CircledIco
const oy = (size - vh * scale) / 2 - minY * scale;
return (
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<Circle cx={size / 2} cy={size / 2} r={size / 2 - ring / 2} stroke={color} strokeWidth={ring} fill="none" />
{solid ? <Circle cx={size / 2} cy={size / 2} r={size / 2} fill={color} /> : <Circle cx={size / 2} cy={size / 2} r={size / 2 - ring / 2} stroke={color} strokeWidth={ring} fill="none" />}
<G transform={`translate(${ox} ${oy}) scale(${scale})`}>
{icon.paths.map((d, i) => (
<Path key={i} d={d} fill={color} />
<Path key={i} d={d} fill={solid ? glyphColor : color} />
))}
</G>
</Svg>
+109
View File
@@ -0,0 +1,109 @@
import { Circle, Svg } from "@react-pdf/renderer";
import type { DecorItem } from "../blocks/FirstPageHeader";
import type { PageFrame } from "../engine/geometry";
/*
* Citrus Split's halftone chevrons. Read off the Canva content stream (docs/templates/13-citrus-split.md): each
* field is 19 rows x 4 dots on a lattice of 22.5-unit row pitch and 45.3-unit dot spacing; each row sits 22.5 units
* to the right of the one above it until the middle row, then back again (a '>'), and the diameter grows from 9.8
* to 22.54 towards the middle row. The fill is an axial gradient from orange (right edge) to light grey (left)
* at 9% opacity; here every dot gets the colour of the gradient at its own x, which needs no gradient object.
*/
export const HALFTONE = { rows: 19, perRow: 4, rowPitch: 22.5, dotSpacing: 45.3, x0: 4.89, y0: 4.89, minD: 9.8, maxD: 22.54, w: 354.6, h: 415 } as const;
export const HALFTONE_OPACITY = 0.09;
export interface HalftoneDot {
/** Local units (0..354.6 x 0..415). */
cx: number;
cy: number;
d: number;
/** The gradient colour at the dot's x. */
color: string;
}
const GRADIENT: Array<[number, [number, number, number]]> = [
[0, [0xff, 0x92, 0x20]],
[0.15, [0xff, 0x93, 0x22]],
[1, [0xf2, 0xf2, 0xf2]],
];
/** The gradient axis in local units: orange at x1 (right), grey at x0 (left). */
const AXIS = { from: 372.33, to: -13.03 };
function gradientAt(x: number): string {
const t = Math.min(1, Math.max(0, (x - AXIS.from) / (AXIS.to - AXIS.from)));
const i = GRADIENT.findIndex(([o]) => o >= t);
const [o1, c1] = GRADIENT[Math.max(0, i)];
const [o0, c0] = GRADIENT[Math.max(0, i - 1)];
const k = o1 === o0 ? 0 : (t - o0) / (o1 - o0);
return `#${c0.map((v, j) => Math.round(v + (c1[j] - v) * k).toString(16).padStart(2, "0")).join("")}`;
}
/** The 76 dots of one field. */
export function halftoneDots(): HalftoneDot[] {
const h = HALFTONE;
const mid = (h.rows - 1) / 2;
const dots: HalftoneDot[] = [];
for (let r = 0; r < h.rows; r++) {
const lead = mid - Math.abs(r - mid);
const d = h.minD + (h.maxD - h.minD) * (lead / mid);
for (let k = 0; k < h.perRow; k++) {
const cx = h.x0 + h.rowPitch * lead + h.dotSpacing * k;
dots.push({ cx, cy: h.y0 + h.rowPitch * r, d, color: gradientAt(cx) });
}
}
return dots;
}
/** Where one field sits on the Canva A4 page (595.5 x 842.25): page = origin + scale * local, x mirrored when `turned`. */
export interface HalftoneField {
id: "bottom-left" | "bottom-centre" | "top-right";
scale: number;
x: number;
y: number;
/** Turned 180 degrees: page = origin - scale * local. */
turned?: boolean;
/** How the field follows the page when it is not A4: to the left/right edge, or the centre, and to the top or bottom edge. */
anchorX: "left" | "centre" | "right";
anchorY: "top" | "bottom";
}
export const CITRUS_HALFTONE: HalftoneField[] = [
{ id: "bottom-left", scale: 0.8424, x: -126.2, y: 517.4, anchorX: "left", anchorY: "bottom" },
{ id: "bottom-centre", scale: 0.7608, x: 142.5, y: 572.1, anchorX: "centre", anchorY: "bottom" },
{ id: "top-right", scale: 0.4776, x: 646.3, y: 178.3, turned: true, anchorX: "right", anchorY: "top" },
];
const CANVA = { w: 595.5, h: 842.25 } as const;
const round2 = (n: number) => Math.round(n * 100) / 100;
/**
* One field as a decor item: an Svg covering the part of the field that is on the page, with the dots in page
* coordinates (viewBox = that part), so nothing is drawn off the page. Anchored to the page edges, so it also fits Letter.
*/
export function halftoneField(frame: PageFrame, field: HalftoneField, opacity = HALFTONE_OPACITY): DecorItem {
const { width: pw, height: ph } = frame.page;
const dx = field.anchorX === "left" ? 0 : field.anchorX === "centre" ? (pw - CANVA.w) / 2 : pw - CANVA.w;
const dy = field.anchorY === "top" ? 0 : ph - CANVA.h;
const sign = field.turned ? -1 : 1;
const toPage = (cx: number, cy: number) => ({ x: field.x + dx + sign * field.scale * cx, y: field.y + dy + sign * field.scale * cy });
const dots = halftoneDots().map((dot) => ({ ...toPage(dot.cx, dot.cy), r: (dot.d * field.scale) / 2, color: dot.color }));
const x0 = Math.max(0, Math.min(...dots.map((d) => d.x - d.r)));
const y0 = Math.max(0, Math.min(...dots.map((d) => d.y - d.r)));
const x1 = Math.min(pw, Math.max(...dots.map((d) => d.x + d.r)));
const y1 = Math.min(ph, Math.max(...dots.map((d) => d.y + d.r)));
const w = x1 - x0;
const h = y1 - y0;
return {
x: x0,
y: y0,
width: w,
height: h,
children: (
<Svg width={w} height={h} viewBox={`${round2(x0)} ${round2(y0)} ${round2(w)} ${round2(h)}`}>
{dots.map((d, i) => (
<Circle key={i} cx={round2(d.x)} cy={round2(d.y)} r={round2(d.r)} fill={d.color} fillOpacity={opacity} />
))}
</Svg>
),
};
}
+21
View File
@@ -0,0 +1,21 @@
import { View } from "@react-pdf/renderer";
import { pdfFlags } from "../blocks/pdfProps";
import type { DecorItem } from "../blocks/FirstPageHeader";
import { DECOR_ID } from "../engine/ids";
/**
* Fixed, page-anchored boxes painted BEFORE the flow, so the text of the flow sits on top of them: a panel the
* numbers are printed on, watermarks, halftone dots. PageChrome's decor is painted after the flow and would
* cover it. Render this as the first child of the Page.
*/
export function Underlay({ items }: { items: DecorItem[] }) {
return (
<>
{items.map((d, i) => (
<View key={i} id={DECOR_ID} style={{ position: "absolute", left: d.x, top: d.y, width: d.width, height: d.height }} {...pdfFlags({ fixed: true })}>
{d.children}
</View>
))}
</>
);
}
+2
View File
@@ -14,9 +14,11 @@ const goodFont = () => {
describe("font manifest", () => {
it("lists the bundled families", () => {
expect(FONT_FAMILIES).toEqual([
"DM Sans",
"IBM Plex Mono",
"IBM Plex Sans",
"Inter",
"Jost",
"Montserrat",
"Noto Sans Devanagari",
"Noto Sans Kannada",
+21 -3
View File
@@ -6,7 +6,19 @@
* TYPE_ROLES holds Classic's values. Every other template declares its own role table (see
* defineRoles) from the faces below and passes those tokens wherever a role is accepted.
*/
export type FaceKey = "IBM Plex Sans" | "IBM Plex Mono" | "Montserrat" | "Open Sans" | "Poppins" | "Inter";
export type FaceKey =
| "IBM Plex Sans"
| "IBM Plex Mono"
| "Montserrat"
| "Open Sans"
| "Poppins"
| "Inter"
| "DM Sans"
| "Jost"
// Licensed faces of the Canva designs; SUBSTITUTES maps each to the bundled look-alike.
| "Now"
| "Gotham"
| "Open Sauce One";
export interface TypeToken {
face: FaceKey;
@@ -55,9 +67,15 @@ export function defineRoles<const T extends Record<string, TypeToken>>(table: T)
/**
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
* actually bundled. Empty until those families are registered.
* actually bundled. A user-imported font replaces the licensed name through `faceOverrides`.
* Gotham runs about 4% narrower than Montserrat, so tokens that use it carry sizes already scaled by
* GOTHAM_SIZE (see templates/northline); a real Gotham import would need them back at 1.
*/
export const SUBSTITUTES: Readonly<Record<string, string>> = {};
export const SUBSTITUTES: Readonly<Record<string, string>> = {
Now: "Jost",
Gotham: "Montserrat",
"Open Sauce One": "Poppins",
};
/** The family name to register/look up for a face: user override first, then substitute, then itself. */
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): string {
+1 -1
View File
@@ -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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
});
it("both are Montserrat throughout, and their slots are the measured Canva ones", () => {
+1 -1
View File
@@ -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(8);
expect(TEMPLATE_CATALOG).toHaveLength(10);
});
it("groups the C4 templates into the Ledger and Band families", () => {
+4 -1
View File
@@ -5,7 +5,7 @@
export interface TemplateCatalogEntry {
id: string;
name: string;
family: "classic" | "neutral" | "ledger" | "band";
family: "classic" | "neutral" | "ledger" | "band" | "northline";
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
thumbnail: string;
badge?: "Matches your original format";
@@ -18,6 +18,7 @@ export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
neutral: "Neutral",
ledger: "Ledger",
band: "Band",
northline: "Northline",
};
/** Picker order: Classic first, Linea (the vendor's original format) second. */
@@ -30,6 +31,8 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "tangerine-ledger", name: "Tangerine Ledger", family: "ledger", thumbnail: "/templates/tangerine-ledger.png" },
{ id: "slate-band", name: "Slate Band", family: "band", thumbnail: "/templates/slate-band.png" },
{ id: "teal-swoosh", name: "Teal Swoosh", family: "band", thumbnail: "/templates/teal-swoosh.png" },
{ id: "purple-pop", name: "Purple Pop", family: "northline", thumbnail: "/templates/purple-pop.png", logoOptIn: true },
{ id: "citrus-split", name: "Citrus Split", family: "northline", thumbnail: "/templates/citrus-split.png" },
];
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
+1 -1
View File
@@ -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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic);
+5
View File
@@ -31,6 +31,11 @@ export interface TemplateDecor {
* supplier contact lines (a footer band); decor is exempt from the audit, so regulated slots never go here.
*/
everyPage?: (frame: PageFrame, model: RenderModel) => DecorItem[];
/**
* Fixed every-page art that must sit UNDER the flow (a full-height panel the numbers are printed on, watermarks, halftone dots).
* everyPage is painted after the flow, so it would cover text; the underlay is painted before it. Still page-edge anchored.
*/
underlay?: (frame: PageFrame, model: RenderModel) => DecorItem[];
/** Page-space boxes the flow must stay out of (audited as `keepout`); `page` limits one to a single 1-based page. */
keepOuts?: (frame: PageFrame) => Array<Rect & { page?: number }>;
}
+1 -1
View File
@@ -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"]);
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh", "purple-pop", "citrus-split"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
@@ -0,0 +1,471 @@
import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Txt } from "../../blocks/Txt";
import { CircledIcon } 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 { atRate, BULLET, NOTES_FLOW_GAP, planCitrus, REVERSE_GAP, signatureOptions, type CitrusPlan } from "./plan";
import type { CitrusVariant } from "./variant";
interface Ctx {
model: TemplateProps["model"];
plan: CitrusPlan;
v: CitrusVariant;
logo: PlacedLogo | null;
}
const WHITE = "#FFFFFF";
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header -----------------------------------------------------------------------------------------------------
function Title({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
return (
<View id={TITLE_ID} style={pinned(h.titleW) as never}>
{h.tax ? (
<Txt role={v.roles.taxCaption} color={v.label} width={h.titleW} style={{ marginBottom: 2 }}>
TAX
</Txt>
) : null}
{/* Left-aligned, so the negative tracking only shortens the line; the ink stays inside the box. */}
<Txt role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
Invoice
</Txt>
</View>
);
}
function PanelHeader({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
return (
<View id={SUPPLIER_ID} style={pinned(plan.panelW) as never}>
<View style={{ flexDirection: "row", marginLeft: v.head.panelInset, minHeight: h.boxH } as never}>
{logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: Math.max(0, (h.h - logo.h) / 2), objectFit: "contain" } as never} /> : null}
{h.showName ? (
<View style={{ ...pinned(h.nameW), marginLeft: logo ? v.head.markGap : 0, marginTop: Math.max(0, (h.h - h.nameH) / 2) } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={v.roles.brand} color={v.ink}>
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
</View>
);
}
// --- client block, meta row -------------------------------------------------------------------------------------
function ClientBlock({ ctx, inset }: { ctx: Ctx; inset: number }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const w = plan.client.w;
return (
<View style={{ marginLeft: inset, ...pinned(w) } as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.billedLabel} color={v.ink} width={w} style={{ marginBottom: v.client.labelGap }}>
Billed to:
</Txt>
<SafeTxt role={r.clientName} color={v.label} style={{ marginBottom: v.client.nameGap }}>
{c.name}
</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.clientLine} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.clientLine} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.clientLine} color={v.ink} />
{c.poNumber ? (
<SafeTxt role={r.clientLine} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt>
) : null}
</AvailableWidthProvider>
</View>
);
}
function MetaCell({ ctx, label, children }: { ctx: Ctx; label: string; children: ReactNode }) {
const { v } = ctx;
return (
<View>
<Txt role={v.roles.metaLabel} color={v.label} style={{ marginBottom: v.meta.labelGap }}>
{label}
</Txt>
{children}
</View>
);
}
function MetaRow({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const m = plan.meta;
const no = `${model.meta.numberLabel.replace(/\.$/, "")} :`;
return (
<View id={META_ID} style={pinned(plan.W) as never}>
<Row widths={[{ fixed: v.meta.inset }, { fixed: m.noW }, { fixed: m.dateW }, { fixed: m.spacer }, { fixed: m.dueW }, { fill: true }]} width={plan.W}>
<View />
<MetaCell ctx={ctx} label={no}>
<IdText role={r.metaValue} color={v.ink}>
{model.meta.number}
</IdText>
</MetaCell>
<MetaCell ctx={ctx} label="Invoice Date :">
<SafeTxt role={r.metaValue} color={v.ink}>
{model.meta.date}
</SafeTxt>
</MetaCell>
<View />
{m.hasDue ? (
<MetaCell ctx={ctx} label="Due Date :">
<SafeTxt role={r.metaValue} color={v.ink}>
{model.meta.dueDate as string}
</SafeTxt>
</MetaCell>
) : (
<View />
)}
<MetaCell ctx={ctx} label="Amount Due:">
<IdText role={r.metaStrong} color={v.ink}>
{model.totals.totalText}
</IdText>
</MetaCell>
</Row>
</View>
);
}
// --- totals ------------------------------------------------------------------------------------------------------
function Panel({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const k = plan.tail;
const t = v.totals;
const inner = plan.panelW - t.labelInset - t.valueInset;
const labelW = inner - k.valueW;
const barInner = plan.panelW - 2 * t.barInset;
return (
<View style={pinned(plan.panelW) as never}>
<View style={{ height: v.separator.h, backgroundColor: v.separator.color, ...pinned(plan.panelW) } as never} />
<View style={{ marginTop: t.sepGap, marginLeft: t.labelInset, ...pinned(inner) } as never}>
<AvailableWidthProvider width={inner}>
{k.stack.map((row, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: k.valueW }]} style={{ marginBottom: t.rowGap }}>
<Txt role={r.totalLabel} color={v.text} width={labelW}>
{row.label}
</Txt>
<Txt role={r.totalValue} color={v.text} width={k.valueW} align="right">
{row.amount}
</Txt>
</Row>
))}
<Slot.TaxRows
model={model}
role={r.totalLabel}
amountRole={r.totalValue}
color={v.text}
amountWidth={k.valueW}
rowStyle={{ marginBottom: t.rowGap }}
format={atRate}
/>
</AvailableWidthProvider>
</View>
<View
style={{
marginTop: t.barGap,
marginLeft: t.barInset,
...pinned(barInner),
minHeight: t.barH,
justifyContent: "center",
paddingLeft: t.labelInset - t.barInset - 4.4,
paddingRight: t.valueInset - t.barInset,
backgroundColor: v.orange,
} as never}
>
<AvailableWidthProvider width={barInner - (t.labelInset - t.barInset - 4.4) - (t.valueInset - t.barInset)}>
<Row widths={[{ fill: true }, { fixed: k.barValueW }]}>
<Txt role={r.grand} color={v.text} width={barInner - (t.labelInset - t.barInset - 4.4) - (t.valueInset - t.barInset) - k.barValueW}>
Total
</Txt>
<Txt role={r.grandValue} color={v.text} width={k.barValueW} align="right">
{model.totals.totalText}
</Txt>
</Row>
</AvailableWidthProvider>
</View>
{k.wordsH > 0 || k.reverseH > 0 ? (
<View style={{ marginTop: t.wordsGap, marginLeft: 13.1, ...pinned(k.wordsW) } as never}>
<AvailableWidthProvider width={k.wordsW}>
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} align="right" />
{k.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: REVERSE_GAP }} /> : null}
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const k = plan.tail;
return (
// The gap is inside the block, so the totals' height is what the last row's minPresenceAhead (tailH) asks for.
<View id={TOTALS_ID} wrap={false} style={pinned(plan.tableW) as never}>
<View style={{ marginTop: plan.gaps.tail }}>
<Row widths={[{ fixed: k.leftW }, { fill: true }]} width={plan.tableW}>
<View>{plan.clientAbove ? null : <View style={{ marginTop: v.totals.sepGap }}><ClientBlock ctx={ctx} inset={v.client.inset - v.table.left} /></View>}</View>
<Panel ctx={ctx} />
</Row>
</View>
</View>
);
}
// --- closing -----------------------------------------------------------------------------------------------------
/** Terms too long for the closing's left column: a block of their own across the width, each bullet unbreakable, the label held with the first. */
function NotesFlow({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const bullet = (line: string, i: number) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === c.terms.length - 1 ? 0 : cl.bulletGap, marginLeft: cl.bulletInset - cl.methodInset, ...pinned(c.flowW - (cl.bulletInset - cl.methodInset)) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: ((r.bullet.leading as number) - BULLET.dot) / 2, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(c.flowBulletW), marginLeft: cl.bulletText - cl.bulletInset - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={c.flowBulletW}>
{line}
</SafeTxt>
</View>
</View>
);
return (
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: cl.methodInset, ...pinned(c.flowW) } as never}>
<View wrap={false}>
<Txt role={r.sectionLabel} color={v.label} width={c.flowW} style={{ marginBottom: cl.labelGap }}>
Payment terms
</Txt>
{bullet(c.terms[0], 0)}
</View>
{c.terms.slice(1).map((line, i) => bullet(line, i + 1))}
</View>
);
}
function Method({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
return (
<View style={pinned(plan.Lw) as never}>
{model.bank ? (
<View style={{ marginLeft: cl.methodInset, ...pinned(plan.Lw - cl.methodInset) } as never}>
<Txt role={r.sectionLabel} color={v.label} width={plan.Lw - cl.methodInset} style={{ marginBottom: cl.labelGap }}>
Payment method
</Txt>
<View style={{ marginLeft: cl.bankInset - cl.methodInset, ...pinned(plan.Lw - cl.bankInset - 8) } as never}>
<AvailableWidthProvider width={plan.Lw - cl.bankInset - 8}>
{c.bank.map((line, i) => (
<SafeTxt key={i} role={r.bank} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
</View>
) : null}
{c.termsBeside && c.terms.length ? (
<View style={{ marginTop: model.bank ? cl.sectionGap : 0, marginLeft: cl.methodInset, ...pinned(plan.Lw - cl.methodInset) } as never}>
<Txt role={r.sectionLabel} color={v.label} width={plan.Lw - cl.methodInset} style={{ marginBottom: cl.labelGap }}>
Payment terms
</Txt>
{c.terms.map((line, i) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === c.terms.length - 1 ? 0 : cl.bulletGap, marginLeft: cl.bulletInset - cl.methodInset, ...pinned(plan.Lw - cl.bulletInset - 8) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: ((r.bullet.leading as number) - BULLET.dot) / 2, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(c.bulletW), marginLeft: cl.bulletText - cl.bulletInset - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={c.bulletW} wrap={false}>
{line}
</SafeTxt>
</View>
</View>
))}
</View>
) : null}
</View>
);
}
function Contacts({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const iconCol = cl.iconGap + cl.iconSize + cl.iconInset;
return (
<View style={pinned(plan.panelW) as never}>
{c.contacts.map((x, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: iconCol }]} width={plan.panelW} style={{ marginBottom: i === c.contacts.length - 1 ? 0 : cl.contactGap }}>
<View style={pinned(plan.panelW - iconCol) as never}>
<AvailableWidthProvider width={c.contactTextW}>
{x.id ? (
<IdText role={r.contact} color={v.ink} align="right" fill width={c.contactTextW}>
{x.lines[0]}
</IdText>
) : (
x.lines.map((line, j) => (
<SafeTxt key={j} role={r.contact} color={v.ink} align="right" width={c.contactTextW}>
{line}
</SafeTxt>
))
)}
</AvailableWidthProvider>
</View>
<View style={{ marginLeft: cl.iconGap, marginTop: ((r.contact.leading as number) - cl.iconSize) / 2 }}>
<CircledIcon name={x.icon} size={cl.iconSize} color={x.icon === "phone" ? v.orange : v.icon} ring={1} solid={x.icon === "phone"} glyph={x.icon === "phone" ? 0.62 : 0.56} />
</View>
</Row>
))}
</View>
);
}
function RightColumn({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = plan.closing;
const cl = v.closing;
const s = model.supplier;
const iconCol = cl.iconGap + cl.iconSize + cl.iconInset;
const opts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.text, "right");
const groups: ReactNode[] = [];
if (c.contacts.length || c.idLinesH > 0) {
groups.push(
<View key="contacts">
<Contacts ctx={ctx} />
{c.idLinesH > 0 ? (
<View style={{ marginTop: 4, marginRight: iconCol, ...pinned(plan.panelW - iconCol) } as never}>
<AvailableWidthProvider width={plan.panelW - iconCol}>
<Slot.Gstin model={model} party="supplier" role={r.small} color={v.ink} align="right" />
{s.pan ? (
<IdText role={r.small} color={v.ink} align="right" fill>{`PAN: ${s.pan}`}</IdText>
) : null}
</AvailableWidthProvider>
</View>
) : null}
</View>,
);
}
groups.push(
<View key="sign" style={{ marginLeft: plan.panelW - cl.signW - cl.iconInset, ...pinned(cl.signW) } as never}>
<Slot.Signatory model={model} width={cl.signW} {...opts} />
</View>,
);
return (
<View style={pinned(plan.panelW) as never}>
{groups.map((g, i) => (
<View key={i} style={{ marginTop: i === 0 ? 0 : 12 }}>
{g}
</View>
))}
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: v.closing.clientTop, ...pinned(plan.W) } as never}>
<AvailableWidthProvider width={plan.W}>
<Row widths={[{ fixed: plan.Lw }, { fill: true }]} width={plan.W}>
<Method ctx={ctx} />
<RightColumn ctx={ctx} />
</Row>
{model.cancelled ? (
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout --------------------------------------------------------------------------------------------------
export function CitrusBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: CitrusVariant }) {
const plan = planCitrus(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
return (
<>
<FirstPageHeader pad={frame.pad}>
<Row widths={[{ fixed: plan.Lw }, { fill: true }]} width={plan.W}>
<Title ctx={ctx} />
<PanelHeader ctx={ctx} />
</Row>
{plan.clientAbove ? (
<>
<Spacer h={plan.gaps.client} />
<ClientBlock ctx={ctx} inset={v.client.inset} />
</>
) : null}
<Spacer h={plan.gaps.meta} />
<MetaRow ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={{ marginLeft: plan.tableLeft, ...pinned(plan.tableW) } as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.tableW}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.headNum, body: r.tableBody }}
align={plan.align}
padX={0}
padLeft={plan.padLeft}
padRight={plan.padRight}
padY={plan.padY}
headPadY={plan.headPadY}
border={0}
headColor={(i) => (i === 0 ? WHITE : v.ink)}
headCellStyle={(i) => (i === 0 ? { backgroundColor: v.orange, paddingLeft: v.table.headInset } : { backgroundColor: WHITE })}
rowStyle={(i) => (i % 2 === 1 ? { backgroundColor: v.panel } : {})}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{plan.closing.termsBeside ? null : <NotesFlow ctx={ctx} />}
<Closing ctx={ctx} />
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { TemplateProps } from "../contract";
import { CitrusBody } from "./CitrusLayout";
import { PurpleBody } from "./PurpleLayout";
import type { NorthlineVariant } from "./variant";
/**
* The Northline family component. Purple Pop and Citrus Split share the Northline Group content model, the data
* blocks (slots, bank and terms lines, contact list, the table plan) and the type system; the page compositions
* differ (pills on cream, a split grey column on blush), so each variant has its own body.
*/
export function NorthlineLayout({ variant, ...props }: TemplateProps & { variant: NorthlineVariant }) {
return variant.id === "purple-pop" ? <PurpleBody {...props} variant={variant} /> : <CitrusBody {...props} variant={variant} />;
}
@@ -0,0 +1,457 @@
import { Image, View } from "@react-pdf/renderer";
import { pinned } from "../../engine/columns";
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader, type DecorItem } from "../../blocks/FirstPageHeader";
import { FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Tracked } from "../../blocks/Tracked";
import { Txt } from "../../blocks/Txt";
import { Asterisk, Dome } from "../../decor/Asterisk";
import { CircledIcon } 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 { atRate, BULLET, BULLET_GAP, CLIENT_LABEL_GAP, CLIENT_PILL_GAP, CLOSING_COLUMN_GAP, DOME_TOP, MARK_GAP, NOTES_FLOW_GAP, PILL_GAP, planPurple, REVERSE_GAP, signatureOptions, type PurplePlan } from "./plan";
import type { PurpleVariant } from "./variant";
interface Ctx {
model: TemplateProps["model"];
plan: PurplePlan;
v: PurpleVariant;
logo: PlacedLogo | null;
}
const WHITE = "#FFFFFF";
const ON_PURPLE = "#FFF4F4";
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
// --- header -------------------------------------------------------------------------------------------------
function VendorBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const r = v.roles;
const h = plan.head;
const s = model.supplier;
return (
<View id={SUPPLIER_ID} style={pinned(h.leftW) as never}>
<View style={{ flexDirection: "row", ...pinned(h.leftW), minHeight: h.boxH } as never}>
{logo ? <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: Math.max(0, (h.boxH - logo.h) / 2), objectFit: "contain" } as never} /> : null}
{h.showName ? (
<View style={{ ...pinned(h.nameW), minHeight: h.boxH, justifyContent: "center", marginLeft: logo ? MARK_GAP : 0 } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={r.brand} color={v.ink}>
{s.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
<AvailableWidthProvider width={h.leftW}>
<Slot.Gstin model={model} party="supplier" role={r.small} color={v.ink} />
{s.pan ? <IdText role={r.small} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
</AvailableWidthProvider>
</View>
);
}
function NumberPill({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
return (
<View style={{ ...pinned(h.pillW), minHeight: v.head.pillH, justifyContent: "center", backgroundColor: v.grey, borderRadius: v.head.pillH / 2 } as never}>
<Tracked role={{ ...v.roles.pill, size: h.pillSize }} color={v.purple} width={h.pillW - 2 * v.head.pillPadX} align="center" style={{ marginLeft: v.head.pillPadX }}>
{h.pillText}
</Tracked>
</View>
);
}
// --- client and the wordmark -------------------------------------------------------------------------------------
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const k = plan.client;
const lineStyle = { marginLeft: v.client.inset };
return (
<View style={pinned(k.cellW) as never}>
<Txt role={r.label} color={v.ink} width={k.cellW - v.client.inset} style={lineStyle}>
Billed to:
</Txt>
<View style={{ marginTop: CLIENT_LABEL_GAP, ...pinned(k.pillW), minHeight: k.pillBoxH, justifyContent: "center", paddingHorizontal: v.client.pillPadX, backgroundColor: v.yellow, borderRadius: v.client.pillH / 2 } as never}>
<AvailableWidthProvider width={k.pillW - 2 * v.client.pillPadX}>
<SafeTxt role={r.clientName} color={v.purple}>
{c.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
<View style={{ marginTop: CLIENT_PILL_GAP, ...lineStyle, ...pinned(k.lineW) } as never}>
<AvailableWidthProvider width={k.lineW}>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.clientLine} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.clientLine} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.clientLine} color={v.ink} />
</AvailableWidthProvider>
</View>
</View>
);
}
function Wordmark({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const t = plan.title;
return (
<View id={TITLE_ID} style={pinned(t.w) as never}>
{t.tax ? (
<Tracked role={r.taxCaption} color={v.purple} width={t.w} align="right" style={{ marginBottom: 2 }}>
TAX
</Tracked>
) : null}
{/* Negative tracking leaves the last glyph's ink |step| past the line's end; the padding makes that part of the box. */}
<Txt role={{ ...r.wordmark, size: t.size }} color={v.purple} width={t.w} align="right" style={{ paddingRight: t.pad }}>
Invoice
</Txt>
</View>
);
}
// --- amount due and dates -------------------------------------------------------------------------------------
function AmountBand({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const b = plan.band;
return (
<View id={META_ID} style={{ marginLeft: v.band.amountInset, ...pinned(b.amountW + v.band.gap + b.datesW) } as never}>
<Row widths={[{ fixed: b.amountW }, { fixed: b.datesW }]} gap={v.band.gap} width={b.amountW + v.band.gap + b.datesW}>
<View style={pinned(b.amountW) as never}>
<Txt role={r.label} color={v.ink} width={b.amountW}>
Amount due:
</Txt>
<FitText role={r.amount} color={v.purple} width={b.amountW} style={{ marginTop: 2 }}>
{b.amountText}
</FitText>
</View>
<View style={pinned(b.datesW) as never}>
{b.lines.map((line, i) => (
<Txt key={i} role={{ ...r.date, size: b.dateSize }} color={v.ink} width={b.datesW} align="right">
{line}
</Txt>
))}
</View>
</Row>
</View>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const k = plan.tail;
const inner = k.boxW - v.totals.labelInset - v.totals.valueInset;
const labelW = inner - k.valueW;
return (
<View style={pinned(k.boxW) as never}>
<View style={{ marginLeft: v.totals.labelInset, ...pinned(inner) } as never}>
<AvailableWidthProvider width={inner}>
{k.stack.map((row, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: k.valueW }]} style={{ marginBottom: v.totals.rowGap }}>
<Txt role={r.totalLabel} color={v.ink} width={labelW}>
{row.label}
</Txt>
<Txt role={r.totalValue} color={v.ink} width={k.valueW} align="right">
{row.amount}
</Txt>
</Row>
))}
<Slot.TaxRows
model={model}
role={r.totalLabel}
amountRole={r.totalValue}
color={v.ink}
amountWidth={k.valueW}
rowStyle={{ marginBottom: v.totals.rowGap }}
format={atRate}
/>
</AvailableWidthProvider>
</View>
<View
style={{
marginTop: PILL_GAP - v.totals.rowGap,
...pinned(k.boxW),
minHeight: v.totals.pillH,
justifyContent: "center",
paddingLeft: v.totals.labelInset,
paddingRight: v.totals.pillValueInset,
backgroundColor: v.purple,
borderRadius: v.totals.pillH / 2,
} as never}
>
<AvailableWidthProvider width={k.boxW - v.totals.labelInset - v.totals.pillValueInset}>
<Row widths={[{ fill: true }, { fixed: k.valueW }]}>
<Txt role={r.grand} color={ON_PURPLE} width={inner - k.valueW}>
Total
</Txt>
<Txt role={r.grandValue} color={ON_PURPLE} width={k.valueW} align="right">
{model.totals.totalText}
</Txt>
</Row>
</AvailableWidthProvider>
</View>
{k.wordsH > 0 ? (
<View style={{ marginTop: v.totals.wordsGap, marginLeft: v.totals.labelInset, ...pinned(k.wordsW) } as never}>
<AvailableWidthProvider width={k.wordsW}>
<Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} />
</AvailableWidthProvider>
</View>
) : null}
{k.reverseH > 0 ? (
<View style={{ marginTop: REVERSE_GAP, marginLeft: v.totals.labelInset, ...pinned(k.wordsW) } as never}>
<AvailableWidthProvider width={k.wordsW}>
<Slot.ReverseCharge model={model} role={r.small} color={v.ink} />
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const k = plan.tail;
return (
// The gap is inside the block, so the totals' height is what the last row's minPresenceAhead (tailH) asks for.
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<View style={{ marginTop: plan.gaps.tail }}>
<Row widths={[{ fixed: k.gutter }, { fill: true }]} width={plan.W}>
<View style={{ marginLeft: v.dome.x, marginTop: DOME_TOP }}>
<Dome width={v.dome.w} color={v.yellow} />
</View>
<TotalsBox ctx={ctx} />
</Row>
</View>
</View>
);
}
// --- closing ---------------------------------------------------------------------------------------------------
/** Terms with more bullets than fit beside the signature: a block of their own, each bullet unbreakable, the label held with the first. */
function NotesFlow({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const r = v.roles;
const t = plan.terms;
const dotTop = (r.bullet.leading as number) / 2 - BULLET.dot / 2;
const bullet = (line: string, i: number) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === t.lines.length - 1 ? 0 : BULLET_GAP, ...pinned(t.flowW) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: dotTop, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(t.flowBulletW), marginLeft: BULLET.textAt - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={t.flowBulletW}>
{line}
</SafeTxt>
</View>
</View>
);
return (
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.terms.labelInset, ...pinned(t.flowW) } as never}>
<View wrap={false}>
<Txt role={r.termsLabel} color={v.ink} width={t.flowW} style={{ marginBottom: 4 }}>
Payment terms
</Txt>
{bullet(t.lines[0], 0)}
</View>
{t.lines.slice(1).map((line, i) => bullet(line, i + 1))}
</View>
);
}
function Terms({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = plan.terms;
const opts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.ink, "right");
const dotTop = (r.bullet.leading as number) / 2 - BULLET.dot / 2;
return (
<Row widths={[{ fixed: v.terms.labelInset + v.terms.labelW }, { fill: true }, { fixed: v.terms.signW }]} gap={CLOSING_COLUMN_GAP}>
<View style={pinned(v.terms.labelInset + v.terms.labelW) as never}>
{t.beside && t.lines.length ? (
<Txt role={r.termsLabel} color={v.ink} width={v.terms.labelW} style={{ marginLeft: v.terms.labelInset }}>
Payment terms
</Txt>
) : null}
</View>
<View>
{(t.beside ? t.lines : []).map((line, i) => (
<View key={i} wrap={false} style={{ flexDirection: "row", marginBottom: i === t.lines.length - 1 ? 0 : BULLET_GAP, ...pinned(t.fillW) } as never}>
<View style={{ ...pinned(BULLET.dot), height: BULLET.dot, marginTop: dotTop, borderRadius: BULLET.dot / 2, backgroundColor: v.ink } as never} />
<View style={{ ...pinned(t.bulletW), marginLeft: BULLET.textAt - BULLET.dot } as never}>
<SafeTxt role={r.bullet} color={v.ink} width={t.bulletW} wrap={false}>
{line}
</SafeTxt>
</View>
</View>
))}
</View>
<View style={pinned(v.terms.signW) as never}>
<Slot.Signatory model={model} width={v.terms.signW} {...opts} />
</View>
</Row>
);
}
function Footer({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const f = plan.footer;
const ft = v.footer;
const iconCol = ft.iconW + ft.iconGap;
const phoneMail = f.contacts.filter((x) => x.icon !== "location");
const address = f.contacts.find((x) => x.icon === "location");
const icon = (name: "phone" | "email" | "location") => <CircledIcon name={name} size={ft.iconW} color={v.ink} ring={0.9} solid={name !== "location"} glyph={0.62} />;
return (
<Row widths={[{ fixed: ft.contactLeft }, { fixed: ft.addressLeft - ft.contactLeft }, { fill: true }]} width={plan.W}>
<View style={{ marginLeft: ft.ruleLeft, ...pinned(ft.bankW) } as never}>
{model.bank ? (
<>
<Txt role={r.label} color={v.ink} width={ft.bankW} style={{ marginBottom: 4 }}>
Payment method
</Txt>
<AvailableWidthProvider width={ft.bankW}>
{f.bank.map((line, i) => (
<SafeTxt key={i} role={r.bank} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</>
) : null}
</View>
<View style={pinned(ft.contactW) as never}>
{phoneMail.map((x, i) => (
<Row key={i} widths={[{ fixed: iconCol }, { fill: true }]} width={ft.contactW} style={{ marginBottom: 1.5 }}>
<View style={{ marginTop: ((r.contact.leading as number) - ft.iconW) / 2 }}>{icon(x.icon)}</View>
<IdText role={r.contact} color={v.ink} width={f.textW.contact}>
{x.lines[0]}
</IdText>
</Row>
))}
</View>
<View>
{address ? (
<Row widths={[{ fixed: iconCol }, { fill: true }]} width={plan.W - ft.addressLeft}>
<View style={{ marginTop: ((r.contact.leading as number) - ft.iconW) / 2 }}>{icon("location")}</View>
<View>
<AvailableWidthProvider width={f.textW.address}>
{address.lines.map((line, i) => (
<SafeTxt key={i} role={r.contact} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
</Row>
) : null}
</View>
</Row>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: v.gaps.termsAbove, ...pinned(plan.W) } as never}>
<AvailableWidthProvider width={plan.W}>
<Terms ctx={ctx} />
<View style={{ marginTop: v.gaps.ruleGap, marginLeft: v.footer.ruleLeft, marginBottom: v.gaps.ruleGap, ...pinned(plan.W - v.footer.ruleLeft - v.footer.ruleRight), height: v.rule.w, backgroundColor: v.ink } as never} />
<Footer ctx={ctx} />
{model.cancelled ? (
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function PurpleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: PurpleVariant }) {
const plan = planPurple(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const decor: DecorItem[] = [];
// Beside the table's header and first rows, page 1 only: it is a child of the first-page header, never a page-level absolute.
if (plan.asteriskY !== null && plan.asteriskY + v.asterisk.size < frame.page.height - frame.pad.bottom) {
decor.push({ x: frame.pad.left + v.asterisk.offsetX, y: plan.asteriskY, width: v.asterisk.size, height: v.asterisk.size, children: <Asterisk size={v.asterisk.size} color={v.purple} /> });
}
const rule = (
<View style={{ marginLeft: v.rule.left, ...pinned(plan.W - v.rule.left - v.rule.right), height: v.rule.w, backgroundColor: v.ink } as never} />
);
return (
<>
<FirstPageHeader pad={frame.pad} decor={decor}>
<View style={{ marginLeft: v.head.nameInset, ...pinned(plan.head.innerW) } as never}>
<Row widths={[{ fill: true }, { fixed: plan.head.pillW }]} gap={12} width={plan.head.innerW}>
<VendorBlock ctx={ctx} />
<NumberPill ctx={ctx} />
</Row>
</View>
<Spacer h={plan.gaps.client} />
<Row widths={[{ fixed: v.client.colW }, { fill: true }]} width={plan.W}>
<ClientBlock ctx={ctx} />
<Wordmark ctx={ctx} />
</Row>
<Spacer h={plan.gaps.rule} />
{rule}
<Spacer h={plan.gaps.band} />
<AmountBand ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<TableRows
columns={plan.columns}
gap={0}
width={plan.tableW}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={0}
padY={plan.padY}
headPadY={plan.headPadY}
border={0}
headColor={WHITE}
headerStyle={{ backgroundColor: v.purple, borderRadius: v.table.radiusHead }}
rowStyle={(i) => ({ backgroundColor: i % 2 === 0 ? v.rowGrey : v.yellow, borderRadius: v.table.radiusRow })}
pill={{ indent: plan.tableLeft, inset: v.table.inset }}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
{plan.terms.beside ? null : <NotesFlow ctx={ctx} />}
<Closing ctx={ctx} />
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { View } from "@react-pdf/renderer";
import { CITRUS_HALFTONE, halftoneField } from "../../decor/Halftone";
import { defineRoles } from "../../fonts/roles";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NorthlineLayoutFor } from "./layoutFor";
import { gotham, type CitrusRoles, type CitrusVariant } from "./variant";
const DM = "DM Sans" as const;
const G = "Gotham" as const;
const INK = "#060605";
const TEXT = "#231F20";
export const ORANGE = "#FF9220";
export const LABEL = "#F96300";
/**
* Citrus Split's type (docs/templates/13-citrus-split.md): the INVOICE wordmark in Now Bold (drawn in Jost 700, at
* the design's -0.081 em), DM Sans for labels, names and the words line, Gotham (drawn in Montserrat through
* gotham()) for the table, totals, client lines and the closing text.
*/
export const CITRUS_ROLES = defineRoles({
title: { face: "Now", weight: 700, size: 47.94, leading: 52, tracking: -0.081, caps: true, minSize: 30 },
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
brand: { face: DM, weight: 700, size: 17.13, leading: 17.1, minSize: 12 },
metaLabel: { face: DM, weight: 700, size: 12, leading: 16 },
metaValue: { face: DM, weight: 400, size: 12, leading: 16, minSize: 8 },
metaStrong: { face: DM, weight: 700, size: 12.36, leading: 16, minSize: 8 },
billedLabel: { face: DM, weight: 700, size: 12.4, leading: 17, caps: true },
clientName: { face: DM, weight: 700, size: 14.87, leading: 19, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.5), leading: 15.5 },
headService: { face: G, weight: 700, size: gotham(15.17), leading: 18, tracking: 0.15, caps: true },
headNum: { face: G, weight: 700, size: gotham(14.17), leading: 18, caps: true },
tableBody: { face: G, weight: 400, size: gotham(12), leading: 15 },
tableSub: { face: G, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(12.5), leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(12.5), leading: 17 },
grand: { face: G, weight: 700, size: gotham(14.7), leading: 18, caps: true },
grandValue: { face: G, weight: 700, size: gotham(14.7), leading: 18 },
words: { face: DM, weight: 700, size: 11, leading: 14 },
wordsLabel: { face: DM, weight: 700, size: 11, leading: 14 },
sectionLabel: { face: DM, weight: 700, size: 12, leading: 16, caps: true },
bank: { face: G, weight: 400, size: gotham(11), leading: 15 },
bullet: { face: G, weight: 400, size: gotham(12), leading: 16.5 },
contact: { face: G, weight: 400, size: gotham(12.77), leading: 18, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
footer: { face: DM, weight: 400, size: 7 },
} satisfies CitrusRoles);
/**
* Canva A4: content x 73.3, the stripe 298.8..535.6 (right margin 59.68). The title's cap top is y 75.6 and the
* footer text ends near y 750, so the bottom margin is 40 plus the footer reserve.
*/
export const CITRUS_VARIANT: CitrusVariant = {
id: "citrus-split",
roles: CITRUS_ROLES,
ink: INK,
text: TEXT,
blush: "#FFF7F7",
panel: "#F1F1F1",
orange: ORANGE,
label: LABEL,
icon: "#F39322",
stripeW: 236.8,
margins: { top: 58, right: 59.68, bottom: 40, left: 73.3 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
signatureImageH: 40,
chromeLeftW: 150,
head: { panelInset: 38.6, markGap: 20 },
client: { inset: 12.3, gap: 8, labelGap: 5, nameGap: 4 },
meta: { inset: 14.6, noMin: 86.2, dateMin: 90, dueW: 108.2, panelInset: 16.7, labelGap: 6 },
table: { left: 10.9, headH: 47.2, headInset: 31.7, bodyInset: 15.7, padY: 12.15, padYMin: 8, descRight: 8, cols: { qty: 66.8, rate: 80, amount: 90 } },
separator: { color: "#FFF7F7", h: 1.5 },
totals: { labelInset: 22.1, valueInset: 30.6, rowGap: 7, barH: 47.2, barInset: 1, barGap: 8, sepGap: 14, wordsGap: 12 },
closing: { methodInset: 8.4, bankInset: 9.9, bulletInset: 15.9, bulletText: 28.8, bulletGap: 4.3, sectionGap: 18, labelGap: 6, iconSize: 15, iconGap: 11.6, iconInset: 11.6, contactGap: 5, signW: 150, clientTop: 16, termsBesideMax: 110 },
gaps: {
client: { nominal: 14, min: 6 },
meta: { nominal: 75, min: 10 },
table: { nominal: 17, min: 6 },
tail: { nominal: 20, min: 6 },
},
};
export const citrusSplit: TemplateDefinition = {
...catalogEntry("citrus-split"),
version: 1,
roles: CITRUS_ROLES,
margins: CITRUS_VARIANT.margins,
marginFloor: CITRUS_VARIANT.marginFloor,
decor: {
ink: INK,
background: CITRUS_VARIANT.blush,
// The grey stripe runs the full page height on every page and the halftone fields sit on it and beside it, all
// under the flow. The stripe is anchored to the right margin, so it also follows Letter and the margin presets.
underlay: (frame) => [
{
x: frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW,
y: 0,
width: CITRUS_VARIANT.stripeW,
height: frame.page.height,
children: <View style={{ width: CITRUS_VARIANT.stripeW, height: frame.page.height, backgroundColor: CITRUS_VARIANT.panel }} />,
},
...CITRUS_HALFTONE.map((field) => halftoneField(frame, field)),
],
},
chrome: {
color: "#6f6f6f",
rule: null,
role: CITRUS_ROLES.footer,
leftWidth: CITRUS_VARIANT.chromeLeftW,
},
logo: {
box: { w: 144.5, h: 34 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 144.5,
slot: slotFor("citrus-split"),
available: () => ({ w: 144.5, h: 34 }),
},
Layout: NorthlineLayoutFor(CITRUS_VARIANT),
};
+11
View File
@@ -0,0 +1,11 @@
import type { ComponentType } from "react";
import type { TemplateProps } from "../contract";
import { NorthlineLayout } from "./Layout";
import type { NorthlineVariant } from "./variant";
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
export function NorthlineLayoutFor(variant: NorthlineVariant): ComponentType<TemplateProps> {
const Layout = (props: TemplateProps) => <NorthlineLayout {...props} variant={variant} />;
Layout.displayName = `NorthlineLayout(${variant.id})`;
return Layout;
}
@@ -0,0 +1,463 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ASTERISK_PATH, asteriskWatermark, DOME_PATH, WATERMARK } from "../../decor/Asterisk";
import { CITRUS_HALFTONE, halftoneDots, halftoneField, HALFTONE } from "../../decor/Halftone";
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
import { walk, type LayoutNode } from "../../engine/layoutTree";
import { measureToken, getMeasurer } from "../../engine/measure";
import { resolveFace } from "../../fonts/roles";
import { SLOT_ID } from "../../blocks/slots";
import { frameForPrefs } from "../../render/invoice";
import { loadCanvas, pagePng } from "../../testing/rasterize";
import { catalogEntry, TEMPLATE_CATALOG } from "../catalog";
import { marginsFor } from "../contract";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, type FixtureSpec } from "../harness";
import { getTemplate, TEMPLATES } from "../registry";
import { SLOTS } from "../slots.generated";
import { OPTIN_SLOTS } from "../slots.optin";
import { citrusSplit, CITRUS_ROLES, CITRUS_VARIANT } from "./citrus";
import { planCitrus, planPurple } from "./plan";
import { purplePop, PURPLE_ROLES, PURPLE_VARIANT } from "./purple";
import { gotham, GOTHAM_SIZE } from "./variant";
ensureSetup();
const NORTHLINE = [purplePop, citrusSplit];
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
const out: LayoutNode[] = [];
for (const p of layout) walk(p.nodes, (n) => out.push(n));
return out;
};
const onPage = (r: Awaited<ReturnType<typeof renderFixture>>, i: number) => nodes([r.result.core.layout[i]]);
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
const textsOf = (r: Awaited<ReturnType<typeof renderFixture>>, page = 0) => r.pdf.pages[page].items.map((i) => i.str).join(" ");
/** The colour of pixels of page 1, at 4x. */
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise<number[][]> {
const lib = await loadCanvas();
if (!lib) return [];
const png = await pagePng(lib, bytes, 1, 4);
const img = await lib.loadImage(png);
const c = lib.createCanvas(img.width, img.height);
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0);
return points.map(([x, y]) => Array.from(ctx.getImageData(Math.round(x * 4), Math.round(y * 4), 1, 1).data).slice(0, 3));
}
/** Items with short names, like the Canva sample: the narrow Purple Pop table and the one-page Citrus Split layout both need them. */
const SHORT = ["Consultation", "Logo design", "Web design"].map((description, i) => ({ id: i + 1, description, mode: "rate" as const, rate: 100, unit: "hour" as const, quantity: 1, amount: 0, sortOrder: i, hsnSac: "998397" }));
/** The same without an HSN/SAC code, so every row is one line tall. */
const PLAIN = SHORT.map(({ hsnSac, ...rest }) => ({ ...rest, ...(hsnSac ? {} : {}) }));
const near = (c: number[], hex: string, tol = 6) => {
const want = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return want.every((w, i) => Math.abs(c[i] - w) <= tol);
};
describe("registry, catalog and fonts", () => {
it("lists Purple Pop and Citrus Split last, in the Northline family, with their thumbnails on disk", () => {
for (const t of NORTHLINE) {
expect(getTemplate(t.id)).toBe(t);
expect(t.family).toBe("northline");
expect(catalogEntry(t.id).name).toBe(t.name);
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
}
expect(Object.keys(TEMPLATES).slice(-2)).toEqual(["purple-pop", "citrus-split"]);
expect(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"]);
});
it("Gotham tokens carry the Montserrat size factor, and the licensed faces resolve to the bundled look-alikes", () => {
expect(GOTHAM_SIZE).toBe(0.96);
expect(gotham(12)).toBe(11.52);
expect(PURPLE_ROLES.label.size).toBe(gotham(12));
expect(CITRUS_ROLES.headNum.size).toBe(gotham(14.17));
expect(resolveFace("Gotham")).toBe("Montserrat");
expect(resolveFace("Now")).toBe("Jost");
expect(resolveFace("Open Sauce One")).toBe("Poppins");
// A user-imported font replaces the licensed name before the substitute applies.
expect(resolveFace("Gotham", { Gotham: "user:Gotham" })).toBe("user:Gotham");
// The wordmarks: Poppins 800 at -0.08 em (Purple) and Jost 700 at -0.081 em (Citrus).
expect(PURPLE_ROLES.wordmark).toMatchObject({ face: "Open Sauce One", weight: 800, size: 94.07, tracking: -0.08 });
expect(CITRUS_ROLES.title).toMatchObject({ face: "Now", weight: 700, size: 47.94, tracking: -0.081 });
});
it("amounts are never set in Jost (it has no rupee sign): Jost is only the Citrus wordmark", () => {
for (const t of NORTHLINE) {
for (const [name, token] of Object.entries(t.roles)) {
const face = resolveFace(token.face);
if (face === "Jost") expect(name, `${t.id}.${name}`).toBe("title");
}
}
});
});
describe("page geometry", () => {
it("template margins", () => {
expect(marginsFor(purplePop, "template")).toEqual({ top: 70, right: 52.48, bottom: 40, left: 54.8 });
expect(marginsFor(citrusSplit, "template")).toEqual({ top: 58, right: 59.68, bottom: 40, left: 73.3 });
});
it("Letter keeps the physical margins and takes the missing height from the gaps", () => {
for (const t of NORTHLINE) {
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
expect(letter.pad).toEqual(a4.pad);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
}
});
it("the cream (Purple Pop) and blush (Citrus Split) page fills are the Page background, free of any page-level block", async () => {
const purple = await renderFixture({ name: "p", items: 3, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const [a] = await colourAt(purple.result.bytes, [[20, 60]]);
if (a) expect(near(a, "#FEFFEE"), `cream ${a}`).toBe(true);
const citrus = await renderFixture({ name: "p", items: 3, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
const [b] = await colourAt(citrus.result.bytes, [[20, 300]]);
if (b) expect(near(b, "#FFF7F7"), `blush ${b}`).toBe(true);
});
});
describe("decor primitives", () => {
it("the asterisk has eight arms of 16.5% and the dome fits its box", () => {
expect(ASTERISK_PATH.startsWith("M100 41.7H69.9")).toBe(true);
expect(DOME_PATH).toContain("M89.7,565.3");
expect(WATERMARK.size).toBe(352.4);
});
it("each watermark box lies on the page (A4 and Letter), anchored to its corner", () => {
for (const size of ["A4", "LETTER"] as const) {
const frame = frameForPrefs(prefsFor(size, { templateId: "purple-pop" }));
const tr = asteriskWatermark(frame, "top-right", "#FFF7F7");
const bl = asteriskWatermark(frame, "bottom-left", "#FFF7F7");
expect(tr.x + tr.width).toBeCloseTo(frame.page.width, 6);
expect(tr.y).toBe(0);
expect(bl.x).toBe(0);
expect(bl.y + bl.height).toBeCloseTo(frame.page.height, 6);
}
});
it("a halftone field is 19 rows of 4 dots with the chevron's diameters, and every field lies on the page", () => {
const dots = halftoneDots();
expect(dots).toHaveLength(HALFTONE.rows * HALFTONE.perRow);
expect(Math.min(...dots.map((d) => d.d))).toBeCloseTo(HALFTONE.minD, 2);
expect(Math.max(...dots.map((d) => d.d))).toBeCloseTo(HALFTONE.maxD, 2);
for (const size of ["A4", "LETTER"] as const) {
const frame = frameForPrefs(prefsFor(size, { templateId: "citrus-split" }));
for (const f of CITRUS_HALFTONE) {
const item = halftoneField(frame, f);
expect(item.x, f.id).toBeGreaterThanOrEqual(0);
expect(item.y, f.id).toBeGreaterThanOrEqual(0);
expect(item.x + item.width, f.id).toBeLessThanOrEqual(frame.page.width + 1e-6);
expect(item.y + item.height, f.id).toBeLessThanOrEqual(frame.page.height + 1e-6);
}
}
});
});
describe("Purple Pop", () => {
it("the watermarks are fixed under the flow: two decor svgs at the page corners on every page", async () => {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const frame = frameForPrefs(r.prefs);
expect(r.result.pages).toBeGreaterThan(1);
for (let i = 0; i < r.result.pages; i++) {
const wm = onPage(r, i).filter((n) => n.type === "svg" && n.decor && n.fixed && n.box.w > 250);
expect(wm, `page ${i + 1}`).toHaveLength(2);
const tr = wm.find((n) => n.box.y < 1)!;
const bl = wm.find((n) => n.box.x < 1)!;
expect(tr.box.x + tr.box.w).toBeCloseTo(frame.page.width, 1);
expect(bl.box.y + bl.box.h).toBeCloseTo(frame.page.height, 1);
}
});
it("a short invoice uses the narrow table beside the asterisk (page 1 only); a longer one the full width, without it", async () => {
const frame = frameForPrefs(prefsFor("A4", { templateId: "purple-pop" }));
const short = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
const head = onPage(short, 0).find((n) => n.id === TABLE_HEAD_ID)!;
expect(head.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.narrowLeft, 1);
const stars = (r: typeof short, i: number) => onPage(r, i).filter((n) => n.type === "svg" && n.decor && !n.fixed);
expect(stars(short, 0)).toHaveLength(1);
expect(stars(short, 0)[0].box.w).toBeCloseTo(PURPLE_VARIANT.asterisk.size, 1);
const long = await renderFixture({ name: "l", items: 12, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const head2 = onPage(long, 0).find((n) => n.id === TABLE_HEAD_ID)!;
expect(head2.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.fullLeft, 1);
expect(stars(long, 0)).toHaveLength(0);
// Every continuation page repeats the header at the full-width position.
for (let i = 1; i < long.result.pages; i++) {
const h = onPage(long, i).find((n) => n.id === TABLE_HEAD_ID);
if (h) expect(h.box.x).toBeCloseTo(frame.pad.left + PURPLE_VARIANT.table.fullLeft, 1);
}
});
it("header and rows are pills: purple head, then grey and yellow rows, flat colours", async () => {
const r = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
const head = onPage(r, 0).find((n) => n.id === TABLE_HEAD_ID)!;
const rows = [0, 1, 2].map((i) => onPage(r, 0).find((n) => n.id === `${TABLE_ROW_PREFIX}${i}`)!);
const sample = [head, ...rows].map((n): [number, number] => [n.box.x + 8, n.box.y + n.box.h / 2]);
const colours = await colourAt(r.result.bytes, sample);
if (colours.length) {
expect(near(colours[0], "#530895"), `head ${colours[0]}`).toBe(true);
expect(near(colours[1], "#EDEDE8"), `row 1 ${colours[1]}`).toBe(true);
expect(near(colours[2], "#FFD80B"), `row 2 ${colours[2]}`).toBe(true);
expect(near(colours[3], "#EDEDE8"), `row 3 ${colours[3]}`).toBe(true);
}
// Pills are flush: each row starts where the one above ends.
for (let i = 1; i < rows.length; i++) expect(rows[i].box.y).toBeCloseTo(rows[i - 1].box.y + rows[i - 1].box.h, 1);
expect(rows[0].box.h).toBeCloseTo(PURPLE_VARIANT.table.rowH, 1);
expect(head.box.h).toBeCloseTo(PURPLE_VARIANT.table.headH, 1);
});
it("the INVOICE NO pill and both dates are explicit lines; TAX sits above the wordmark on a tax invoice only", async () => {
const tax = await renderFixture({ name: "t", items: 3, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const text = tax.pdf.pages[0].items.map((i) => i.str.replace(/\s+/g, "")).join("").toUpperCase();
expect(text).toContain("INVOICENO:AP/2026-001");
expect(text).toContain("INVOICEDATE:15SEPT2026");
expect(text).toContain("DUEDATE:15OCT2026");
expect(tax.pdf.pages[0].items.some((i) => i.str.replace(/\s+/g, "") === "TAX")).toBe(true);
const plain = await renderFixture({ name: "t", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "purple-pop" }));
expect(plain.pdf.pages[0].items.some((i) => i.str.replace(/\s+/g, "") === "TAX")).toBe(false);
});
it("the wordmark's ink stays inside its box, whatever the page size", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "w", items: 3, logo: "none" }, prefsFor(size, { templateId: "purple-pop" }));
expect(r.result.issues.filter((i) => i.code === "overflow-x"), size).toEqual([]);
}
});
it("the logo is opt-in: off by default, on in the opt-in area, a mark keeps the name, a wordmark replaces it", async () => {
expect(purplePop.logo.optIn).toBe(true);
expect(purplePop.logo.defaultOn).toBe(false);
expect(purplePop.logo.slot).toBe(OPTIN_SLOTS["purple-pop"]);
const off = await renderFixture({ name: "o", items: 2, logo: "real" }, prefsFor("A4", { templateId: "purple-pop" }));
expect(imageBoxes(off)).toHaveLength(0);
expect(off.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
const on = (logo: FixtureSpec["logo"]) => renderFixture({ name: "o", items: 2, logo }, prefsFor("A4", { templateId: "purple-pop", logo: { show: true } }));
const wide = await on("real");
const [img] = imageBoxes(wide);
expect(imageBoxes(wide)).toHaveLength(1);
const slot = OPTIN_SLOTS["purple-pop"]!.lockup!;
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
expect(img.box.x).toBeCloseTo(frameForPrefs(wide.prefs).pad.left + PURPLE_VARIANT.head.nameInset, 0);
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(0);
const square = await on("square");
expect(imageBoxes(square)).toHaveLength(1);
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
});
it("the dome and the TOTAL pill travel with the totals; amount in words sits under the pill", async () => {
const r = await renderFixture({ name: "t", items: SHORT, logo: "none" }, prefsFor("A4", { templateId: "purple-pop" }));
const totals = onPage(r, 0).find((n) => n.id === TOTALS_ID)!;
const inside = (n: LayoutNode) => n.box.y >= totals.box.y - 0.5 && n.box.y + n.box.h <= totals.box.y + totals.box.h + 0.5;
expect(onPage(r, 0).filter((n) => n.type === "svg" && !n.decor && inside(n))).toHaveLength(1);
const words = onPage(r, 0).find((n) => n.id === SLOT_ID.amountInWords)!;
const pill = onPage(r, 0).find((n) => n.type === "view" && Math.abs(n.box.h - PURPLE_VARIANT.totals.pillH) < 0.6 && n.box.w > 300 && inside(n))!;
expect(words.box.y).toBeGreaterThanOrEqual(pill.box.y + pill.box.h);
});
it("long terms flow as a block of their own and never make the closing block taller than a page", async () => {
const spec = FIXTURES.find((f) => f.name === "long-notes")!;
const r = await renderFixture(spec, prefsFor("A4", { templateId: "purple-pop" }));
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
expect(closing.box.h).toBeLessThan(260);
const plan = planPurple(r.fixture.model, frameForPrefs(r.prefs), PURPLE_VARIANT, null, false);
expect(plan.terms.beside).toBe(false);
expect(r.pdf.pages.map((p, i) => textsOf(r, i)).join(" ")).toContain("Note 18");
});
});
describe("Citrus Split", () => {
it("the grey stripe is a full-height underlay on every page, anchored to the right margin, on both sizes and every margin preset", async () => {
for (const size of ["A4", "LETTER"] as const) {
for (const margins of ["template", "narrow", "normal"] as const) {
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "citrus-split", margins }));
const frame = frameForPrefs(r.prefs);
expect(r.result.pages).toBeGreaterThan(1);
for (let i = 0; i < r.result.pages; i++) {
const stripe = onPage(r, i).filter((n) => n.type === "view" && n.id === "decor" && n.fixed && Math.abs(n.box.w - CITRUS_VARIANT.stripeW) < 0.01 && n.box.h > frame.page.height - 1);
expect(stripe, `${size}/${margins} p${i + 1}`).toHaveLength(1);
expect(stripe[0].box.x).toBeCloseTo(frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW, 1);
expect(stripe[0].box.y).toBeCloseTo(0, 1);
// Three halftone fields on every page.
expect(onPage(r, i).filter((n) => n.type === "svg" && n.decor && n.fixed), `${size}/${margins} p${i + 1} halftone`).toHaveLength(3);
}
}
}
});
it("the numeric columns fill the stripe exactly, so amounts always print on the grey", async () => {
for (const size of ["A4", "LETTER"] as const) {
const r = await renderFixture({ name: "p", items: 6, logo: "none" }, prefsFor(size, { templateId: "citrus-split" }));
const frame = frameForPrefs(r.prefs);
const stripeX = frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW;
const plan = planCitrus(r.fixture.model, frame, CITRUS_VARIANT, null, false);
expect(plan.cw.qty + plan.cw.rate + plan.cw.amount).toBeCloseTo(CITRUS_VARIANT.stripeW, 1);
expect(plan.table.widths[0] + frame.pad.left + CITRUS_VARIANT.table.left).toBeCloseTo(stripeX, 1);
// Every body amount starts inside the stripe.
for (const t of r.pdf.pages[0].items) if (/^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.top > 250) expect(t.x0, t.str).toBeGreaterThan(stripeX - 0.5);
}
});
it("the header: SERVICE on an orange cell, the numeric headings on white; zebra rows span the left part", async () => {
const r = await renderFixture({ name: "s", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
const frame = frameForPrefs(r.prefs);
const head = onPage(r, 0).find((n) => n.id === TABLE_HEAD_ID)!;
const row1 = onPage(r, 0).find((n) => n.id === `${TABLE_ROW_PREFIX}1`)!;
expect(head.box.h).toBeCloseTo(CITRUS_VARIANT.table.headH, 0);
const stripeX = frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW;
const colours = await colourAt(r.result.bytes, [
[head.box.x + 3, head.box.y + 3],
[stripeX + 3, head.box.y + 3],
[head.box.x + 3, row1.box.y + 3],
[head.box.x + 3, row1.box.y - 3],
]);
if (colours.length) {
expect(near(colours[0], "#FF9220"), `SERVICE cell ${colours[0]}`).toBe(true);
expect(near(colours[1], "#FFFFFF"), `numeric heading band ${colours[1]}`).toBe(true);
expect(near(colours[2], "#F1F1F1"), `zebra ${colours[2]}`).toBe(true);
expect(near(colours[3], "#FFF7F7"), `row above is blush ${colours[3]}`).toBe(true);
}
});
it("the client block sits beside the totals when the invoice fits a page, above the table when it does not", async () => {
const short = await renderFixture({ name: "s", items: PLAIN, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
expect(planCitrus(short.fixture.model, frameForPrefs(short.prefs), CITRUS_VARIANT, null, false).clientAbove).toBe(false);
const client = short.pdf.pages[0].items.find((i) => i.str.toUpperCase().includes("ACME"))!;
const headY = onPage(short, 0).find((n) => n.id === TABLE_HEAD_ID)!.box.y;
expect(client.top).toBeGreaterThan(headY);
const long = await renderFixture({ name: "l", items: 60, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
expect(planCitrus(long.fixture.model, frameForPrefs(long.prefs), CITRUS_VARIANT, null, false).clientAbove).toBe(true);
const client2 = long.pdf.pages[0].items.find((i) => i.str.toUpperCase().includes("ACME"))!;
expect(client2.top).toBeLessThan(onPage(long, 0).find((n) => n.id === TABLE_HEAD_ID)!.box.y);
});
it("the contact list carries three icons in the closing block, right-aligned beside the stripe's right edge", async () => {
const r = await renderFixture({ name: "s", items: 3, logo: "none", registered: false }, prefsFor("A4", { templateId: "citrus-split" }));
const closing = onPage(r, r.result.pages - 1).find((n) => n.id === CLOSING_ID)!;
const icons = onPage(r, r.result.pages - 1).filter((n) => n.type === "svg" && !n.decor && n.box.y >= closing.box.y && n.box.w === CITRUS_VARIANT.closing.iconSize);
expect(icons).toHaveLength(3);
const frame = frameForPrefs(r.prefs);
for (const i of icons) expect(i.box.x + i.box.w).toBeLessThanOrEqual(frame.page.width - frame.pad.right + 0.5);
const text = textsOf(r, r.result.pages - 1);
expect(text).toContain("9738284459");
expect(text).toContain("arunakira113@gmail.com");
});
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
const slot = SLOTS["citrus-split"]!.lockup!;
const withLogo = { name: "o", items: 2, logo: "real" as const };
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "citrus-split" }));
const [img] = imageBoxes(wide);
expect(imageBoxes(wide)).toHaveLength(1);
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.6);
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.6);
const frame = frameForPrefs(wide.prefs);
expect(img.box.x).toBeGreaterThanOrEqual(frame.page.width - frame.pad.right - CITRUS_VARIANT.stripeW);
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(0);
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "citrus-split" }));
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "citrus-split" }));
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Arun P" && i.bottom < 130)).toHaveLength(1);
});
it("the totals label column never wraps ('TAXABLE VALUE', 'UTGST @ 14%')", async () => {
const r = await renderFixture({ name: "t", items: 3, logo: "none", vendor: { vendorStateCode: "04", vendorGstin: "04AAAAA0000A1Z5" }, invoice: { placeOfSupplyStateCode: "04" } }, prefsFor("A4", { templateId: "citrus-split" }));
const totals = onPage(r, 0).find((n) => n.id === TOTALS_ID)!;
const labels = nodes([r.result.core.layout[0]]).filter((n) => n.type === "text" && n.box.y >= totals.box.y && /TAXABLE|UTGST|CGST/.test(n.text ?? ""));
expect(labels.length).toBeGreaterThan(2);
for (const l of labels) expect(l.lines).toHaveLength(1);
});
it("long terms flow as a block of their own", async () => {
const r = await renderFixture(FIXTURES.find((f) => f.name === "long-notes")!, prefsFor("A4", { templateId: "citrus-split" }));
expect(planCitrus(r.fixture.model, frameForPrefs(r.prefs), CITRUS_VARIANT, null, false).closing.termsBeside).toBe(false);
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
expect(closing.box.h).toBeLessThan(300);
});
});
describe("the GST slots and the closing", () => {
it("every GST slot has a home on a tax invoice", async () => {
for (const t of NORTHLINE) {
const r = await renderFixture({ name: "t", items: 2, logo: "none", signature: true }, prefsFor("A4", { templateId: t.id }));
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), `${t.id} #${id}`).toBe(true);
const flat = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str)).join("").replace(/\s+/g, "").toUpperCase();
for (const word of ["SUBTOTAL", "TAXABLEVALUE", "CGST@9%", "SGST@9%", "HSN/SAC:998397", "PAN:CPOPP7388K"]) expect(flat, `${t.id} ${word}`).toContain(word);
}
});
it("an unregistered invoice carries none of the GST slots", async () => {
for (const t of NORTHLINE) {
const r = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
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), `${t.id} #${id}`).toBe(false);
}
});
});
describe("long text", () => {
it("long names, addresses and unbroken tokens raise no audit error", async () => {
for (const t of NORTHLINE) {
for (const size of ["A4", "LETTER"] as const) {
for (const name of ["id-tokens", "long-address", "long-client"]) {
const r = await renderFixture(FIXTURES.find((f) => f.name === name)!, prefsFor(size, { templateId: t.id }));
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}/${name}`).toEqual([]);
}
}
}
});
it("a long invoice number shrinks inside the Purple Pop pill and the vendor name keeps its room", async () => {
const r = await renderFixture({ name: "n", items: 2, logo: "none", invoice: { number: "AB/2026-27/0012345" } }, prefsFor("A4", { templateId: "purple-pop" }));
expect(r.result.issues.filter((i) => i.severity === "error")).toEqual([]);
expect(r.pdf.pages[0].items.map((i) => i.str.replace(/\s+/g, "")).join("")).toContain("AB/2026-27/0012345");
});
});
describe("plan matches the laid-out blocks", () => {
for (const t of NORTHLINE) {
for (const size of ["A4", "LETTER"] as const) {
for (const spec of FIXTURES) {
it(`${t.id}/${spec.name} on ${size}`, async () => {
const prefs = prefsFor(size, { templateId: t.id });
const frame = frameForPrefs(prefs);
// Logo-less (Purple Pop's logo is off anyway), 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 = t.id === "purple-pop" ? planPurple(r.fixture.model, frame, PURPLE_VARIANT, null, false) : planCitrus(r.fixture.model, frame, CITRUS_VARIANT, null, false);
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(20 + plan.gaps.tail);
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
// A wrapped footer or contact line is estimated at most a line or two high (never low).
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(24);
});
}
}
}
});
describe("measured fit", () => {
it("the Purple Pop wordmark and the Citrus title take about the design's width in the substitute faces", () => {
const m = getMeasurer();
// Canva: 'Invoice' 292.2 pt wide (Open Sauce One 700); 'INVOICE' 189.7 pt (Now Bold).
expect(Math.abs(measureToken(m, PURPLE_ROLES.wordmark, "Invoice") - 292.2)).toBeLessThan(16);
expect(Math.abs(measureToken(m, CITRUS_ROLES.title, "Invoice") - 189.7)).toBeLessThan(16);
});
});
describe("design docs", () => {
it("each Northline doc records its implementation notes", () => {
for (const f of ["12-purple-pop.md", "13-citrus-split.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});
void buildFixture;
+553
View File
@@ -0,0 +1,553 @@
import type { ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { DOME_BOX } from "../../decor/Asterisk";
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 { TypeToken } from "../../fonts/roles";
import type { RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import { bankLines, CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
import type { CitrusVariant, PurpleVariant } from "./variant";
/** How both templates print a tax row's label: "CGST @ 9%". */
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
export const leading = (t: TypeToken) => t.leading as number;
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Space between the client label and the name pill, and between the pill and the address lines. */
export const CLIENT_LABEL_GAP = 9;
export const CLIENT_PILL_GAP = 6;
/** Gap between a mark and the vendor name beside it. */
export const MARK_GAP = 8;
/** Gap between the stack's last row and the TOTAL pill. */
export const PILL_GAP = 8;
export const REVERSE_GAP = 4;
/** Width of a bullet dot and the space after it. */
export const BULLET = { dot: 3.8, textAt: 12.7 };
/** Rows of the bullet column are this far apart on top of their line height. */
export const BULLET_GAP = 3;
export const CLOSING_COLUMN_GAP = 12;
/** Space above terms that flow as their own block (more bullets than fit beside the signature). */
export const NOTES_FLOW_GAP = 14;
/** Top of the dome below the totals box top (Canva: 565.3 against the subtotal row at 541.5). */
export const DOME_TOP = 22;
/** The notes as bullet lines: one bullet per note line. */
export const termsLines = (model: RenderModel): string[] => model.notesLines;
export interface ContactEntry {
icon: "phone" | "email" | "location";
lines: string[];
/** Identifiers (phone, e-mail) shrink instead of wrapping; free text wraps. */
id: boolean;
}
/** The supplier's phone, e-mail and address as an icon list. */
export function contactEntries(model: RenderModel): ContactEntry[] {
const s = model.supplier;
const out: ContactEntry[] = [];
if (s.phone) out.push({ icon: "phone", lines: [s.phone], id: true });
if (s.email) out.push({ icon: "email", lines: [s.email], id: true });
if (s.addressLines.length) out.push({ icon: "location", lines: s.addressLines, id: false });
return out;
}
export function signatureOptions(roles: { name: TypeToken; designation: TypeToken; fine: TypeToken }, imageHeight: number, color: string, align: "left" | "right"): SignatoryOptions {
return { roles, align, rule: { width: 0.75, color }, color, imageHeight };
}
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
export function idLines(role: TypeToken, text: string, width: number): number {
const f = fitSize(role, text, Math.max(1, width));
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)) + 1);
}
/** Plain shrink-wrapped width of a one-line text. */
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
const hsn = Slot.HsnSac(model);
return model.items.map((item, i) => {
const rate = rateCell(item.rateText, rateW, body);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
return [desc, item.qtyText, rateParts, item.amountText];
});
}
/** Width of a numeric column: the design's, or wider when the widest value or the header needs it. */
export function numericColW(base: number, body: TypeToken, head: TypeToken, texts: string[], headText: string, pad = 6): number {
const m = getMeasurer();
const widest = Math.max(0, ...texts.map((t) => measureToken(m, body, t)), measureToken(m, head, headText));
return Math.max(base, Math.ceil(widest) + pad);
}
// --- Purple Pop ---------------------------------------------------------------------------------------------
export interface PurplePlan {
kind: "purple";
v: PurpleVariant;
W: number;
head: {
innerW: number;
pillText: string;
pillSize: number;
pillW: number;
leftW: number;
showName: boolean;
nameW: number;
nameLeft: number;
boxH: number;
linesH: number;
h: number;
};
client: { cellW: number; wordmarkW: number; pillW: number; pillBoxH: number; lineW: number; clientH: number };
title: { size: number; tax: boolean; h: number; w: number; pad: number };
rowH: number;
band: { amountW: number; datesW: number; dateSize: number; lines: string[]; amountText: string; amountSize: number; h: number };
mode: "narrow" | "full";
tableLeft: number;
tableW: number;
columns: ColSpec[];
headerCells: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padY: number;
headPadY: number;
table: ItemsTablePlan;
guardH: number;
tail: { gutter: number; boxW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; wordsW: number; wordsH: number; reverseH: number; body: number };
/** `beside`: the bullets sit next to the signature in the closing block; otherwise they flow as their own block before it. */
terms: { lines: string[]; beside: boolean; fillW: number; bulletW: number; bulletsH: number; signH: number; h: number; flowW: number; flowBulletW: number; flowH: number };
footer: { bank: string[]; contacts: ContactEntry[]; bankH: number; contactH: number; addressH: number; textW: { contact: number; address: number }; h: number };
gaps: { client: number; rule: number; band: number; table: number; tail: number };
tailH: number;
closingH: number;
/** Page-space y of the first-page asterisk's top, or null when the table is full width. */
asteriskY: number | null;
}
export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVariant, logo: PlacedLogo | null, includesName: boolean): PurplePlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
// --- header: name (and the opt-in logo) at the left, the INVOICE NO pill at the right -------------------
const innerW = W - v.head.nameInset - v.head.pillInset;
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
const markW = logo?.w ?? 0;
const nameLeft = markW > 0 ? markW + (showName ? MARK_GAP : 0) : 0;
const label = model.meta.numberLabel.replace(/\.$/, "");
const pillText = `${label} : ${model.meta.number}`;
const pillTextMax = Math.max(40, innerW - 12 - nameLeft - v.head.minNameW - 2 * v.head.pillPadX);
const fit = fitSize(r.pill, pillText, pillTextMax);
const pillW = Math.ceil(fit.width) + 2 * v.head.pillPadX + 2;
const leftW = innerW - pillW - 12;
const nameW = showName ? Math.min(v.head.nameMaxW, leftW - nameLeft) : 0;
const nameH = showName ? textHeight(r.brand, model.supplier.name, nameW) : 0;
const boxH = Math.max(v.head.pillH, nameH, logo?.h ?? 0);
const s = model.supplier;
const linesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small);
const headH = Math.max(boxH + linesH, v.head.pillH);
// --- client column and the wordmark -------------------------------------------------------------------------
const cellW = v.client.colW - v.client.colGap;
const c = model.client;
const pillNatural = widthOf(r.clientName, c.name);
const pillW2 = Math.min(cellW, pillNatural + 2 * v.client.pillPadX);
const pillLines = textLines(r.clientName, c.name, pillW2 - 2 * v.client.pillPadX);
const pillBoxH = Math.max(v.client.pillH, pillLines * leading(r.clientName) + 2 * ((v.client.pillH - leading(r.clientName)) / 2));
const lineW = cellW - v.client.inset;
let clientH = leading(r.label) + CLIENT_LABEL_GAP + pillBoxH + CLIENT_PILL_GAP;
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, lineW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, lineW) * leading(r.clientLine);
const wordmarkW = W - v.client.colW - v.client.wordmarkInset;
const tax = model.docType === "tax_invoice";
const wmPad = Math.abs((r.wordmark.tracking ?? 0) * r.wordmark.size);
const wm = fitSize(r.wordmark, "Invoice", wordmarkW - wmPad);
const titleH = leading(r.wordmark) + (tax ? leading(r.taxCaption) + 2 : 0);
const rowH = Math.max(clientH, titleH);
// --- amount due and dates ---------------------------------------------------------------------------------
const bandInner = W - v.band.amountInset - v.band.datesInset;
const dateLines = [`Invoice Date : ${model.meta.date}`, ...(model.meta.dueDate ? [`Due Date : ${model.meta.dueDate}`] : []), ...(c.poNumber ? [`PO : ${c.poNumber}`] : [])];
const widest = dateLines.reduce((a, b) => (widthOf(r.date, b) > widthOf(r.date, a) ? b : a));
const amountMin = 150;
const dFit = fitSize(r.date, widest, bandInner - v.band.gap - amountMin);
const datesW = Math.ceil(dFit.width) + 2;
const amountW = bandInner - v.band.gap - datesW;
const aFit = fitSize(r.amount, model.totals.totalText, amountW);
const bandH = Math.max(leading(r.label) + leading(r.amount) + 2, dateLines.length * leading(r.date));
// --- table: the narrow variant (beside the asterisk) or the full width ---------------------------------------
const t = v.table;
const rowsFor = (rateW: number) => tableRows(model, rateW, r.tableBody, r.tableSub);
const headerCells = ["Service", "Qty", "Rate", "Amount"];
const align: PurplePlan["align"] = ["left", "center", "right", "right"];
const padY = (t.rowH - leading(r.tableBody)) / 2;
const headPadY = (t.headH - leading(r.tableHead)) / 2;
const priced = model.items.map((i) => priceOf(i.rateText));
const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
const tableW = round2(W - left - t.right);
const inner = tableW - 2 * t.inset;
const cw = {
qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"),
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
};
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
const rows = rowsFor(cw.rate - 2);
return { tableW, inner, cw, columns, rows, descW: inner - cw.qty - cw.rate - cw.amount };
};
const plan = (b: ReturnType<typeof build>, tailH: number) =>
planItemsTable({
columns: b.columns,
gap: 0,
width: b.inner,
header: headerCells,
rows: b.rows,
measurer: m,
roles: { head: r.tableHead, body: r.tableBody },
padX: 0,
padY,
headPadY,
border: 0,
tailH,
contentH: frame.contentHeight,
});
const narrowB = build(t.narrowLeft, t.cols);
let mode: "narrow" | "full" = "full";
if (model.items.length <= 3 && narrowB.descW >= t.narrowDescMin) {
const probe = plan(narrowB, 0);
if (probe.rowLines.every((n) => n <= 2)) mode = "narrow";
}
const fullB = build(t.fullLeft, { qty: 46, rate: 100, amount: 108 });
const b = mode === "narrow" ? narrowB : fullB;
const tableLeft = mode === "narrow" ? t.narrowLeft : t.fullLeft;
// --- totals: the stack, the TOTAL pill, amount in words ---------------------------------------------------------
const tt = model.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 gutter = round2(W - v.totals.boxW);
const boxW = v.totals.boxW;
const taxTexts = slotHeights.taxRowTexts(model, atRate);
const valueTexts = [tt.subtotalText, tt.totalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)];
const valueW = Math.max(100, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2);
const stackRowH = leading(r.totalLabel) + v.totals.rowGap;
const stackH = (stack.length + slotRows) * stackRowH;
const wordsW = boxW - v.totals.labelInset - v.totals.pillValueInset;
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const boxBody = stackH + PILL_GAP + v.totals.pillH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
// The dome in the left gutter must not make the row taller than its totals box.
const tailBody = Math.max(boxBody, DOME_TOP + DOME_BOX.h * (v.dome.w / DOME_BOX.w));
// --- closing: terms and signature, the rule, the three footer columns ----------------------------------------------
const lines = termsLines(model);
const signW = v.terms.signW;
const fillW = W - (v.terms.labelInset + v.terms.labelW) - signW - 2 * CLOSING_COLUMN_GAP;
const bulletW = fillW - BULLET.textAt;
const bulletsH = lines.reduce((h, l) => h + textHeight(r.bullet, l, bulletW) + BULLET_GAP, 0) - (lines.length ? BULLET_GAP : 0);
const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.ink, "right");
const signH = signatoryHeight(model, signW, sOpts);
const beside = lines.length === 0 || bulletsH <= v.terms.besideMax;
const flowW = W - v.terms.labelInset - 7;
const flowBulletW = flowW - BULLET.textAt;
const flowH = beside ? 0 : leading(r.termsLabel) + 4 + lines.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW) + BULLET_GAP, 0) - BULLET_GAP;
const termsH = beside ? Math.max(lines.length ? leading(r.termsLabel) : 0, bulletsH, signH) : signH;
const bank = bankLines(model);
const bankH = model.bank ? leading(r.label) + 4 + bank.reduce((h, l) => h + textHeight(r.bank, l, v.footer.bankW), 0) : 0;
const contacts = contactEntries(model);
const contactText = v.footer.contactW - v.footer.iconW - v.footer.iconGap;
const addressText = W - v.footer.addressLeft - v.footer.iconW - v.footer.iconGap;
const phoneMail = contacts.filter((x) => x.icon !== "location");
const address = contacts.find((x) => x.icon === "location");
const contactH = phoneMail.reduce((h, x) => h + idLines(r.contact, x.lines[0], contactText) * leading(r.contact) + 1.5, 0);
const addressH = address ? address.lines.reduce((h, l) => h + textHeight(r.contact, l, addressText), 0) : 0;
const footerH = Math.max(bankH, contactH, addressH);
let closingH = v.gaps.termsAbove + termsH + v.gaps.ruleGap + v.rule.w + v.gaps.ruleGap + footerH + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- gaps: the elastic ones give back Letter's missing height, or an overflow within their slack ------------------------
const ranges = { client: v.gaps.client, rule: v.gaps.rule, band: v.gaps.band, table: v.gaps.table, tail: v.gaps.tail };
const table0 = plan(b, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const tailNominal = ranges.tail.nominal + tailBody;
const notesBlock = beside ? 0 : NOTES_FLOW_GAP + flowH;
const nominalTotal = headH + ranges.client.nominal + rowH + ranges.rule.nominal + v.rule.w + ranges.band.nominal + bandH + ranges.table.nominal + tableTotal + tailNominal + notesBlock + closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together
// on page 1 and let only the closing block move to the next page.
const overflowBeforeClosing = nominalTotal - closingH - frame.contentHeight;
const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const tailH = g.tail + tailBody;
const table = plan(b, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (b.rows.length === 1 ? tailH : 0);
const asteriskY = mode === "narrow" ? round2(frame.pad.top + headH + g.client + rowH + g.rule + v.rule.w + g.band + bandH + g.table + v.asterisk.offsetY) : null;
return {
kind: "purple",
v,
W,
head: { innerW, pillText, pillSize: fit.size, pillW, leftW, showName, nameW, nameLeft, boxH, linesH, h: headH },
client: { cellW, wordmarkW, pillW: pillW2, pillBoxH, lineW, clientH },
title: { size: wm.size, tax, h: titleH, w: wordmarkW, pad: Math.abs((r.wordmark.tracking ?? 0) * wm.size) },
rowH,
band: { amountW, datesW, dateSize: dFit.size, lines: dateLines, amountText: model.totals.totalText, amountSize: aFit.size, h: bandH },
mode,
tableLeft,
tableW: b.tableW,
columns: b.columns,
headerCells,
rows: b.rows,
align,
padY,
headPadY,
table,
guardH,
tail: { gutter, boxW, valueW, stack, slotRows, stackRowH, stackH, wordsW, wordsH, reverseH, body: tailBody },
terms: { lines, beside, fillW, bulletW, bulletsH, signH, h: termsH, flowW, flowBulletW, flowH },
footer: { bank, contacts, bankH, contactH, addressH, textW: { contact: contactText, address: addressText }, h: footerH },
gaps: g,
tailH,
closingH,
asteriskY,
};
}
// --- Citrus Split --------------------------------------------------------------------------------------------
export interface CitrusPlan {
kind: "citrus";
v: CitrusVariant;
W: number;
/** The stripe's left edge from the content edge. */
Lw: number;
panelW: number;
/** The client block above the table (an invoice longer than a page), instead of beside the totals. */
clientAbove: boolean;
head: { titleW: number; titleSize: number; tax: boolean; titleH: number; showName: boolean; nameW: number; nameLeft: number; nameH: number; boxH: number; h: number };
client: { w: number; h: number; nameW: number };
meta: { noW: number; dateW: number; dueW: number; amountW: number; spacer: number; hasDue: boolean; h: number };
tableLeft: number;
tableW: number;
columns: ColSpec[];
headerCells: TableCellValue[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
padLeft: number[];
padRight: number[];
padY: number;
headPadY: number;
cw: { qty: number; rate: number; amount: number };
table: ItemsTablePlan;
guardH: number;
tail: { leftW: number; valueW: number; barValueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; panelH: number; leftH: number; body: number; wordsW: number; wordsH: number; reverseH: number };
closing: {
bank: string[];
terms: string[];
/** Terms in the closing's left column; otherwise a flowing block before the closing. */
termsBeside: boolean;
flowW: number;
flowBulletW: number;
flowH: number;
leftH: number;
bankH: number;
termsH: number;
bulletW: number;
contacts: ContactEntry[];
contactTextW: number;
contactsH: number;
idLinesH: number;
signH: number;
rightH: number;
h: number;
};
gaps: { client: number; meta: number; table: number; tail: number };
tailH: number;
closingH: number;
}
export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVariant, logo: PlacedLogo | null, includesName: boolean): CitrusPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const panelW = v.stripeW;
const Lw = round2(W - panelW);
const c = model.client;
const s = model.supplier;
// --- header: the wordmark at the left, the mark and name inside the panel ---------------------------------
const tax = model.docType === "tax_invoice";
const titleW = Lw - 8;
const tFit = fitSize(r.title, "Invoice", titleW);
const titleH = leading(r.title) + (tax ? leading(r.taxCaption) + 2 : 0);
const showName = !(logo && includesName) && s.name.trim() !== "";
const markW = logo?.w ?? 0;
const nameLeft = v.head.panelInset + (markW > 0 ? markW + (showName ? v.head.markGap : 0) : 0);
const nameW = showName ? Math.max(60, panelW - nameLeft - 4) : 0;
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
const boxH = Math.max(logo?.h ?? 0, nameH);
const headH = Math.max(titleH, boxH);
// --- client block -------------------------------------------------------------------------------------------
const cw = Lw - v.client.inset - 8;
let clientH = leading(r.billedLabel) + v.client.labelGap + textHeight(r.clientName, c.name, cw) + v.client.nameGap;
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, cw);
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, cw) * leading(r.clientLine);
if (c.poNumber) clientH += textHeight(r.clientLine, `PO: ${c.poNumber}`, cw);
// --- meta row ----------------------------------------------------------------------------------------------
const mt = v.meta;
const hasDue = Boolean(model.meta.dueDate);
const noLabel = `${model.meta.numberLabel.replace(/\.$/, "")} :`;
const noW = Math.min(Lw - mt.inset - mt.dateMin, Math.max(mt.noMin, widthOf(r.metaLabel, noLabel) + 8, Math.ceil(measureToken(m, r.metaValue, model.meta.number)) + 10));
const dateW = round2(Lw - mt.inset - noW);
const spacer = mt.panelInset;
const dueW = mt.dueW;
const amountW = round2(panelW - spacer - dueW);
const metaH = leading(r.metaLabel) + mt.labelGap + leading(r.metaValue);
// --- table ----------------------------------------------------------------------------------------------------
const t = v.table;
const tableW = round2(W - t.left);
const priced = model.items.map((i) => priceOf(i.rateText));
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6;
const amount = Math.max(t.cols.amount, amountNeed);
const rate = Math.max(t.cols.rate, rateNeed);
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
const cwid = { qty: Math.max(qty, qtyNeed), rate, amount: round2(panelW - Math.max(qty, qtyNeed) - rate) };
const columns: ColSpec[] = [{ fill: true }, { fixed: cwid.qty }, { fixed: cwid.rate }, { fixed: cwid.amount }];
const headerCells: TableCellValue[] = [[{ text: "Service", role: r.headService }], [{ text: "Qty", role: r.headNum }], [{ text: "Rate", role: r.headNum }], [{ text: "Amount", role: r.headNum }]];
const align: CitrusPlan["align"] = ["left", "center", "right", "right"];
const padLeft = [t.bodyInset, 0, 0, 0];
const padRight = [t.descRight, 0, 14, 13.6];
const headPadY = (t.headH - leading(r.headNum)) / 2;
const rows = tableRows(model, cwid.rate - 14 - 2, r.tableBody, r.tableSub);
const planTable = (padY: number, tailH: number) =>
planItemsTable({ columns, gap: 0, width: tableW, header: headerCells, rows, measurer: m, roles: { head: r.headNum, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
// --- totals panel and the tail row ----------------------------------------------------------------------------
const tt = model.totals;
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
const slotRows = slotHeights.taxRowCount(model);
const taxTexts = slotHeights.taxRowTexts(model, atRate);
const valueTexts = [tt.subtotalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)];
const innerTotals = panelW - v.totals.labelInset - v.totals.valueInset;
const stackLabels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
const widestLabel = Math.ceil(Math.max(...stackLabels.map((l) => measureToken(m, r.totalLabel, l)))) + 4;
// The amounts take their own width; the labels get the rest and never wrap (the row height is one line).
const valueW = Math.min(innerTotals - widestLabel, Math.max(64, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2));
const barValueW = Math.max(valueW, Math.ceil(Math.max(measureToken(m, r.grandValue, tt.totalText), 0)) + 2);
const stackRowH = leading(r.totalLabel) + v.totals.rowGap;
const stackH = (stack.length + slotRows) * stackRowH;
// Amount in words sits under the TOTAL bar (the design's tagline slot), right-aligned.
const wordsW = panelW - 2 * 13.1;
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const panelTail = v.separator.h + v.totals.sepGap + stackH + v.totals.barGap + v.totals.barH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
const tailLeftW = Lw - t.left;
const leftTail = v.totals.sepGap + clientH;
// --- closing: method and terms at the left; words, contacts, signature in the panel column --------------------------
const cl = v.closing;
const bank = bankLines(model);
const bankH = model.bank ? leading(r.sectionLabel) + cl.labelGap + bank.reduce((h, l) => h + textHeight(r.bank, l, Lw - cl.bankInset - 8), 0) : 0;
const terms = termsLines(model);
const bulletW = Lw - cl.bulletText - 8;
const termsH = terms.length ? leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, bulletW), 0) + (terms.length - 1) * cl.bulletGap : 0;
const termsBeside = terms.length === 0 || termsH <= cl.termsBesideMax;
const flowW = W - cl.methodInset - 8;
const flowBulletW = flowW - (cl.bulletText - cl.methodInset);
const flowH = termsBeside ? 0 : leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW), 0) + (terms.length - 1) * cl.bulletGap;
const leftH = bankH + (termsBeside && bankH && termsH ? cl.sectionGap : 0) + (termsBeside ? termsH : 0);
const contacts = contactEntries(model);
const contactTextW = panelW - (cl.iconGap + cl.iconSize + cl.iconInset) - 8;
const contactsH = contacts.reduce((h, x) => h + (x.id ? idLines(r.contact, x.lines[0], contactTextW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, contactTextW), 0)) + cl.contactGap, 0) - (contacts.length ? cl.contactGap : 0);
const idLinesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small);
const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.text, "right");
const signH = signatoryHeight(model, cl.signW, sOpts);
const parts = [contactsH + (idLinesH ? 4 + idLinesH : 0), signH].filter((x) => x > 0);
const rightH = parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 12;
let closingH = cl.clientTop + Math.max(leftH, rightH) + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- gaps. The client block sits beside the totals when the invoice fits one page, above the table otherwise. -------------
const padYn = t.padY;
const n = rows.length;
const run = (clientAbove: boolean) => {
const tailBody = Math.max(panelTail, clientAbove ? 0 : leftTail);
const ranges = {
client: clientAbove ? v.gaps.client : { nominal: 0, min: 0 },
meta: clientAbove ? { nominal: 24, min: 12 } : v.gaps.meta,
table: v.gaps.table,
tail: v.gaps.tail,
rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n },
};
const table0 = planTable(padYn, 0);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
const nominalTotal = headH + (clientAbove ? ranges.client.nominal + clientH : 0) + ranges.meta.nominal + metaH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + (termsBeside ? 0 : NOTES_FLOW_GAP + flowH) + closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
return { tailBody, ranges, nominalTotal, slack, tableTotal };
};
const faithful = run(false);
const clientAbove = faithful.nominalTotal - faithful.slack > frame.contentHeight;
const chosen = clientAbove ? run(true) : faithful;
const { tailBody, ranges, nominalTotal, slack } = chosen;
const overflow = nominalTotal - frame.contentHeight;
const overflowBeforeClosing = nominalTotal - closingH - frame.contentHeight;
const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0;
const deficit = Math.max(frame.heightDeficit, fits);
const g = elastic(ranges, deficit);
const padY = n ? g.rowPad / (2 * n) : padYn;
const tailH = g.tail + tailBody;
const table = planTable(padY, tailH);
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (rows.length === 1 ? tailH : 0);
return {
kind: "citrus",
v,
W,
Lw,
panelW,
clientAbove,
head: { titleW, titleSize: tFit.size, tax, titleH, showName, nameW, nameLeft, nameH, boxH, h: headH },
client: { w: cw, h: clientH, nameW: cw },
meta: { noW, dateW, dueW, amountW, spacer, hasDue, h: metaH },
tableLeft: t.left,
tableW,
columns,
headerCells,
rows,
align,
padLeft,
padRight,
padY,
headPadY,
cw: cwid,
table,
guardH,
tail: { leftW: tailLeftW, valueW, barValueW, stack, slotRows, stackRowH, stackH, panelH: panelTail, leftH: leftTail, body: tailBody, wordsW, wordsH, reverseH },
closing: { bank, terms, termsBeside, flowW, flowBulletW, flowH, leftH, bankH, termsH, bulletW, contacts, contactTextW, contactsH, idLinesH, signH, rightH, h: closingH },
gaps: { client: g.client, meta: g.meta, table: g.table, tail: g.tail },
tailH,
closingH,
};
}
+129
View File
@@ -0,0 +1,129 @@
import { defineRoles } from "../../fonts/roles";
import { asteriskWatermark } from "../../decor/Asterisk";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NorthlineLayoutFor } from "./layoutFor";
import { gotham, type PurpleRoles, type PurpleVariant } from "./variant";
const DM = "DM Sans" as const;
const G = "Gotham" as const;
const INK = "#060605";
export const PURPLE = "#530895";
export const YELLOW = "#FFD80B";
/**
* Purple Pop's type (docs/templates/12-purple-pop.md): DM Sans for names, amounts, dates and the table;
* Gotham (drawn in Montserrat, sizes through gotham()) for the labels, client lines, totals and footer;
* the "Invoice" wordmark in Open Sauce One 700, which is Poppins 800 here at the design's -0.08 em.
* Leadings are the design's pitch, tightened where a Voiced invoice carries more lines than the sample.
*/
export const PURPLE_ROLES = defineRoles({
wordmark: { face: "Open Sauce One", weight: 800, size: 94.07, leading: 84, tracking: -0.08, minSize: 48 },
taxCaption: { face: DM, weight: 700, size: 14, leading: 18, tracking: 0.15, caps: true },
brand: { face: DM, weight: 700, size: 17.77, leading: 21, minSize: 12 },
pill: { face: G, weight: 700, size: gotham(13.39), leading: 17, tracking: 0.13, caps: true, minSize: 8.5 },
label: { face: G, weight: 700, size: gotham(12), leading: 16, caps: true },
clientName: { face: G, weight: 700, size: gotham(14.39), leading: 18, caps: true },
clientLine: { face: G, weight: 400, size: gotham(10.16), leading: 15.5 },
amount: { face: DM, weight: 700, size: 32.74, leading: 36, minSize: 20, tnum: true },
date: { face: DM, weight: 700, size: 14.05, leading: 19.5, caps: true, minSize: 10 },
tableHead: { face: DM, weight: 700, size: 14, leading: 18, caps: true },
tableBody: { face: DM, weight: 400, size: 11.83, leading: 15 },
tableSub: { face: DM, weight: 400, size: 9, leading: 11.5 },
totalLabel: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
totalValue: { face: G, weight: 400, size: gotham(13), leading: 17 },
grand: { face: G, weight: 700, size: gotham(13), leading: 17, caps: true },
grandValue: { face: G, weight: 700, size: gotham(13), leading: 17 },
words: { face: DM, weight: 400, size: 10, leading: 13 },
wordsLabel: { face: DM, weight: 700, size: 10, leading: 13 },
termsLabel: { face: DM, weight: 700, size: 13, leading: 17, caps: true },
bullet: { face: DM, weight: 400, size: 11, leading: 14.2 },
bank: { face: G, weight: 400, size: gotham(11), leading: 12.5 },
contact: { face: G, weight: 400, size: gotham(10.14), leading: 15, minSize: 7 },
small: { face: DM, weight: 400, size: 9, leading: 12 },
fine: { face: DM, weight: 400, size: 7, leading: 10 },
signName: { face: DM, weight: 700, size: 9.5, leading: 13 },
footer: { face: DM, weight: 400, size: 7 },
} satisfies PurpleRoles);
/**
* Canva A4: content x 54.8..542.8, so the left margin is 54.8 and the right 52.48. The pill's top is y 77 (the
* row starts there) and the footer ends near y 772, so the bottom margin is 40 plus the footer reserve.
*/
export const PURPLE_VARIANT: PurpleVariant = {
id: "purple-pop",
roles: PURPLE_ROLES,
ink: INK,
purple: PURPLE,
yellow: YELLOW,
grey: "#E5E5E5",
rowGrey: "#EDEDE8",
cream: "#FEFFEE",
watermark: "#FFF7F7",
margins: { top: 70, right: 52.48, bottom: 40, left: 54.8 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
signatureImageH: 40,
chromeLeftW: 150,
head: { nameInset: 4.7, pillInset: 11.9, pillH: 33.2, pillPadX: 20, nameMaxW: 240, minNameW: 110 },
client: { inset: 12.3, pillH: 33.2, pillPadX: 12.3, colW: 183.4, colGap: 8.4, wordmarkInset: 12.4 },
rule: { w: 1.5, left: 10, right: 9.4 },
band: { amountInset: 17, datesInset: 23.1, gap: 12 },
table: {
narrowLeft: 122.1,
fullLeft: 4.7,
right: 3.4,
inset: 15,
headH: 46.6,
rowH: 32.7,
radiusHead: 23.3,
radiusRow: 16.35,
cols: { qty: 40, rate: 90, amount: 100 },
narrowDescMin: 100,
},
asterisk: { size: 71.5, offsetX: 2.2, offsetY: 19.5 },
dome: { x: 2.2, w: 65.3 },
totals: { boxW: 369.4, labelInset: 18.6, valueInset: 20.8, rowGap: 3, pillH: 41, pillValueInset: 13.7, wordsGap: 8 },
terms: { labelInset: 17, labelW: 118, signW: 190, besideMax: 90 },
footer: { ruleLeft: 12.6, ruleRight: 6.8, bankW: 143, contactLeft: 163.7, contactW: 158, addressLeft: 325.9, iconW: 11.2, iconGap: 9 },
gaps: {
client: { nominal: 35, min: 10 },
rule: { nominal: 18, min: 6 },
band: { nominal: 18, min: 6 },
table: { nominal: 20, min: 8 },
tail: { nominal: 14, min: 6 },
termsAbove: 8,
ruleGap: 7,
},
};
export const purplePop: TemplateDefinition = {
...catalogEntry("purple-pop"),
version: 1,
roles: PURPLE_ROLES,
margins: PURPLE_VARIANT.margins,
marginFloor: PURPLE_VARIANT.marginFloor,
decor: {
ink: INK,
background: PURPLE_VARIANT.cream,
// The pale watermarks sit under the flow, on every page, anchored to the page corners. Nothing is printed
// over them that needs a clear ground, so there are no keep-outs.
underlay: (frame) => [asteriskWatermark(frame, "top-right", PURPLE_VARIANT.watermark), asteriskWatermark(frame, "bottom-left", PURPLE_VARIANT.watermark)],
},
chrome: {
color: "#6f6f6f",
rule: null,
role: PURPLE_ROLES.footer,
leftWidth: PURPLE_VARIANT.chromeLeftW,
},
logo: {
box: { w: 200, h: 34 },
stackBelowTextWidth: 0,
defaultOn: false,
optIn: catalogEntry("purple-pop").logoOptIn === true,
slotWidth: () => 200,
slot: slotFor("purple-pop"),
available: () => ({ w: 200, h: 34 }),
},
Layout: NorthlineLayoutFor(PURPLE_VARIANT),
};
+169
View File
@@ -0,0 +1,169 @@
import type { Sides } from "../../engine/geometry";
import type { TypeToken } from "../../fonts/roles";
import type { GapRange } from "../neutral/variant";
/**
* Gotham (commercial) is drawn with Montserrat, which runs about 4% wider at the same size. Every Gotham-derived
* size is multiplied by this when a token is defined, so the substitute takes the width the design has. A real
* Gotham imported over the licensed name would need these tokens at factor 1.
*/
export const GOTHAM_SIZE = 0.96;
const round2 = (n: number) => Math.round(n * 100) / 100;
/** The size a Gotham design value takes in Montserrat. */
export const gotham = (designSize: number) => round2(designSize * GOTHAM_SIZE);
/** Type roles of Purple Pop (DM Sans, Gotham -> Montserrat, the wordmark in Poppins 800). */
export interface PurpleRoles {
/** The oversized "Invoice" wordmark. */
wordmark: TypeToken;
/** "TAX" above the wordmark on a tax invoice. */
taxCaption: TypeToken;
brand: TypeToken;
/** "INVOICE NO : 012345" in the grey pill. */
pill: TypeToken;
/** "BILLED TO:", "AMOUNT DUE:", "PAYMENT METHOD". */
label: TypeToken;
clientName: TypeToken;
clientLine: TypeToken;
amount: TypeToken;
date: TypeToken;
tableHead: TypeToken;
tableBody: TypeToken;
tableSub: TypeToken;
totalLabel: TypeToken;
totalValue: TypeToken;
grand: TypeToken;
grandValue: TypeToken;
words: TypeToken;
wordsLabel: TypeToken;
termsLabel: TypeToken;
bullet: TypeToken;
bank: TypeToken;
contact: TypeToken;
/** GSTIN and PAN under the vendor name. */
small: TypeToken;
fine: TypeToken;
signName: TypeToken;
footer: TypeToken;
}
/** Type roles of Citrus Split (Now -> Jost for the wordmark, DM Sans, Gotham -> Montserrat). */
export interface CitrusRoles {
title: TypeToken;
taxCaption: TypeToken;
brand: TypeToken;
metaLabel: TypeToken;
metaValue: TypeToken;
metaStrong: TypeToken;
billedLabel: TypeToken;
clientName: TypeToken;
clientLine: TypeToken;
headService: TypeToken;
headNum: TypeToken;
tableBody: TypeToken;
tableSub: TypeToken;
totalLabel: TypeToken;
totalValue: TypeToken;
grand: TypeToken;
grandValue: TypeToken;
words: TypeToken;
wordsLabel: TypeToken;
sectionLabel: TypeToken;
bank: TypeToken;
bullet: TypeToken;
contact: TypeToken;
small: TypeToken;
fine: TypeToken;
signName: TypeToken;
footer: TypeToken;
}
export type NorthlineId = "purple-pop" | "citrus-split";
interface Common {
margins: Sides;
marginFloor: Sides;
signatureImageH: number;
chromeLeftW: number;
}
/**
* Purple Pop: y values are design distances measured on the Canva A4 page (docs/templates/12-purple-pop.md)
* converted to flow gaps; x values are relative to the content edge (the left margin, x 54.8), so the same
* tokens serve every margin preset and both page sizes.
*/
export interface PurpleVariant extends Common {
id: "purple-pop";
roles: PurpleRoles;
ink: string;
purple: string;
yellow: string;
grey: string;
/** Alternate item row fill (the grey at 68% on cream, flattened). */
rowGrey: string;
cream: string;
watermark: string;
/** Header: inset of the vendor name and of the pill's right edge from the content edges. */
head: { nameInset: number; pillInset: number; pillH: number; pillPadX: number; nameMaxW: number; minNameW: number };
/** Client column: label inset, pill padding, the column's width and the gap to the wordmark. */
client: { inset: number; pillH: number; pillPadX: number; colW: number; colGap: number; wordmarkInset: number };
rule: { w: number; left: number; right: number };
/** Amount due / dates band. */
band: { amountInset: number; datesInset: number; gap: number };
/** Table: left offset of the narrow variant, the right inset, the pill paddings and the column widths. */
table: { narrowLeft: number; fullLeft: number; right: number; inset: number; headH: number; rowH: number; radiusHead: number; radiusRow: number; cols: { qty: number; rate: number; amount: number }; narrowDescMin: number };
asterisk: { size: number; offsetX: number; offsetY: number };
dome: { x: number; w: number };
totals: { boxW: number; labelInset: number; valueInset: number; rowGap: number; pillH: number; pillValueInset: number; wordsGap: number };
terms: { labelInset: number; labelW: number; signW: number; besideMax: number };
footer: { ruleLeft: number; ruleRight: number; bankW: number; contactLeft: number; contactW: number; addressLeft: number; iconW: number; iconGap: number };
gaps: {
/** Header row (or its GSTIN/PAN lines) to the client row. */
client: GapRange;
rule: GapRange;
band: GapRange;
table: GapRange;
tail: GapRange;
termsAbove: number;
ruleGap: number;
};
}
/**
* Citrus Split (docs/templates/13-citrus-split.md). x values are relative to the content edge (the left
* margin, x 73.3 on the Canva page); the grey stripe is anchored to the RIGHT margin, so on Letter the extra
* width goes to the left column.
*/
export interface CitrusVariant extends Common {
id: "citrus-split";
roles: CitrusRoles;
ink: string;
text: string;
blush: string;
panel: string;
orange: string;
label: string;
/** Orange of the icon glyphs. */
icon: string;
stripeW: number;
/** The header's panel cell: inset of the logo from the stripe's left edge, and the gap between a mark and the name. */
head: { panelInset: number; markGap: number };
/** Client block: inset of its text from the content edge and the gap to the stripe. */
client: { inset: number; gap: number; labelGap: number; nameGap: number };
/** Meta row: inset of the first cell, minimum cell widths, panel-side start, gap between a label and its value. */
meta: { inset: number; noMin: number; dateMin: number; dueW: number; panelInset: number; labelGap: number };
/** Table: left offset from the content edge, header height, the SERVICE text inset, body text inset and row padding, columns. */
table: { left: number; headH: number; headInset: number; bodyInset: number; padY: number; padYMin: number; descRight: number; cols: { qty: number; rate: number; amount: number } };
separator: { color: string; h: number };
totals: { labelInset: number; valueInset: number; rowGap: number; barH: number; barInset: number; barGap: number; sepGap: number; wordsGap: number };
closing: { methodInset: number; bankInset: number; bulletInset: number; bulletText: number; bulletGap: number; sectionGap: number; labelGap: number; iconSize: number; iconGap: number; iconInset: number; contactGap: number; signW: number; clientTop: number; termsBesideMax: number };
gaps: {
/** Client block above the table (only when the invoice is more than a page). */
client: GapRange;
meta: GapRange;
table: GapRange;
tail: GapRange;
};
}
export type NorthlineVariant = PurpleVariant | CitrusVariant;
+4
View File
@@ -5,6 +5,8 @@ import { crimsonGrid } from "./ledger/crimson";
import { tangerineLedger } from "./ledger/tangerine";
import { slateBand } from "./band/slate";
import { tealSwoosh } from "./band/teal";
import { citrusSplit } from "./northline/citrus";
import { purplePop } from "./northline/purple";
import { monogram } from "./neutral/monogram";
import { serenity } from "./neutral/serenity";
@@ -18,6 +20,8 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
"tangerine-ledger": tangerineLedger,
"slate-band": slateBand,
"teal-swoosh": tealSwoosh,
"purple-pop": purplePop,
"citrus-split": citrusSplit,
};
export const DEFAULT_TEMPLATE_ID = "classic";