Add Ledger and Band template families with decor primitives
Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from the Canva PDFs with grid and band tables that survive page breaks, full-bleed fixed bands, pennant tabs, gradient corner swooshes with keep-outs, Carbon icon circles, and the user's logo in the measured slot (knockout or white chip on the dark Slate band). Adds static Poppins 500-700 and Inter fonts. Thumbnails regenerated for all eight templates; the item-count sweeps run in their own test file.
This commit is contained in:
Vendored
+19
@@ -126,3 +126,22 @@ Everything can be built with View backgroundColor and borders. Svg is only neede
|
||||
- Near-duplicate: 11.pdf ('Rimberio'). Same structure and placeholder copy: Invoice Number/Date, vendor and BILL TO columns, 4-column ruled grid with a coloured header row, NOTES/TERMS beside a ruled totals box, PAYMENT METHOD and PREPARED BY. Differences: orange accent, Inter body with a Poppins Bold title, mirrored header (name left, INVOICE right), filled Total row, corner shapes instead of the rule and footer band. Build 7 and 11 as one ruled-grid component with theme props (accent, fontFamily, headerMirror, totalRowFill, decoration set).
|
||||
- Weaker resemblance: 2.pdf (ruled grid with a yellow header and a ruled total cell).
|
||||
- None of 7/8/9 resembles the in-app InvoiceDocument.tsx (IBM Plex, hairline rules, title-left/meta-right). The closest in the set is 3.pdf. Not similar to format.pdf (identical to 4.pdf).
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built as `src/pdf/templates/ledger/` (one family component, `Layout.tsx`, shared with Tangerine Ledger; tokens in `crimson.tsx`, planning in `plan.ts`) on the decor primitives in `src/pdf/decor/`. Measured against `7.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-crimson-grid.png`, left Canva, right ours).
|
||||
|
||||
**Matches the design (measured, A4):** title baseline 100.6 pt at x 59.5, 43.8 pt Poppins Bold; the 1.5 pt #A00000 rule at y 128.85; meta baselines 173.6 / 192.3; party heading to first line 23.9 pt; BILL TO at x 304.3; table text at x 69.7, header row 42 pt (Canva 40.5 plus the 0.75 pt rules) and 40.5 pt single-line rows; header fill #A00000 with white inner verticals and a black outer box; the 63 pt full-bleed band; all colours exact.
|
||||
|
||||
**Intentional deviations, and why:**
|
||||
- **Margins.** Top 53 and sides 59.55 as designed. The bottom margin floor is 26 pt plus a 53 pt footer reserve, so the flow is at least 79 pt (band 63 + 16) above the page bottom on every margin preset and on Letter. The footer text is placed by `chrome.bottom` (14 pt above the edge), inside the band whatever the preset.
|
||||
- **Header.** Title and brand block are one row without `alignItems: flex-end` (lint bans it): the brand block is placed from the measured slot offsets. 'TAX INVOICE' (about 281 pt) shrinks to at most 60 % of its size before the brand block (at most 40 % of the width) is squeezed. A wordmark logo replaces the whole flame + name lockup; a mark logo sits left of the vendor name in 15.44 pt caps (13.7 pt gap); without a logo only the name shows.
|
||||
- **Meta** adds 'Due Date:' and 'PO No:' lines (same 18.7 pt pitch); 'Invoice Number:' keeps the design's wording.
|
||||
- **Parties.** The vendor block's heading is the vendor name in caps, followed by address, phone, e-mail, PAN and the GSTIN (slot). Canva's PREPARED BY (x 322.4, misaligned) is normalised to the BILL TO column (x 304.3) and renamed AUTHORISED SIGNATORY.
|
||||
- **Table.** The grid is drawn as per-cell borders (`TableRows` `grid`), not lines over the text, so it survives page breaks: the header repeats with its rules and every row owns its bottom rule. An HSN/SAC column (at least 56 pt) appears on tax invoices. Unit Price shows '₹1,200.00/hr' on one line when it fits, else the amount over 'per hour' at 8.5 pt; the money columns are at least 100 / 45 / 100 pt and grow to their widest amount. Rows grow with wrapped descriptions (padY 12.55).
|
||||
- **Totals box** is widened (about 210 pt: label at least 96, value at least 100) with 28 pt rows instead of 40.5, so Sub-Total, Discount, Taxable value, CGST/SGST (or UTGST, IGST via `Slot.TaxRows`) and Total fit. The Total row is bold 12 pt without a fill, as designed.
|
||||
- **Notes / Terms** sit left of the totals box when they are at most 110 pt tall; longer notes flow below the table (a long note must never make the unbreakable totals block unplaceable). Amount in words and the reverse-charge line run full width under the box.
|
||||
- **Closing.** Bank lines are 10.5 / 14.5 pt (not 12 / 16.5) so six lines fit; the signature is the user's image or the computer-generated line with the signatory name.
|
||||
- **Footer band.** The contact line is 'email | phone' (no website field exists; the GSTIN is printed through its slot only) at 11 pt with 0.05 em tracking instead of 12.44 pt / 0.064 em, and is fitted to one line. The standard footer (supplier name, 'Generated with Voiced', Page n of N) is added inside the band in cream at 7 pt; the continuation band on pages 2+ is grey so it stays readable on white.
|
||||
- **Letter.** The 49.9 pt deficit comes out of the rule gap (21.5 to 12), meta (31.4 to 20), party (22.3 to 14), table (27.4 to 16), totals (11.3 to 6) and notes (12 to 8) gaps, 50.4 pt in all; type and row padding are not scaled. The band stays 63 pt at the bottom edge.
|
||||
- **Grid colour.** The Canva artefact (black top/bottom edges on cells 1 and 3 only) is normalised to a black outer box with white inner verticals in the header, as the doc recommends.
|
||||
|
||||
Vendored
+17
@@ -128,3 +128,20 @@ No raster XObjects. Everything is vector shapes plus live text.
|
||||
- Near-duplicate: 10.pdf ('Liceria & Co.'). The body is identical: Date / Billed to / From, the full-bleed grey SERVICE-QTY-PRICE-TOTAL band, rule-separated rows, TOTAL with an accent dash, inline Payment method / Note. The header differs: teal/grey corner swoosh artwork instead of the slate and blue bands, a dark letter-spaced INVOICE on white, and NO. in the right column. 10 also has a 'Contact:' block instead of the icon footer. Build 9 and 10 as one component with header/decoration variants.
|
||||
- Shares Montserrat and letter-spaced uppercase INVOICE/headers with format.pdf (identical to 4.pdf), but the structure is different.
|
||||
- Does not resemble the in-app InvoiceDocument.tsx; the closest in the set is 3.pdf.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built as `src/pdf/templates/band/` (one family component, `Layout.tsx`, shared with Teal Swoosh; tokens in `slate.tsx`, planning in `plan.ts`) on the decor primitives in `src/pdf/decor/`. Measured against `9.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-slate-band.png`, left Canva, right ours; also `-nologo` and the logo variants).
|
||||
|
||||
**Matches the design (measured, A4):** dark band 0-103.5, blue band to 157.35 (53.8 tall), INVOICE in Montserrat 800 30 pt with 0.224 em tracking at x 59.5; the name and 'NO.' baseline at 74.7 (Canva 74.4 / 74.8), the number right-aligned to the content edge; Date row baseline 193.4 (label and value share it); party heading to first line 21.3 pt; header band 58.3 tall and full bleed; table text at x 69.4 (the 9.8 pt inset) with 2.25 pt #C1C5CE rules from 69 to 526; TOTAL with the 41.85 x 3 pt dash on its baseline; the footer rule 86.7 pt above the page bottom.
|
||||
|
||||
**Intentional deviations, and why:**
|
||||
- **Bands.** Both are first-page decor inside `FirstPageHeader`, full bleed from the page edges (so they also fill Letter and any margin preset); their heights follow the flow (page top margin + logo row + gap), so they stay glued to the logo row whatever the preset. They overlap by half a point so no viewer shows a seam.
|
||||
- **Logo (dark surface).** The slot is the measured 25 x 31 mark / 99 x 31 lockup. A logo with alpha uses its white knockout; one without alpha sits on a white rounded chip (4 pt padding) drawn as decor behind it. A wordmark replaces the name; a mark keeps the vendor name beside it in white Medium 12.
|
||||
- **Number and due date.** 'NO. {number}' in the band; 'Due:' goes in the right column of the Date row (the From column), 'PO No:' under Date.
|
||||
- **Table.** SERVICE / QTY / PRICE / TOTAL with PRICE right-aligned (centred prices left a ragged edge); the HSN/SAC code is a 10 pt sub-line under the description; the rate unit is '/hr' on the same line or a second 10 pt line when it does not fit. Rows are 44 pt (padY 13.5 + the rule) instead of 52.5, the compact density the doc offers, so a Voiced invoice (longer supplier block, tax rows, words, bank) still fits a page; the header band keeps its 58.3 pt. Money columns are at least 55 / 95 / 105 pt and grow to their widest amount (a 12-digit amount does not overflow from 1 lakh up).
|
||||
- **Totals.** The design has only TOTAL. A right-anchored stack (SUBTOTAL, DISCOUNT, TAXABLE VALUE, CGST/SGST or IGST via `Slot.TaxRows`, tracked Bold 11 labels over Regular 12 amounts, 20 pt pitch) and a thin rule sit above the TOTAL row; the amount in words runs full width below it.
|
||||
- **Payment, note, signature.** 'Payment details:' is a label plus bank lines (the inline single value does not scale); the Note sits under it when it is at most 100 pt tall, else flows below the table; the signature (the user's image or the signatory name and the computer-generated line) is the right column.
|
||||
- **Footer.** Circled Carbon icons (phone, e-mail instead of the globe because Voiced has no website field, location) with 1.2 pt rings, drawn as static Svg decor on every page. Decor is not audited, so each text is fitted to one line (phone, e-mail) or two with an ellipsis (address); the full address is printed in the From block. The standard footer (name, 'Generated with Voiced', Page n of N) is added below the icons in grey 7 pt.
|
||||
- **Margins.** Top 55.75 (the logo top), sides 59.55, bottom floor 20 plus a 79 pt reserve: the flow ends at least 99 pt above the page bottom (rule 86.7 + 12), on every preset.
|
||||
- **Letter.** The 49.9 pt deficit comes out of the band's bottom padding (16.5 to 8), the gaps to the date row, parties and table, and the gap above the totals (54 pt of slack); type, rows and the header band are not scaled.
|
||||
|
||||
Vendored
+16
@@ -197,3 +197,19 @@ Near-duplicate of 9.pdf. Both use the same Montserrat type scale, and these elem
|
||||
- Payment method and Note.
|
||||
|
||||
9.pdf swaps the swooshes for a slate top bar (logo plus NO.), a blue full-width 'INVOICE' band and an icon contact footer. 10.pdf has a logo/brand row, swoosh corners and a 'Contact:' block. Build 9 and 10 as one layout component with a header/decor variant. Not related to format.pdf or the current IBM Plex in-app template.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built as `src/pdf/templates/band/` (the family component shared with Slate Band; tokens in `teal.tsx`) on the decor primitives in `src/pdf/decor/` (`CornerSwoosh`, with `LinearGradient` fills and keep-out rectangles). Measured against `10.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-teal-swoosh.png`, left Canva, right ours).
|
||||
|
||||
**Matches the design (measured, A4):** logo mark at (59.5, 59.5); INVOICE in Montserrat 800 30 pt with 0.26 em tracking, baseline 144; Date and 'NO.' on one row (x 59.5 and 342), Billed to / From at x 59.5 / 342 (270.5 and 193.7 wide); the full-bleed #EFF1F2 header band 58.3 tall, 2.25 pt #C2C7CF rules; the teal / gunmetal / silver corner art at the design's coordinates (the paths are the spec's, mirrored numerically for the top-right cluster, gradients flipped with them).
|
||||
|
||||
**Intentional deviations, and why:**
|
||||
- **Swooshes.** Static Svg paths with gradients in a single `Defs` (react-pdf resolves `url(#id)` from the first direct `Defs` only), anchored to the page corners (`left 0 / bottom 0`, `right 0 / top 0`, 342 x 188 pt), so they also fit Letter. The bottom-left cluster is fixed on every page. The top-right cluster is **page 1 only** (it sits in `FirstPageHeader`): on pages 2+ the table band starts at the top margin right under where the crescent would be, and the flow may not enter a keep-out. The gradient stops (dark at the teal edge, a highlight about two thirds along, dark again at the page edge) were read off the rendered original; the doc lists only the colours.
|
||||
- **Keep-outs.** Staircases of 8 pt strips follow the silver crescent's inner edge plus 4 pt, per corner (bottom-left on every page, top-right on page 1); the audit's `keepout` check runs against them for text, images and Svg, fixed chrome included. The bottom padding is 110 pt so the flow stays above the crescent on every page and size; the footer text starts at x 200 (right of the crescent at the footer's height, no supplier name there), and the continuation band keeps its normal place.
|
||||
- **Header.** The brand block stays left of x 440 (the design's limit): a mark logo has the vendor name beside it at 13 pt Bold (the design's 10.75 pt is a short placeholder), wrapping to more lines when long; a wordmark replaces the name; the tagline slot is dropped.
|
||||
- **Meta.** Left column: Date, then 'Due:'; right column: 'NO. {number}', then 'PO:'. (The design's second meta line is added; everything below shifts down by one line, 16.5 pt.)
|
||||
- **Table, totals.** As for Slate Band (docs/templates/09-slate-band.md): PRICE and TOTAL right-aligned, HSN/SAC as a sub-line, 44 pt rows instead of 52.5, a right-anchored stack of SUBTOTAL / DISCOUNT / TAXABLE VALUE / CGST+SGST (or IGST) above TOTAL with the 41.85 x 3 pt teal dash on its baseline, amount in words under it.
|
||||
- **Bottom block.** The bank details (headed 'Payment details:') take the 'Contact:' column on the right (x 345-526, 181 pt), with the signature below; the Note sits bottom left when it is at most 100 pt tall, else flows below the table. Vendor phone and e-mail are in the From block, so 'Contact:' is not repeated.
|
||||
- **Margins.** Top 55, sides 59.55, bottom floor 24 plus an 86 pt reserve (110 in all).
|
||||
- **Letter.** The 49.9 pt deficit comes out of the brand-to-title gap (26.2 to 14), the gaps to the date row, parties, table and totals (58 pt of slack); rows, bands and the art keep their size.
|
||||
|
||||
+14
@@ -173,3 +173,17 @@ Near-duplicate of 7.pdf. Both carry the same 'Studio Shodwe' content and the sam
|
||||
Differences: 7.pdf uses Poppins for body text, a dark-red accent, a flame logo at top-right, a red rule under the header and a full-width red contact footer band. 11.pdf uses Inter body, #FF5F2F, brand text at left and edge tabs.
|
||||
|
||||
Build one 'Ledger Grid' component with an accent colour and a decor variant (tabs or footer band). Of 10–13, this is structurally closest to the current in-app template. Unrelated to format.pdf.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
Built as `src/pdf/templates/ledger/` (the family component shared with Crimson Grid; tokens in `tangerine.tsx`) on the decor primitives in `src/pdf/decor/` (`Pennant`). Measured against `11.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-tangerine-ledger.png`, left Canva, right ours).
|
||||
|
||||
**Matches the design (measured, A4):** INVOICE baseline 80.6 pt, right edge on the content edge (536.7), Poppins Bold 33; 'Invoice Number' / 'Invoice Date' baselines 168.6 / 187.3 (Canva 168.7 / 187.4); party headings at x 59.5 and 284.1, 23.9 pt above the first line; table header and row pitch (header 41.5 pt single-line incl. rules, rows 37.5); accent #FF5F2F header, filled Total row with white separators; both pennant tabs at the design's coordinates.
|
||||
|
||||
**Intentional deviations, and why:**
|
||||
- **Margins.** Top 46 pt: Poppins's tall ascent puts the 33 pt title's box top 11.5 pt above the cap top, so the box (not the ink) starts at the margin. Sides 59.55 with a floor of 45 on every preset, so Narrow (36) never puts text under the 35 pt tabs. Bottom 60 + 16 footer reserve.
|
||||
- **Header.** The brand zone is the measured 220 x 36 allowance at y 62-98. A wordmark logo replaces the 'Rimberio' text; a mark logo sits left of the vendor name in Poppins Bold 18; without a logo the name stands in. The brand block is at most half the content width, the title shrinks to at most 60 % before it would meet it.
|
||||
- **Tabs.** Two static Svg paths anchored to the page corners (top right 35.1 x 230.5, bottom left 35.3 x 218.6), fixed on every page; the Canva paths are simplified to their visible part with the same curves. They keep their size on Letter.
|
||||
- **Meta, parties, table, totals, notes, closing, elastic gaps:** as for Crimson Grid (docs/templates/07-crimson-grid.md), with Tangerine's own numbers: meta gap 59 to 32, party 20.8 to 12, table 25.4 to 14, totals 23.3 to 10 (64 pt of slack on Letter), cell padX 9.8, padY 10.8, totals box at least 205 pt. The Total row is filled with the accent (white 12 pt Inter Bold, white separator).
|
||||
- **Type.** Poppins 700 for brand and title, Inter 400/600/700 for the rest (static TTFs; Inter and Poppins both carry U+20B9, Plex still ends every stack). Inter's strongly kerned pairs ('7,', '7.') make pdf.js report an amount as two text items; the text is unchanged and the harness joins such fragments before checking the rupee sign.
|
||||
- **Footer.** The standard footer (supplier name, 'Generated with Voiced', Page n of N) at 7 pt in grey; the design has none. The left tab (x 0-35.3) clears the footer at x 59.5 by 24 pt.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"logo:measure": "node scripts/logo/measure-placeholders.mjs",
|
||||
"logo:sheet": "node scripts/logo/contact-sheet.mjs",
|
||||
"decor:script-word": "node scripts/decor/script-word.mjs",
|
||||
"decor:icons": "node scripts/decor/icons.mjs",
|
||||
"thumbnails": "node scripts/thumbnails.mjs",
|
||||
"templates:compare": "node scripts/templates/compare.mjs"
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
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 AND 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.
|
||||
@@ -152,6 +152,59 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"family": "Inter",
|
||||
"folder": "inter",
|
||||
"license": "OFL-1.1",
|
||||
"licenseFile": "OFL.txt",
|
||||
"fonts": [
|
||||
{
|
||||
"file": "Inter-Regular.ttf",
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"sha256": "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82",
|
||||
"fsType": 0,
|
||||
"upm": 2048,
|
||||
"ascent": 1984,
|
||||
"descent": -494,
|
||||
"lineGap": 0,
|
||||
"capHeight": 1490,
|
||||
"xHeight": 1118,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": true
|
||||
},
|
||||
{
|
||||
"file": "Inter-SemiBold.ttf",
|
||||
"weight": 600,
|
||||
"style": "normal",
|
||||
"sha256": "78a843fade9d4612a5567302fb595b56976eb5fcebf4fea5a5912d638bafcde3",
|
||||
"fsType": 0,
|
||||
"upm": 2048,
|
||||
"ascent": 1984,
|
||||
"descent": -494,
|
||||
"lineGap": 0,
|
||||
"capHeight": 1490,
|
||||
"xHeight": 1118,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": true
|
||||
},
|
||||
{
|
||||
"file": "Inter-Bold.ttf",
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"sha256": "288316099b1e0a47a4716d159098005eef7c0066921f34e3200393dbdb01947f",
|
||||
"fsType": 0,
|
||||
"upm": 2048,
|
||||
"ascent": 1984,
|
||||
"descent": -494,
|
||||
"lineGap": 0,
|
||||
"capHeight": 1490,
|
||||
"xHeight": 1118,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"family": "Montserrat",
|
||||
"folder": "montserrat",
|
||||
@@ -369,6 +422,51 @@
|
||||
"xHeight": 548,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": false
|
||||
},
|
||||
{
|
||||
"file": "Poppins-Medium.ttf",
|
||||
"weight": 500,
|
||||
"style": "normal",
|
||||
"sha256": "90373e7d838d32468438fc3e152dca0bdb12edcab99ea639f158790b1ba1fd05",
|
||||
"fsType": 0,
|
||||
"upm": 1000,
|
||||
"ascent": 1050,
|
||||
"descent": -350,
|
||||
"lineGap": 100,
|
||||
"capHeight": 697,
|
||||
"xHeight": 551,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": false
|
||||
},
|
||||
{
|
||||
"file": "Poppins-SemiBold.ttf",
|
||||
"weight": 600,
|
||||
"style": "normal",
|
||||
"sha256": "d3bf1bdaf0550e83da9ac0b1d1d9fe6db086835a83aa28578e609a394b9a0286",
|
||||
"fsType": 0,
|
||||
"upm": 1000,
|
||||
"ascent": 1050,
|
||||
"descent": -350,
|
||||
"lineGap": 100,
|
||||
"capHeight": 701,
|
||||
"xHeight": 554,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": false
|
||||
},
|
||||
{
|
||||
"file": "Poppins-Bold.ttf",
|
||||
"weight": 700,
|
||||
"style": "normal",
|
||||
"sha256": "983676516167748b74de6f4771fb384c664fd913acb8b471122ecacf5da5ea6c",
|
||||
"fsType": 0,
|
||||
"upm": 1000,
|
||||
"ascent": 1050,
|
||||
"descent": -350,
|
||||
"lineGap": 100,
|
||||
"capHeight": 705,
|
||||
"xHeight": 558,
|
||||
"hasRupee": true,
|
||||
"hasTabularNums": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
// Extracts the SVG path data of a few Carbon icons into src/pdf/decor/icons.generated.ts, so the PDF code
|
||||
// carries plain path strings and never imports React icon components (src/pdf stays free of the DOM).
|
||||
// Run: npm run decor:icons
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const ICONS = { phone: "Phone", email: "Email", location: "Location" };
|
||||
|
||||
const out = {};
|
||||
for (const [key, name] of Object.entries(ICONS)) {
|
||||
const src = readFileSync(path.join(ROOT, "node_modules/@carbon/icons-react/es", `${name}.js`), "utf8");
|
||||
const viewBox = /viewBox: "([^"]+)"/.exec(src)?.[1];
|
||||
const paths = [...src.matchAll(/jsx\("path", \{ d: "([^"]+)" \}\)/g)].map((m) => m[1]);
|
||||
if (!viewBox || paths.length === 0 || /jsx\("(circle|rect|polygon)"/.test(src)) throw new Error(`${name}: unexpected icon structure`);
|
||||
out[key] = { viewBox, paths };
|
||||
}
|
||||
|
||||
const body = `// Generated by \`npm run decor:icons\` (scripts/decor/icons.mjs). Do not edit by hand.
|
||||
// Path data of IBM Carbon icons, Apache-2.0 (see ICONS-NOTICE.md).
|
||||
|
||||
export interface IconArt {
|
||||
/** "minX minY width height", as in the Carbon source. */
|
||||
viewBox: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export const ICONS = ${JSON.stringify(out, null, 2)} as const satisfies Record<string, IconArt>;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
`;
|
||||
writeFileSync(path.join(ROOT, "src/pdf/decor/icons.generated.ts"), body);
|
||||
console.log(`icons.generated.ts: ${Object.keys(out).join(", ")}`);
|
||||
@@ -1,17 +1,20 @@
|
||||
// Side-by-side review of the Neutral templates: the Canva original (left) against our render (right), plus
|
||||
// the multi-page, Letter and long-text samples. Run: npm run templates:compare [-- linea monogram serenity]
|
||||
// Output: /tmp/invoice-c3/. With --numbers it also prints text baselines (Canva vs ours) for tuning.
|
||||
// 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.
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { CANVA_DIR, loadCanvas, loadRenderer, rasterize, textItems } from "./lib.mjs";
|
||||
|
||||
const OUT = "/tmp/invoice-c3";
|
||||
const argv = process.argv.slice(2);
|
||||
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c4";
|
||||
const SCALE = 1.6;
|
||||
const PDF_NO = { marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 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 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 : ["linea", "monogram", "serenity"];
|
||||
const targets = ids.length ? ids : ["crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh"];
|
||||
|
||||
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";
|
||||
@@ -36,7 +39,7 @@ async function main() {
|
||||
const no = PDF_NO[id];
|
||||
const canvaBytes = readFileSync(path.join(CANVA_DIR, `${no}.pdf`));
|
||||
const canva = await rasterize(canvaBytes, 1, SCALE);
|
||||
const rows = id === "linea" ? 7 : 5;
|
||||
const rows = ROWS[id] ?? 5;
|
||||
const NAMES = ["Brand consultation", "Logo design", "Website design", "Social media templates", "Brand photography", "Brand guide", "Social media templates"];
|
||||
// Short one-line descriptions like the Canva sample, so positions can be compared row by row.
|
||||
const items = (hsn) => NAMES.slice(0, rows).map((description, i) => ({ id: i + 1, description, mode: "rate", rate: 100, unit: "hour", quantity: 1, amount: 0, sortOrder: i, ...(hsn ? { hsnSac: "998397" } : {}) }));
|
||||
@@ -63,7 +66,9 @@ async function main() {
|
||||
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"));
|
||||
}
|
||||
for (const logo of ["square", "wide"]) {
|
||||
// Dark-surface templates: the white knockout variant (a logo with alpha) next to the chip fallback (no alpha) above.
|
||||
const logoKinds = DARK.includes(id) ? ["square", "wide", "knockout"] : ["square", "wide"];
|
||||
for (const logo of logoKinds) {
|
||||
const x = await render(sample({ logo }));
|
||||
writeFileSync(path.join(OUT, `${id}-A4-logo-${logo}.png`), (await rasterize(x.result.bytes, 1, SCALE)).toBuffer("image/png"));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function InvoicePage({ frame, model, template, children }: InvoicePagePro
|
||||
const marginBottom = pad.bottom - frame.footerReserve;
|
||||
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) ?? [])];
|
||||
const items: DecorItem[] = [...(decor.everyPage?.(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 }} /> });
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export function InvoicePage({ frame, model, template, children }: InvoicePagePro
|
||||
pad: { ...pad, left: pad.left + inset.left, right: pad.right + inset.right },
|
||||
contentWidth: frame.contentWidth - inset.left - inset.right,
|
||||
};
|
||||
const bandInset = chrome.bandInset ?? inset;
|
||||
const bandBox = { left: pad.left + bandInset.left, width: frame.contentWidth - bandInset.left - bandInset.right };
|
||||
return (
|
||||
<Page
|
||||
size={frame.page.size}
|
||||
@@ -57,12 +59,15 @@ export function InvoicePage({ frame, model, template, children }: InvoicePagePro
|
||||
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
|
||||
<PageChrome
|
||||
frame={chromeFrame}
|
||||
footerLeft={model.supplier.name}
|
||||
footerLeft={chrome.footerName === false ? undefined : model.supplier.name}
|
||||
footerLeftWidth={chrome.leftWidth}
|
||||
footerCenter={model.footerText}
|
||||
role={chrome.role}
|
||||
color={chrome.color}
|
||||
band={{ left: bandLeft, right: "continued" }}
|
||||
band={{ left: bandLeft, ...(chrome.bandRight === false ? {} : { right: "continued" }) }}
|
||||
bandBox={bandBox}
|
||||
bandColor={chrome.bandColor}
|
||||
bottom={chrome.bottom}
|
||||
decor={items}
|
||||
/>
|
||||
</Page>
|
||||
|
||||
@@ -29,8 +29,12 @@ export interface TableLook {
|
||||
padX?: number;
|
||||
/** Vertical cell padding, pt per side. Default 4. */
|
||||
padY?: number;
|
||||
/** Vertical padding of the header row; default `padY`. */
|
||||
headPadY?: number;
|
||||
/** Bottom border of each row in pt (counted in the row height). Default 0. */
|
||||
border?: number;
|
||||
/** Bottom border of the header row; default `border`. */
|
||||
headBorder?: number;
|
||||
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
|
||||
padRight?: number[];
|
||||
faceOverrides?: Readonly<Record<string, string>>;
|
||||
@@ -105,12 +109,12 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
|
||||
|
||||
const { widths } = colsDetailed(width, columns, gap);
|
||||
const inner = widths.map((w, i) => Math.max(0, w - 2 * padX - (input.padRight?.[i] ?? 0)));
|
||||
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[]) => {
|
||||
const heightOf = (role: RoleName | TypeToken, cells: TableCellValue[], py = padY, bd = border) => {
|
||||
const hs = cells.map((c, i) => cellHeight(measurer, role, c, inner[i], input));
|
||||
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * padY + border };
|
||||
return { lines: Math.max(...hs.map((h) => h.lines)), height: Math.max(...hs.map((h) => h.height)) + 2 * py + bd };
|
||||
};
|
||||
|
||||
const headH = heightOf(roles.head, header).height + (input.headerTailH ?? 0);
|
||||
const headH = heightOf(roles.head, header, input.headPadY ?? padY, input.headBorder ?? border).height + (input.headerTailH ?? 0);
|
||||
const measured = rows.map((r) => heightOf(roles.body, r));
|
||||
const rowHeights = measured.map((r) => r.height);
|
||||
|
||||
@@ -150,6 +154,24 @@ export interface TableRowsProps extends TableLook {
|
||||
* Pass the same height as `headerTailH` to planItemsTable.
|
||||
*/
|
||||
headerTail?: ReactNode;
|
||||
/**
|
||||
* Ruled grid: a vertical rule after every column, one on the outer left edge, and a rule above the
|
||||
* header cells (each row draws its own bottom rule through `border`). The cells stretch to the row's
|
||||
* height so the verticals run the full row. In the header the inner verticals take `headerColor` and
|
||||
* the outer edges (top, left, right) `headerEdge`; both default to `color`. Plan with `gridPadRight(...)` so wrapping uses the real
|
||||
* inner width, and add `grid.width` to `headerTailH` for the header's top rule.
|
||||
*/
|
||||
grid?: { width: number; color: string; headerColor?: string; headerEdge?: string };
|
||||
/**
|
||||
* Header band that bleeds to the page edges: `left` is the page-edge-to-table distance, `width` the page
|
||||
* width. The header group is fixed (it repeats), so the bleed passes the safe-zone audit.
|
||||
*/
|
||||
bleed?: { left: number; width: number; bg: string };
|
||||
}
|
||||
|
||||
/** Extra right padding per column that stands in for a grid's vertical rules, for planItemsTable's `padRight`. */
|
||||
export function gridPadRight(columns: number, ruleWidth: number): number[] {
|
||||
return Array.from({ length: columns }, (_, i) => (i === 0 ? 2 : 1) * ruleWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,39 +179,68 @@ export interface TableRowsProps extends TableLook {
|
||||
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because
|
||||
* the repeated header is positioned against the container's top.
|
||||
*/
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, ...look }: TableRowsProps) {
|
||||
export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, grid, bleed, ...look }: TableRowsProps) {
|
||||
const padX = look.padX ?? 0;
|
||||
const padY = look.padY ?? 4;
|
||||
const headPadY = look.headPadY ?? padY;
|
||||
const border = look.border ?? 0;
|
||||
const headBorder = look.headBorder ?? border;
|
||||
const { widths } = colsDetailed(width, columns, gap);
|
||||
const cellStyle = (extra?: Record<string, unknown>) => ({
|
||||
...(border ? { borderBottomWidth: border, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||
const cellStyle = (extra?: Record<string, unknown>, bd = border) => ({
|
||||
...(bd ? { borderBottomWidth: bd, borderBottomColor: borderColor ?? "#000000", borderBottomStyle: "solid" } : {}),
|
||||
...extra,
|
||||
});
|
||||
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string) =>
|
||||
values.map((v, i) => (
|
||||
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
|
||||
{cellParts(v).map((part, li) => (
|
||||
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{partText(part)}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
));
|
||||
const cells = (role: RoleName | TypeToken, values: TableCellValue[], color?: string, isHeader = false) =>
|
||||
values.map((v, i) => {
|
||||
const edge = grid?.width ?? 0;
|
||||
const last = i === values.length - 1;
|
||||
const inner = isHeader ? (grid?.headerColor ?? grid?.color) : grid?.color;
|
||||
const outer = isHeader ? (grid?.headerEdge ?? grid?.color) : grid?.color;
|
||||
const ruleW = edge * (i === 0 ? 2 : 1);
|
||||
return (
|
||||
<Cell
|
||||
key={i}
|
||||
padding={{ top: isHeader ? headPadY : padY, bottom: isHeader ? headPadY : padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}
|
||||
borderLeft={grid && i === 0 ? edge : 0}
|
||||
borderRight={edge}
|
||||
style={
|
||||
grid
|
||||
? {
|
||||
flexGrow: 1,
|
||||
flexBasis: "auto",
|
||||
borderLeftColor: outer,
|
||||
borderRightColor: last ? outer : inner,
|
||||
...(isHeader ? { borderTopWidth: edge, borderTopColor: outer } : {}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{cellParts(v).map((part, li) => (
|
||||
<Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0) - ruleW)} align={align[i]} faceOverrides={look.faceOverrides}>
|
||||
{partText(part)}
|
||||
</Txt>
|
||||
))}
|
||||
</Cell>
|
||||
);
|
||||
});
|
||||
const rowAlign = grid ? ("stretch" as const) : undefined;
|
||||
|
||||
return (
|
||||
<AvailableWidthProvider width={width}>
|
||||
<View>
|
||||
{headerTail ? (
|
||||
<View {...pdfFlags({ fixed: true })}>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
{headerTail || bleed ? (
|
||||
<View
|
||||
{...pdfFlags({ fixed: true })}
|
||||
style={bleed ? { marginLeft: -bleed.left, width: bleed.width, minWidth: bleed.width, maxWidth: bleed.width, paddingLeft: bleed.left, backgroundColor: bleed.bg } : undefined}
|
||||
>
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} style={cellStyle(headerStyle, headBorder)}>
|
||||
{cells(roles.head, header, headColor, true)}
|
||||
</Row>
|
||||
{headerTail}
|
||||
</View>
|
||||
) : (
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
|
||||
{cells(roles.head, header, headColor)}
|
||||
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} align={rowAlign} fixed style={cellStyle(headerStyle, headBorder)}>
|
||||
{cells(roles.head, header, headColor, true)}
|
||||
</Row>
|
||||
)}
|
||||
{rows.map((r, i) => (
|
||||
@@ -199,6 +250,7 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
|
||||
widths={columns}
|
||||
gap={gap}
|
||||
width={width}
|
||||
align={rowAlign}
|
||||
{...pdfFlags({ wrap: false, minPresenceAhead: i === rows.length - 1 ? tailH : undefined })}
|
||||
style={cellStyle(rowStyle)}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface PageChromeProps {
|
||||
bandTop?: number;
|
||||
/** Every-page decor in page space. */
|
||||
decor?: DecorItem[];
|
||||
/** Footer row offset from the page bottom; default: the bottom margin (padding minus the footer reserve). */
|
||||
bottom?: number;
|
||||
/** Colour of the continuation band's text; default `color`. */
|
||||
bandColor?: string;
|
||||
/** Page-space left edge and width of the continuation band; default: the frame's content box. */
|
||||
bandBox?: { left: number; width: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +82,9 @@ export function PageChrome({
|
||||
band,
|
||||
bandTop,
|
||||
decor = [],
|
||||
bottom,
|
||||
bandBox,
|
||||
bandColor,
|
||||
}: PageChromeProps) {
|
||||
const { pad } = frame;
|
||||
const need = chromeHeight(role);
|
||||
@@ -84,7 +93,9 @@ export function PageChrome({
|
||||
throw new PlanError(`footerReserve ${frame.footerReserve} pt is under the ${need} pt a "${roleToken(role).size} pt" footer line needs`, "chrome-reserve");
|
||||
}
|
||||
const labelWidth = Math.ceil(measureToken(getMeasurer(), role, "Page 99 of 99")) + 2;
|
||||
const marginBottom = pad.bottom - frame.footerReserve;
|
||||
const marginBottom = bottom ?? pad.bottom - frame.footerReserve;
|
||||
const bandLeftEdge = bandBox?.left ?? pad.left;
|
||||
const bandWidth = bandBox?.width ?? frame.contentWidth;
|
||||
const bandRightWidth = band?.right !== undefined ? Math.ceil(measureToken(getMeasurer(), role, band.right)) + 2 : 0;
|
||||
const top = bandTop ?? Math.max(0, pad.top - frame.continuationReserve);
|
||||
|
||||
@@ -103,17 +114,17 @@ export function PageChrome({
|
||||
|
||||
{band && (band.left !== undefined || band.right !== undefined) && (
|
||||
<View
|
||||
style={{ position: "absolute", top, left: pad.left, width: frame.contentWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||
style={{ position: "absolute", top, left: bandLeftEdge, width: bandWidth, flexDirection: "row", justifyContent: "flex-end" }}
|
||||
{...pdfFlags({ fixed: true })}
|
||||
>
|
||||
{band.left !== undefined && (
|
||||
<RenderTxt id={BAND_ID} role={role} color={color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
||||
<RenderTxt id={BAND_ID} role={role} color={bandColor ?? color} grow render={({ pageNumber }) => (pageNumber > 1 ? (band.left ?? "") : "")} />
|
||||
)}
|
||||
{band.right !== undefined && (
|
||||
<RenderTxt
|
||||
id={BAND_ID}
|
||||
role={role}
|
||||
color={color}
|
||||
color={bandColor ?? color}
|
||||
align="right"
|
||||
width={bandRightWidth}
|
||||
render={({ pageNumber }) => (pageNumber > 1 ? (band.right ?? "") : "")}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { pinned } from "../engine/columns";
|
||||
import { textLines } from "../engine/textplan";
|
||||
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
|
||||
@@ -68,21 +69,29 @@ export interface TaxRowsProps {
|
||||
/** Space between the label and the amount column. */
|
||||
labelGap?: number;
|
||||
color?: string;
|
||||
/** Role of the amount when it differs from the label's (Band's tracked labels over plain amounts). */
|
||||
amountRole?: Role;
|
||||
/** Draws each row itself (a ruled grid cell pair) from the slot's label and amount text; the slot still owns the container. */
|
||||
renderRow?: (row: { label: string; amount: string }, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
/** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */
|
||||
function TaxRows({ model, role = "body", amountWidth, rowStyle, format, labelAlign, labelGap = 0, color }: TaxRowsProps) {
|
||||
function TaxRows({ model, role = "body", amountWidth, rowStyle, format, labelAlign, labelGap = 0, color, amountRole, renderRow }: TaxRowsProps) {
|
||||
const w = useAvailableWidth();
|
||||
if (model.totals.taxRows.length === 0) return null;
|
||||
const text = format ?? ((label: string, rate: string | undefined) => (rate ? `${label} (${rate})` : label));
|
||||
return (
|
||||
<View id={SLOT_ID.taxRows}>
|
||||
{model.totals.taxRows.map((r, i) => (
|
||||
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
|
||||
<Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
|
||||
<Txt role={role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
|
||||
</Row>
|
||||
))}
|
||||
{model.totals.taxRows.map((r, i) =>
|
||||
renderRow ? (
|
||||
<Fragment key={i}>{renderRow({ label: text(r.label, r.rateText), amount: r.amountText }, i)}</Fragment>
|
||||
) : (
|
||||
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
|
||||
<Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
|
||||
<Txt role={amountRole ?? role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
|
||||
</Row>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import { pinned } from "../engine/columns";
|
||||
|
||||
export interface AccentRuleProps {
|
||||
width: number;
|
||||
thickness: number;
|
||||
color: string;
|
||||
style?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A solid bar in the flow (the Crimson header rule, the Slate/Teal total dash): an empty View, so a pinned height is allowed. */
|
||||
export function AccentRule({ width, thickness, color, style }: AccentRuleProps) {
|
||||
return <View style={{ ...pinned(width), height: thickness, backgroundColor: color, ...style } as never} />;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Circle, G, Path, Svg } from "@react-pdf/renderer";
|
||||
import { ICONS, type IconName } from "./icons.generated";
|
||||
|
||||
export interface ContactIconProps {
|
||||
name: IconName;
|
||||
/** Side of the square box, pt. */
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** A Carbon icon as a static Svg, filled with `color`. */
|
||||
export function ContactIcon({ name, size, color }: ContactIconProps) {
|
||||
const icon = ICONS[name];
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox={icon.viewBox}>
|
||||
{icon.paths.map((d, i) => (
|
||||
<Path key={i} d={d} fill={color} />
|
||||
))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CircledIconProps extends ContactIconProps {
|
||||
/** Ring thickness, pt. */
|
||||
ring: number;
|
||||
/** The icon's share of the diameter, 0..1. */
|
||||
glyph?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const icon = ICONS[name];
|
||||
const [minX, minY, vw, vh] = icon.viewBox.split(" ").map(Number);
|
||||
const scale = (size * glyph) / Math.max(vw, vh);
|
||||
const ox = (size - vw * scale) / 2 - minX * scale;
|
||||
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" />
|
||||
<G transform={`translate(${ox} ${oy}) scale(${scale})`}>
|
||||
{icon.paths.map((d, i) => (
|
||||
<Path key={i} d={d} fill={color} />
|
||||
))}
|
||||
</G>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Path, Svg } from "@react-pdf/renderer";
|
||||
import type { DecorItem } from "../blocks/FirstPageHeader";
|
||||
import type { PageFrame } from "../engine/geometry";
|
||||
import type { Rect } from "../engine/layoutTree";
|
||||
import { GradientDefs, type LinearGradientSpec } from "./FullBleedBand";
|
||||
|
||||
/*
|
||||
* Teal Swoosh's corner art, measured on the Canva A4 page (595.5 x 842.25, docs/templates/10-teal-swoosh.md).
|
||||
* The paths are the bottom-left cluster in A4 page coordinates and run past the page edges (they are
|
||||
* clipped there). The top-right cluster is the same art turned 180 degrees about the page centre; the
|
||||
* coordinates are mirrored numerically (no group rotation) and the gradient vectors flipped with them.
|
||||
*/
|
||||
export const SWOOSH_PAGE = { w: 595.5, h: 842.25 } as const;
|
||||
/** Footprint of one cluster on the A4 page: it keeps this size on every page size. */
|
||||
export const SWOOSH_BOX = { w: 342, h: 188.2 } as const;
|
||||
|
||||
export interface SwooshShape {
|
||||
d: string;
|
||||
fill: string | LinearGradientSpec;
|
||||
/** Flat fill opacity (the teal edge shade). */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
const GUNMETAL: LinearGradientSpec = {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 1,
|
||||
stops: [
|
||||
// The Canva stripes run dark at the teal edge, brighten to a highlight about two thirds along the band and
|
||||
// darken again before the page edge (read off the rendered original; the spec lists only the colours).
|
||||
{ offset: 0, color: "#3F3E3F" },
|
||||
{ offset: 0.28, color: "#343233" },
|
||||
{ offset: 0.62, color: "#7A7A7A" },
|
||||
{ offset: 0.88, color: "#343233" },
|
||||
{ offset: 1, color: "#5A5A5A" },
|
||||
],
|
||||
};
|
||||
const SILVER: LinearGradientSpec = {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
stops: ["#D5D4D4", "#C3C0C0", "#D6D5D5", "#C5C2C2", "#D8D7D7", "#BEBCBE"].map((color, i) => ({ offset: i / 5, color })),
|
||||
};
|
||||
|
||||
/** Back to front: gunmetal band, silver crescent, teal corner, teal edge shade. */
|
||||
export const SWOOSH_BOTTOM_LEFT: SwooshShape[] = [
|
||||
{ d: "M207.7,866.4 C95.8,813.0 21.1,726.7 -25.4,654.1 L-25.4,866.4 Z", fill: GUNMETAL },
|
||||
{ d: "M20,703 C78.8,780.4 163.7,834.8 255,866 L342,866 C220.8,849.9 100.9,796.5 20,703 Z", fill: SILVER },
|
||||
{ d: "M132.2,866.4 L-25.4,866.4 L-25.4,672.7 C2.0,727.2 56.2,808.8 153.5,866.4 Z", fill: "#32B3C7" },
|
||||
{ d: "M153.5,866.4 C56.2,808.8 2.0,727.2 -25.4,672.7 L-25.4,676.7 C-0.1,730.4 48.2,808.5 132.2,866.4 Z", fill: "#333132", opacity: 0.32 },
|
||||
];
|
||||
|
||||
const NUM = /-?\d*\.?\d+/g;
|
||||
|
||||
/** Turns absolute path data (M L C Z H V-free, as measured here) 180 degrees about the A4 page centre. */
|
||||
export function mirrorPath(d: string): string {
|
||||
if (/[a-zA-Z]/.test(d.replace(/[MLCZ]/g, ""))) throw new Error("mirrorPath: only absolute M, L, C and Z are supported");
|
||||
return d.replace(/[MLC][^MLCZ]*/g, (seg) => {
|
||||
const cmd = seg[0];
|
||||
const nums = (seg.slice(1).match(NUM) ?? []).map(Number);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < nums.length; i += 2) out.push(`${round(SWOOSH_PAGE.w - nums[i])},${round(SWOOSH_PAGE.h - nums[i + 1])}`);
|
||||
return `${cmd}${out.join(" ")}${seg.match(/\s*$/)?.[0] ?? ""}`;
|
||||
});
|
||||
}
|
||||
|
||||
const round = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
function mirrorFill(fill: SwooshShape["fill"]): SwooshShape["fill"] {
|
||||
if (typeof fill === "string") return fill;
|
||||
return { ...fill, x1: 1 - fill.x1, y1: 1 - fill.y1, x2: 1 - fill.x2, y2: 1 - fill.y2 };
|
||||
}
|
||||
|
||||
export const SWOOSH_TOP_RIGHT: SwooshShape[] = SWOOSH_BOTTOM_LEFT.map((s) => ({ ...s, d: mirrorPath(s.d), fill: mirrorFill(s.fill) }));
|
||||
|
||||
export type SwooshCorner = "bottom-left" | "top-right";
|
||||
|
||||
function cluster(corner: SwooshCorner): { shapes: SwooshShape[]; viewBox: string } {
|
||||
return corner === "bottom-left"
|
||||
? { shapes: SWOOSH_BOTTOM_LEFT, viewBox: `0 ${SWOOSH_PAGE.h - SWOOSH_BOX.h} ${SWOOSH_BOX.w} ${SWOOSH_BOX.h}` }
|
||||
: { shapes: SWOOSH_TOP_RIGHT, viewBox: `${SWOOSH_PAGE.w - SWOOSH_BOX.w} 0 ${SWOOSH_BOX.w} ${SWOOSH_BOX.h}` };
|
||||
}
|
||||
|
||||
/** One corner cluster in page space, flush with its page corner. Static paths and gradients only. */
|
||||
export function cornerSwoosh(frame: PageFrame, corner: SwooshCorner): DecorItem {
|
||||
const { width: pw, height: ph } = frame.page;
|
||||
const { shapes, viewBox } = cluster(corner);
|
||||
const x = corner === "bottom-left" ? 0 : pw - SWOOSH_BOX.w;
|
||||
const y = corner === "bottom-left" ? ph - SWOOSH_BOX.h : 0;
|
||||
const prefix = `sw-${corner === "bottom-left" ? "bl" : "tr"}`;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: SWOOSH_BOX.w,
|
||||
height: SWOOSH_BOX.h,
|
||||
children: (
|
||||
<Svg width={SWOOSH_BOX.w} height={SWOOSH_BOX.h} viewBox={viewBox}>
|
||||
<GradientDefs items={shapes.flatMap((s, i) => (typeof s.fill === "string" ? [] : [{ id: `${prefix}-${i}`, spec: s.fill }]))} />
|
||||
{shapes.map((s, i) => (
|
||||
<Path
|
||||
key={i}
|
||||
d={s.d}
|
||||
fill={typeof s.fill === "string" ? s.fill : `url(#${prefix}-${i})`}
|
||||
{...(s.opacity !== undefined ? { fillOpacity: s.opacity } : {})}
|
||||
/>
|
||||
))}
|
||||
</Svg>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The inner (page-facing) edge of the silver crescent, bottom-left cluster, A4 coordinates. */
|
||||
const INNER_EDGE = { p0: [342, 866], c1: [220.8, 849.9], c2: [100.9, 796.5], p3: [20, 703] } as const;
|
||||
|
||||
function innerEdgePoints(samples = 400): Array<[number, number]> {
|
||||
const { p0, c1, c2, p3 } = INNER_EDGE;
|
||||
const pts: Array<[number, number]> = [];
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i / samples;
|
||||
const u = 1 - t;
|
||||
const f = (k: 0 | 1) => u * u * u * p0[k] + 3 * u * u * t * c1[k] + 3 * u * t * t * c2[k] + t * t * t * p3[k];
|
||||
pts.push([f(0), f(1)]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boxes in page space that content must stay out of for one corner: a staircase of strips that covers the
|
||||
* cluster up to the silver crescent's inner edge, plus `clearance`. Strips are `step` pt tall; each reaches as
|
||||
* far as the edge does at the strip's far end, so the stairs never cut into the art.
|
||||
*/
|
||||
export function swooshKeepOuts(frame: PageFrame, corner: SwooshCorner, opts: { step?: number; clearance?: number } = {}): Rect[] {
|
||||
const step = opts.step ?? 8;
|
||||
const clearance = opts.clearance ?? 4;
|
||||
const { width: pw, height: ph } = frame.page;
|
||||
const pts = innerEdgePoints();
|
||||
const yTop = INNER_EDGE.p3[1];
|
||||
const yEnd = Math.min(SWOOSH_PAGE.h, INNER_EDGE.p0[1]);
|
||||
const out: Rect[] = [];
|
||||
for (let y0 = yTop; y0 < yEnd; y0 += step) {
|
||||
const y1 = Math.min(yEnd, y0 + step);
|
||||
const reach = Math.max(...pts.filter(([, y]) => y >= y0 - 1e-6 && y <= y1 + 1e-6).map(([x]) => x)) + clearance;
|
||||
const h = y1 - y0;
|
||||
// Bottom-left: A4 y counts from the top and the cluster is flush with the bottom edge. Top-right: the strip turned about the centre.
|
||||
out.push(corner === "bottom-left" ? { x: 0, y: ph - (SWOOSH_PAGE.h - y0), w: reach, h } : { x: pw - reach, y: SWOOSH_PAGE.h - y1, w: reach, h });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Defs, LinearGradient, Rect, Stop, Svg } from "@react-pdf/renderer";
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import type { DecorItem } from "../blocks/FirstPageHeader";
|
||||
import type { PageFrame } from "../engine/geometry";
|
||||
|
||||
export interface GradientStop {
|
||||
offset: number;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
/** A linear gradient in bounding-box units (0..1). Set the direction with x1..y2; never gradientTransform. */
|
||||
export interface LinearGradientSpec {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
stops: GradientStop[];
|
||||
}
|
||||
|
||||
export interface FullBleedBandSpec {
|
||||
/** Distance of the band's top edge from the page top (omit to anchor at the bottom). */
|
||||
top?: number;
|
||||
/** Distance of the band's bottom edge from the page bottom (used when `top` is omitted). */
|
||||
bottom?: number;
|
||||
height: number;
|
||||
fill: string | LinearGradientSpec;
|
||||
}
|
||||
|
||||
let gradientCount = 0;
|
||||
|
||||
/**
|
||||
* The gradients of one Svg. react-pdf resolves `url(#id)` from the FIRST `Defs` that is a direct child of the
|
||||
* Svg, so every gradient of an Svg goes into this single element, and it is never nested in a group.
|
||||
*/
|
||||
export function GradientDefs({ items }: { items: Array<{ id: string; spec: LinearGradientSpec }> }) {
|
||||
return (
|
||||
<Defs>
|
||||
{items.map(({ id, spec }) => (
|
||||
<LinearGradient key={id} id={id} x1={spec.x1} y1={spec.y1} x2={spec.x2} y2={spec.y2}>
|
||||
{spec.stops.map((s, i) => (
|
||||
<Stop key={i} offset={s.offset} stopColor={s.color} {...(s.opacity !== undefined ? { stopOpacity: s.opacity } : {})} />
|
||||
))}
|
||||
</LinearGradient>
|
||||
))}
|
||||
</Defs>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A band from the left page edge to the right one, in page space. It is anchored to the page edges, not to
|
||||
* the margins, so it also fills a Letter page. Hand the item to `decor.everyPage` (a fixed page-level box)
|
||||
* or to `FirstPageHeader`'s `decor` (page 1 only). A solid fill is a View; a gradient is a static Svg.
|
||||
*/
|
||||
export function fullBleedBand(frame: PageFrame, spec: FullBleedBandSpec): DecorItem {
|
||||
const { width, height: pageH } = frame.page;
|
||||
const y = spec.top ?? pageH - (spec.bottom ?? 0) - spec.height;
|
||||
if (typeof spec.fill === "string") {
|
||||
return { x: 0, y, width, height: spec.height, children: <View style={{ width, height: spec.height, backgroundColor: spec.fill }} /> };
|
||||
}
|
||||
const id = `band-${gradientCount++ % 1000}`;
|
||||
return {
|
||||
x: 0,
|
||||
y,
|
||||
width,
|
||||
height: spec.height,
|
||||
children: (
|
||||
<Svg width={width} height={spec.height} viewBox={`0 0 ${width} ${spec.height}`}>
|
||||
<GradientDefs items={[{ id, spec: spec.fill }]} />
|
||||
<Rect x={0} y={0} width={width} height={spec.height} fill={`url(#${id})`} />
|
||||
</Svg>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Icon notice
|
||||
|
||||
`icons.generated.ts` contains SVG path data taken from the IBM Carbon icon set (`@carbon/icons-react`),
|
||||
which is licensed under the Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
|
||||
Copyright IBM Corp. 2016, 2023.
|
||||
|
||||
The path strings are unmodified. They are drawn as vector art in the PDF templates (`CircledIcon`,
|
||||
`ContactIcon`); no icon font and no React icon component is bundled into the PDF code. Regenerate the file
|
||||
with `npm run decor:icons`.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Path, Svg } from "@react-pdf/renderer";
|
||||
import type { DecorItem } from "../blocks/FirstPageHeader";
|
||||
import type { PageFrame } from "../engine/geometry";
|
||||
|
||||
export interface PennantSpec {
|
||||
edge: "top-right" | "bottom-left";
|
||||
/** Size of the visible tab, pt. It keeps this size on every page size. */
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
/** Path data in the tab's own box (0,0 to width,height). */
|
||||
d: string;
|
||||
}
|
||||
|
||||
/** Tangerine Ledger's tabs, measured on the Canva A4 page (docs/templates/11-tangerine-ledger.md). */
|
||||
export const TANGERINE_TABS = {
|
||||
topRight: { edge: "top-right", width: 35.1, height: 230.5, d: "M0,0 H35.1 V230.5 L4.3,186.5 C1.5,182.5 0,177.7 0,172.8 Z" },
|
||||
bottomLeft: { edge: "bottom-left", width: 35.3, height: 218.55, d: "M0,0 L30.2,42.8 C33,46.8 34.5,51.5 34.5,56.4 L35.3,218.55 H0 Z" },
|
||||
} as const satisfies Record<string, Omit<PennantSpec, "color">>;
|
||||
|
||||
/** The tab's box in page space, anchored to its page corner. */
|
||||
export function pennantBox(frame: PageFrame, spec: Pick<PennantSpec, "edge" | "width" | "height">): { x: number; y: number; w: number; h: number } {
|
||||
const { width: pw, height: ph } = frame.page;
|
||||
return spec.edge === "top-right"
|
||||
? { x: pw - spec.width, y: 0, w: spec.width, h: spec.height }
|
||||
: { x: 0, y: ph - spec.height, w: spec.width, h: spec.height };
|
||||
}
|
||||
|
||||
/** An edge tab as a decor item: a static Svg path flush with the page corner (so it also fits Letter). */
|
||||
export function pennant(frame: PageFrame, spec: PennantSpec): DecorItem {
|
||||
const box = pennantBox(frame, spec);
|
||||
return {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.w,
|
||||
height: box.h,
|
||||
children: (
|
||||
<Svg width={box.w} height={box.h} viewBox={`0 0 ${spec.width} ${spec.height}`}>
|
||||
<Path d={spec.d} fill={spec.color} />
|
||||
</Svg>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Document, Page, View } from "@react-pdf/renderer";
|
||||
import { createElement as h, type ReactElement } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
|
||||
import { find } from "../testing/docs";
|
||||
import { renderCore } from "../render/core";
|
||||
import { setupPdfTest } from "../testing/setup";
|
||||
import { loadCanvas } from "../testing/rasterize";
|
||||
import { inkPixels } from "../testing/rasterize";
|
||||
import { cornerSwoosh, mirrorPath, SWOOSH_BOTTOM_LEFT, SWOOSH_BOX, SWOOSH_PAGE, SWOOSH_TOP_RIGHT, swooshKeepOuts } from "./CornerSwoosh";
|
||||
import { CircledIcon, ContactIcon } from "./CircledIcon";
|
||||
import { fullBleedBand, GradientDefs } from "./FullBleedBand";
|
||||
import { pennant, pennantBox, TANGERINE_TABS } from "./Pennant";
|
||||
import { ICONS } from "./icons.generated";
|
||||
|
||||
setupPdfTest();
|
||||
|
||||
const frame = (id: "A4" | "LETTER"): PageFrame => computeFrame({ page: PAGE_SIZES[id], margins: { top: 40, right: 40, bottom: 40, left: 40 }, footerReserve: 16, continuationReserve: 18, letterMode: true });
|
||||
const nums = (d: string) => (d.match(/-?\d*\.?\d+/g) ?? []).map(Number);
|
||||
|
||||
describe("fullBleedBand", () => {
|
||||
it("is anchored to the page edges on A4 and Letter, from the top or the bottom", () => {
|
||||
for (const id of ["A4", "LETTER"] as const) {
|
||||
const f = frame(id);
|
||||
const top = fullBleedBand(f, { top: 0, height: 100, fill: "#4D5869" });
|
||||
expect([top.x, top.y, top.width, top.height]).toEqual([0, 0, f.page.width, 100]);
|
||||
const bottom = fullBleedBand(f, { bottom: 0, height: 63, fill: "#A00000" });
|
||||
expect(bottom.y + bottom.height).toBeCloseTo(f.page.height, 6);
|
||||
expect(bottom.width).toBe(f.page.width);
|
||||
}
|
||||
});
|
||||
|
||||
it("a gradient band is a static Svg with one gradient", () => {
|
||||
const item = fullBleedBand(frame("A4"), { top: 10, height: 20, fill: { x1: 0, y1: 0, x2: 1, y2: 0, stops: [{ offset: 0, color: "#000000" }, { offset: 1, color: "#ffffff" }] } });
|
||||
const svg = item.children as ReactElement<{ children: ReactElement[] }>;
|
||||
expect(svg.props.children.some((c) => c.type === GradientDefs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pennant", () => {
|
||||
it("the tabs stay in their own box and flush with the page corner", () => {
|
||||
for (const id of ["A4", "LETTER"] as const) {
|
||||
const f = frame(id);
|
||||
const tr = pennantBox(f, TANGERINE_TABS.topRight);
|
||||
expect(tr.x + tr.w).toBeCloseTo(f.page.width, 6);
|
||||
expect(tr.y).toBe(0);
|
||||
const bl = pennantBox(f, TANGERINE_TABS.bottomLeft);
|
||||
expect(bl.x).toBe(0);
|
||||
expect(bl.y + bl.h).toBeCloseTo(f.page.height, 6);
|
||||
expect(pennant(f, { ...TANGERINE_TABS.topRight, color: "#FF5F2F" }).width).toBe(TANGERINE_TABS.topRight.width);
|
||||
}
|
||||
for (const t of Object.values(TANGERINE_TABS)) {
|
||||
const n = nums(t.d);
|
||||
for (let i = 0; i < n.length; i += 2) {
|
||||
expect(n[i], t.edge).toBeGreaterThanOrEqual(-0.01);
|
||||
expect(n[i], t.edge).toBeLessThanOrEqual(t.width + 0.01);
|
||||
}
|
||||
for (let i = 1; i < n.length; i += 2) {
|
||||
expect(n[i], t.edge).toBeGreaterThanOrEqual(-0.01);
|
||||
expect(n[i], t.edge).toBeLessThanOrEqual(t.height + 0.01);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CornerSwoosh", () => {
|
||||
it("mirrors a path about the page centre, and twice gives the original back", () => {
|
||||
const d = SWOOSH_BOTTOM_LEFT[1].d;
|
||||
expect(mirrorPath(mirrorPath(d))).toBe(d);
|
||||
expect(mirrorPath("M0,0 L595.5,842.25 Z")).toBe("M595.5,842.25 L0,0 Z");
|
||||
expect(() => mirrorPath("M0,0 H10")).toThrow();
|
||||
// The spec's own top-right gunmetal path.
|
||||
expect(mirrorPath("M207.7,866.4 C95.8,813.0 21.1,726.7 -25.4,654.1 L-25.4,866.4 Z").startsWith("M387.8,-24.15")).toBe(true);
|
||||
});
|
||||
|
||||
it("flips the gradient vectors with the paths", () => {
|
||||
const g = SWOOSH_BOTTOM_LEFT[0].fill;
|
||||
const m = SWOOSH_TOP_RIGHT[0].fill;
|
||||
if (typeof g === "string" || typeof m === "string") throw new Error("expected gradients");
|
||||
expect([m.x1, m.y1, m.x2, m.y2]).toEqual([1 - g.x1, 1 - g.y1, 1 - g.x2, 1 - g.y2]);
|
||||
expect(m.stops).toEqual(g.stops);
|
||||
});
|
||||
|
||||
it("anchors each cluster to its page corner at a fixed size, on A4 and Letter", () => {
|
||||
for (const id of ["A4", "LETTER"] as const) {
|
||||
const f = frame(id);
|
||||
const bl = cornerSwoosh(f, "bottom-left");
|
||||
expect([bl.x, bl.width, bl.height]).toEqual([0, SWOOSH_BOX.w, SWOOSH_BOX.h]);
|
||||
expect(bl.y + bl.height).toBeCloseTo(f.page.height, 6);
|
||||
const tr = cornerSwoosh(f, "top-right");
|
||||
expect(tr.y).toBe(0);
|
||||
expect(tr.x + tr.width).toBeCloseTo(f.page.width, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it("the keep-out stairs cover the crescent's inner edge and nothing above its top", () => {
|
||||
const f = frame("A4");
|
||||
const bl = swooshKeepOuts(f, "bottom-left");
|
||||
const tr = swooshKeepOuts(f, "top-right");
|
||||
expect(bl.length).toBe(tr.length);
|
||||
// A point on the inner edge (the curve's end at the page bottom, A4 y 842.25) is inside a stair.
|
||||
const covered = (rects: typeof bl, x: number, y: number) => rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h);
|
||||
expect(covered(bl, 236, f.page.height - 0.5)).toBe(true);
|
||||
expect(covered(bl, 20.5, f.page.height - (SWOOSH_PAGE.h - 703.5))).toBe(true);
|
||||
// The mirrored stair covers the mirrored point.
|
||||
expect(covered(tr, f.page.width - 236, 0.5)).toBe(true);
|
||||
// Nothing reaches above the cluster's top, and the stairs stay on the page.
|
||||
for (const r of bl) {
|
||||
expect(r.y).toBeGreaterThanOrEqual(f.page.height - SWOOSH_BOX.h - 1);
|
||||
expect(r.x).toBe(0);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(f.page.width);
|
||||
}
|
||||
for (const r of tr) expect(r.y + r.h).toBeLessThanOrEqual(SWOOSH_BOX.h + 1);
|
||||
// Letter keeps the stairs flush with the bottom edge.
|
||||
const letter = swooshKeepOuts(frame("LETTER"), "bottom-left");
|
||||
expect(Math.max(...letter.map((r) => r.y + r.h))).toBeCloseTo(792, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("icons", () => {
|
||||
it("ships Carbon path data for the contact icons, with a Carbon viewBox", () => {
|
||||
for (const name of ["phone", "email", "location"] as const) {
|
||||
expect(ICONS[name].viewBox).toBe("0 0 32 32");
|
||||
expect(ICONS[name].paths.length).toBeGreaterThan(0);
|
||||
for (const d of ICONS[name].paths) expect(d).toMatch(/^M[\d.,\-A-Za-z\s]+Z?$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("a CircledIcon and a ContactIcon render as static Svg nodes", async () => {
|
||||
const f = frame("A4");
|
||||
const r = await renderCore(
|
||||
h(Document, null, h(Page, { size: "A4", style: { padding: 40 } }, h(View, null, h(CircledIcon, { name: "phone", size: 19.2, color: "#0278B2", ring: 1.2 }), h(ContactIcon, { name: "email", size: 12, color: "#000000" })))),
|
||||
{ frame: f },
|
||||
);
|
||||
expect(find(r.layout[0], (n) => n.type === "svg")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("the ring and the glyph both draw ink", async () => {
|
||||
const canvas = await loadCanvas();
|
||||
if (!canvas) return;
|
||||
const r = await renderCore(h(Document, null, h(Page, { size: "A4", style: { padding: 40 } }, h(CircledIcon, { name: "phone", size: 40, color: "#000000", ring: 2 }))), { frame: frame("A4") });
|
||||
expect(await inkPixels(canvas, r.bytes, { x0: 40, y0: 40, x1: 80, y1: 80 })).toBeGreaterThan(100);
|
||||
// The middle of the ring, between the glyph and the ring, stays white around the corners of the box.
|
||||
expect(await inkPixels(canvas, r.bytes, { x0: 40, y0: 40, x1: 43, y1: 43 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Generated by `npm run decor:icons` (scripts/decor/icons.mjs). Do not edit by hand.
|
||||
// Path data of IBM Carbon icons, Apache-2.0 (see ICONS-NOTICE.md).
|
||||
|
||||
export interface IconArt {
|
||||
/** "minX minY width height", as in the Carbon source. */
|
||||
viewBox: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export const ICONS = {
|
||||
"phone": {
|
||||
"viewBox": "0 0 32 32",
|
||||
"paths": [
|
||||
"M26,29h-.17C6.18,27.87,3.39,11.29,3,6.23A3,3,0,0,1,5.76,3h5.51a2,2,0,0,1,1.86,1.26L14.65,8a2,2,0,0,1-.44,2.16l-2.13,2.15a9.37,9.37,0,0,0,7.58,7.6l2.17-2.15A2,2,0,0,1,24,17.35l3.77,1.51A2,2,0,0,1,29,20.72V26A3,3,0,0,1,26,29ZM6,5A1,1,0,0,0,5,6v.08C5.46,12,8.41,26,25.94,27A1,1,0,0,0,27,26.06V20.72l-3.77-1.51-2.87,2.85L19.88,22C11.18,20.91,10,12.21,10,12.12l-.06-.48,2.84-2.87L11.28,5Z"
|
||||
]
|
||||
},
|
||||
"email": {
|
||||
"viewBox": "0 0 32 32",
|
||||
"paths": [
|
||||
"M28,6H4A2,2,0,0,0,2,8V24a2,2,0,0,0,2,2H28a2,2,0,0,0,2-2V8A2,2,0,0,0,28,6ZM25.8,8,16,14.78,6.2,8ZM4,24V8.91l11.43,7.91a1,1,0,0,0,1.14,0L28,8.91V24Z"
|
||||
]
|
||||
},
|
||||
"location": {
|
||||
"viewBox": "0 0 32 32",
|
||||
"paths": [
|
||||
"M16,18a5,5,0,1,1,5-5A5.0057,5.0057,0,0,1,16,18Zm0-8a3,3,0,1,0,3,3A3.0033,3.0033,0,0,0,16,10Z",
|
||||
"M16,30,7.5645,20.0513c-.0479-.0571-.3482-.4515-.3482-.4515A10.8888,10.8888,0,0,1,5,13a11,11,0,0,1,22,0,10.8844,10.8844,0,0,1-2.2148,6.5973l-.0015.0025s-.3.3944-.3447.4474ZM8.8125,18.395c.001.0007.2334.3082.2866.3744L16,26.9079l6.91-8.15c.0439-.0552.2783-.3649.2788-.3657A8.901,8.901,0,0,0,25,13,9,9,0,1,0,7,13a8.9054,8.9054,0,0,0,1.8125,5.395Z"
|
||||
]
|
||||
}
|
||||
} as const satisfies Record<string, IconArt>;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
@@ -16,6 +16,7 @@ describe("font manifest", () => {
|
||||
expect(FONT_FAMILIES).toEqual([
|
||||
"IBM Plex Mono",
|
||||
"IBM Plex Sans",
|
||||
"Inter",
|
||||
"Montserrat",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Kannada",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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";
|
||||
export type FaceKey = "IBM Plex Sans" | "IBM Plex Mono" | "Montserrat" | "Open Sans" | "Poppins" | "Inter";
|
||||
|
||||
export interface TypeToken {
|
||||
face: FaceKey;
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface RenderCoreOptions {
|
||||
/** With a frame the layout is audited; without one `issues` is empty. */
|
||||
frame?: PageFrame;
|
||||
/** Page-space boxes content must stay out of (audited as `keepout`). */
|
||||
decorBoxes?: Rect[];
|
||||
decorBoxes?: Array<Rect & { page?: number }>;
|
||||
/** Further audit options (closing block id, ...). */
|
||||
audit?: Omit<AuditOptions, "keepOuts">;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, byt
|
||||
density: model.logo.density,
|
||||
kind: model.logo.kind,
|
||||
includesName: prefs.logo?.useWordmark ?? model.logo.includesName,
|
||||
hasKnockout: model.logo.hasKnockout ?? false,
|
||||
hasKnockout: Boolean(model.logo.hasKnockout && model.logo.knockoutDataUri),
|
||||
},
|
||||
slot: template.logo.slot,
|
||||
available: template.logo.available?.(frame),
|
||||
@@ -64,7 +64,8 @@ export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, byt
|
||||
const slot = template.logo.slot;
|
||||
const target = slot.textOnly ? slot.allowance : placement.slotKind === "lockup" ? (slot.lockup ?? slot.allowance) : (slot.mark ?? slot.allowance);
|
||||
logo = {
|
||||
dataUri: model.logo.dataUri,
|
||||
// On a dark surface the white knockout replaces the print image; without one the layout draws a white chip.
|
||||
dataUri: placement.variant === "knockout" && model.logo.knockoutDataUri ? model.logo.knockoutDataUri : model.logo.dataUri,
|
||||
w: placement.w,
|
||||
h: placement.h,
|
||||
stacked: false,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import { type ReactNode } from "react";
|
||||
import { FILL, pinned } from "../../engine/columns";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
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 { Txt } from "../../blocks/Txt";
|
||||
import { AccentRule } from "../../decor/AccentRule";
|
||||
import { cornerSwoosh } from "../../decor/CornerSwoosh";
|
||||
import { fullBleedBand } from "../../decor/FullBleedBand";
|
||||
import { CHIP_PADDING } from "../../engine/logo";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import type { PlacedLogo, TemplateProps } from "../contract";
|
||||
import { includesName } from "../logoName";
|
||||
import { bankLines, CANCELLED_INK, CANCELLED_SENTENCE, CLOSING_GAP } from "../neutral/plan";
|
||||
import { CLOSING_COLUMN_GAP, HEADING_AFTER, planBand, REVERSE_GAP, signatureOptions, STACK_RULE, WORDS_GAP, type BandPlan, type MetaItem } from "./plan";
|
||||
import type { BandVariant } from "./variant";
|
||||
|
||||
interface Ctx {
|
||||
model: RenderModel;
|
||||
plan: BandPlan;
|
||||
v: BandVariant;
|
||||
logo: PlacedLogo | null;
|
||||
}
|
||||
|
||||
const WHITE = "#FFFFFF";
|
||||
/** Montserrat's ascent as a share of the size: where the baseline sits below a line's top. */
|
||||
const MONTSERRAT_ASCENT = 0.968;
|
||||
const NOTES_UNDER_PAY = 10;
|
||||
|
||||
function Spacer({ h }: { h: number }) {
|
||||
return h > 0 ? <View style={{ height: h }} /> : null;
|
||||
}
|
||||
|
||||
// --- header ---------------------------------------------------------------------------------------------
|
||||
|
||||
function LogoAndName({ ctx, nameColor }: { ctx: Ctx; nameColor?: string }) {
|
||||
const { model, plan, v, logo } = ctx;
|
||||
const h = plan.header;
|
||||
const zoneTop = v.brandZone.top;
|
||||
return (
|
||||
<>
|
||||
{logo ? (
|
||||
<Image
|
||||
src={logo.dataUri}
|
||||
style={{ ...pinned(logo.w), height: logo.h, marginTop: zoneTop + h.logoTop, objectFit: "contain", ...(logo.placement?.variant === "chip" ? { marginLeft: 0 } : {}) } as never}
|
||||
/>
|
||||
) : null}
|
||||
{h.showName ? (
|
||||
<View style={{ ...pinned(h.nameW), marginLeft: logo ? v.markGap : 0, marginTop: h.nameTop } as never}>
|
||||
<AvailableWidthProvider width={h.nameW}>
|
||||
<SafeTxt role={v.roles.brand} color={nameColor ?? v.ink}>
|
||||
{model.supplier.name}
|
||||
</SafeTxt>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SlateHeader({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const bands = v.bands as NonNullable<BandVariant["bands"]>;
|
||||
const h = plan.header;
|
||||
return (
|
||||
<>
|
||||
{/* minHeight: the bands are drawn from rowH, and without a logo the name and number alone are shorter. */}
|
||||
<View style={{ flexDirection: "row", ...pinned(plan.W), minHeight: h.rowH } as never}>
|
||||
<LogoAndName ctx={ctx} nameColor={WHITE} />
|
||||
<View style={FILL} />
|
||||
<View style={{ ...pinned(h.numW), marginTop: h.numTop } as never}>
|
||||
<Txt role={v.roles.bandNumber} color={WHITE} width={h.numW} align="right">
|
||||
{`NO. ${model.meta.number}`}
|
||||
</Txt>
|
||||
</View>
|
||||
</View>
|
||||
<Spacer h={plan.gaps.top} />
|
||||
<Spacer h={bands.titlePadTop} />
|
||||
<FitText id={TITLE_ID} role={v.roles.title} color={bands.titleText} width={plan.W} minSize={18}>
|
||||
{model.docTitle}
|
||||
</FitText>
|
||||
<Spacer h={bands.titlePadBottom} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TealHeader({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
return (
|
||||
<>
|
||||
<View style={{ flexDirection: "row", ...pinned(plan.W), minHeight: plan.header.rowH } as never}>
|
||||
<LogoAndName ctx={ctx} />
|
||||
</View>
|
||||
<Spacer h={plan.gaps.titleGap} />
|
||||
<FitText id={TITLE_ID} role={v.roles.title} color={v.ink} width={plan.header.titleW} minSize={18}>
|
||||
{model.docTitle}
|
||||
</FitText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelledTag({ ctx }: { ctx: Ctx }) {
|
||||
const textW = Math.ceil(measureToken(getMeasurer(), ctx.v.roles.label, "Cancelled")) + 2;
|
||||
const boxW = textW + 12 + 1.6;
|
||||
return (
|
||||
<View style={{ borderWidth: 0.8, borderColor: CANCELLED_INK, paddingVertical: 2, paddingHorizontal: 6, marginBottom: 6, ...pinned(boxW) } as never}>
|
||||
<Txt role={ctx.v.roles.label} color={CANCELLED_INK} width={textW}>
|
||||
Cancelled
|
||||
</Txt>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaColumn({ ctx, items, labelW }: { ctx: Ctx; items: MetaItem[]; labelW: number }) {
|
||||
const { v } = ctx;
|
||||
return (
|
||||
<View>
|
||||
{items.map((item, i) =>
|
||||
item.label ? (
|
||||
<Row key={i} widths={[{ fixed: labelW }, { fill: true }]}>
|
||||
{/* The 12 pt label shares the baseline of the 13 pt value in the design: it starts one ascent difference lower. */}
|
||||
<Txt role={v.roles.label} color={v.ink} width={labelW} style={{ marginTop: (v.roles.dateValue.size - v.roles.label.size) * MONTSERRAT_ASCENT }}>
|
||||
{item.label}
|
||||
</Txt>
|
||||
<SafeTxt role={v.roles.dateValue} color={v.ink}>
|
||||
{item.value}
|
||||
</SafeTxt>
|
||||
</Row>
|
||||
) : (
|
||||
<SafeTxt key={i} role={v.roles.dateValue} color={v.text} style={{}}>
|
||||
{item.value}
|
||||
</SafeTxt>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
return (
|
||||
<View id={META_ID} style={pinned(plan.W) as never}>
|
||||
{model.cancelled ? <CancelledTag ctx={ctx} /> : null}
|
||||
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
|
||||
<MetaColumn ctx={ctx} items={plan.meta.left} labelW={plan.meta.labelWLeft} />
|
||||
<MetaColumn ctx={ctx} items={plan.meta.right} labelW={plan.meta.labelWRight} />
|
||||
</Row>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = model.client;
|
||||
return (
|
||||
<View style={pinned(plan.leftW) as never}>
|
||||
<Txt role={r.label} color={v.ink} width={plan.leftW} style={{ marginBottom: v.headingGap }}>
|
||||
Billed to:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{c.name}
|
||||
</SafeTxt>
|
||||
{c.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
<Slot.Gstin model={model} party="client" role={r.body} color={v.ink} />
|
||||
<Slot.PlaceOfSupply model={model} role={r.body} color={v.ink} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FromBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const s = model.supplier;
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={pinned(plan.rightW) as never}>
|
||||
<Txt role={r.label} color={v.ink} width={plan.rightW} style={{ marginBottom: v.headingGap }}>
|
||||
From:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{s.name}
|
||||
</SafeTxt>
|
||||
{s.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
{s.phone ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{s.phone}
|
||||
</IdText>
|
||||
) : null}
|
||||
{s.email ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{s.email}
|
||||
</IdText>
|
||||
) : null}
|
||||
{s.pan ? <IdText role={r.body} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
|
||||
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- totals ---------------------------------------------------------------------------------------------
|
||||
|
||||
function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = plan.totals;
|
||||
return (
|
||||
<Row widths={[{ fill: true }, { fixed: t.amountW }]} style={{ marginBottom: v.totals.rowGap }}>
|
||||
<Txt role={v.roles.totalLabel} color={v.text} width={t.labelW}>
|
||||
{label}
|
||||
</Txt>
|
||||
<Txt role={v.roles.totalValue} color={v.text} width={t.amountW} align="right">
|
||||
{amount}
|
||||
</Txt>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function Tail({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const t = plan.totals;
|
||||
return (
|
||||
<View id={TOTALS_ID} wrap={false}>
|
||||
<View style={{ marginTop: plan.gaps.tail }}>
|
||||
<Row widths={[{ fill: true }, { fixed: t.w }]}>
|
||||
<View />
|
||||
<View style={pinned(t.w) as never}>
|
||||
<AvailableWidthProvider width={t.w}>
|
||||
{t.stack.map((row, i) => (
|
||||
<StackRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
|
||||
))}
|
||||
<Slot.TaxRows
|
||||
model={model}
|
||||
role={r.totalLabel}
|
||||
amountRole={r.totalValue}
|
||||
color={v.text}
|
||||
amountWidth={t.amountW}
|
||||
labelGap={0}
|
||||
rowStyle={{ marginBottom: v.totals.rowGap }}
|
||||
format={(label, rate) => (rate ? `${label} @ ${rate}` : label)}
|
||||
/>
|
||||
<View style={{ marginTop: STACK_RULE.before, borderTopWidth: STACK_RULE.thickness, borderTopColor: v.ruleColor, marginBottom: STACK_RULE.after }} />
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</Row>
|
||||
<Row widths={[{ fixed: v.totals.dash.w }, { fill: true }, { fixed: t.w }]}>
|
||||
<AccentRule width={v.totals.dash.w} thickness={v.totals.dash.h} color={v.accent} style={{ marginTop: MONTSERRAT_ASCENT * r.grand.size - v.totals.dash.h / 2 }} />
|
||||
<View />
|
||||
<Row widths={[{ fill: true }, { fixed: t.amountW }]} width={t.w}>
|
||||
<Txt role={r.grand} color={v.text} width={t.labelW}>
|
||||
Total
|
||||
</Txt>
|
||||
<Txt role={r.grandValue} color={v.text} width={t.amountW} align="right">
|
||||
{model.totals.totalText}
|
||||
</Txt>
|
||||
</Row>
|
||||
</Row>
|
||||
{plan.wordsH > 0 ? (
|
||||
<View style={{ marginTop: WORDS_GAP }}>
|
||||
<Slot.AmountInWords model={model} role={r.small} labelRole={r.wordsLabel} color={v.text} />
|
||||
</View>
|
||||
) : null}
|
||||
{plan.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.text} style={{ marginTop: REVERSE_GAP }} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- notes and closing --------------------------------------------------------------------------------------
|
||||
|
||||
function NotesBlock({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
if (model.notesLines.length === 0) return null;
|
||||
return (
|
||||
<View style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
{/* The label travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
|
||||
<View wrap={false}>
|
||||
<Txt role={r.label} color={v.ink} width={width} style={{ marginBottom: HEADING_AFTER }}>
|
||||
Note:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.text}>
|
||||
{model.notesLines[0]}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{model.notesLines.slice(1).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.text} wrap={false}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PayBlock({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { model, v } = ctx;
|
||||
if (!model.bank) return null;
|
||||
return (
|
||||
<View style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
<Txt role={v.roles.label} color={v.ink} width={width} style={{ marginBottom: HEADING_AFTER }}>
|
||||
Payment details:
|
||||
</Txt>
|
||||
{bankLines(model).map((line, i) => (
|
||||
<SafeTxt key={i} role={v.roles.small} color={v.text}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Closing({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const slate = v.id === "slate-band";
|
||||
const opts = signatureOptions(v);
|
||||
const sign = <Slot.Signatory model={model} width={plan.closingRightW} {...opts} />;
|
||||
const notes = plan.notesInClosing ? <NotesBlock ctx={ctx} width={plan.notesLeftW} /> : null;
|
||||
return (
|
||||
<>
|
||||
<BottomSpacer closingH={plan.closingH} />
|
||||
<View id={CLOSING_ID} wrap={false} style={{ marginLeft: v.inset, paddingTop: CLOSING_GAP, ...pinned(plan.ruleW) } as never}>
|
||||
<AvailableWidthProvider width={plan.ruleW}>
|
||||
<Row widths={[{ fill: true }, { fixed: plan.closingRightW }]} gap={CLOSING_COLUMN_GAP}>
|
||||
{slate ? (
|
||||
<View>
|
||||
<PayBlock ctx={ctx} width={plan.closingLeftW} />
|
||||
{notes ? <View style={{ marginTop: model.bank ? NOTES_UNDER_PAY : 0 }}>{notes}</View> : null}
|
||||
</View>
|
||||
) : (
|
||||
<View>{notes}</View>
|
||||
)}
|
||||
{slate ? (
|
||||
<View style={pinned(plan.closingRightW) as never}>{sign}</View>
|
||||
) : (
|
||||
<View style={pinned(plan.closingRightW) as never}>
|
||||
<PayBlock ctx={ctx} width={plan.closingRightW} />
|
||||
<View style={{ marginTop: model.bank ? 14 : 0 }}>{sign}</View>
|
||||
</View>
|
||||
)}
|
||||
</Row>
|
||||
{model.cancelled ? (
|
||||
<Txt role={r.small} color={CANCELLED_INK} width={plan.ruleW} style={{ marginTop: 10 }}>
|
||||
{CANCELLED_SENTENCE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- the layout ------------------------------------------------------------------------------------------------
|
||||
|
||||
/** First-page-only art: Slate's two full-bleed bands (and the white chip behind a logo without a knockout), Teal's top-right swoosh. */
|
||||
function firstPageDecor(ctx: Ctx, frame: TemplateProps["frame"]): DecorItem[] {
|
||||
const { plan, v, logo } = ctx;
|
||||
const items: DecorItem[] = [];
|
||||
if (v.bands) {
|
||||
items.push(fullBleedBand(frame, { top: 0, height: plan.header.topBandH, fill: v.bands.top }));
|
||||
items.push(fullBleedBand(frame, { top: plan.header.topBandH, height: plan.header.titleBandH, fill: v.bands.title }));
|
||||
if (logo && logo.placement?.variant === "chip") {
|
||||
const pad = CHIP_PADDING;
|
||||
const y = frame.pad.top + v.brandZone.top + plan.header.logoTop - pad;
|
||||
items.push({
|
||||
x: frame.pad.left - pad,
|
||||
y,
|
||||
width: logo.w + 2 * pad,
|
||||
height: logo.h + 2 * pad,
|
||||
children: <View style={{ width: logo.w + 2 * pad, height: logo.h + 2 * pad, backgroundColor: WHITE, borderRadius: 3 }} />,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
items.push(cornerSwoosh(frame, "top-right"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function BandLayout({ model, frame, logo, prefs, variant }: TemplateProps & { variant: BandVariant }) {
|
||||
const plan = planBand(model, frame, variant, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v: variant, logo };
|
||||
const v = variant;
|
||||
const r = v.roles;
|
||||
let notes: ReactNode = null;
|
||||
if (model.notesLines.length > 0 && !plan.notesInClosing) {
|
||||
notes = (
|
||||
<View style={{ marginTop: plan.gaps.notes, marginLeft: v.inset, ...pinned(plan.ruleW) } as never}>
|
||||
<NotesBlock ctx={ctx} width={plan.ruleW} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad} decor={firstPageDecor(ctx, frame)}>
|
||||
{v.bands ? <SlateHeader ctx={ctx} /> : <TealHeader ctx={ctx} />}
|
||||
<Spacer h={plan.gaps.meta} />
|
||||
<MetaBlock ctx={ctx} />
|
||||
<Spacer h={plan.gaps.party} />
|
||||
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
|
||||
<ClientBlock ctx={ctx} />
|
||||
<FromBlock ctx={ctx} />
|
||||
</Row>
|
||||
</FirstPageHeader>
|
||||
|
||||
<Spacer h={plan.gaps.table} />
|
||||
|
||||
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
|
||||
<View style={{ marginLeft: v.inset, ...pinned(plan.ruleW) } as never}>
|
||||
<TableRows
|
||||
columns={plan.columns}
|
||||
gap={0}
|
||||
width={plan.ruleW}
|
||||
header={plan.headerCells}
|
||||
rows={plan.rows}
|
||||
roles={{ head: r.tableHead, body: r.tableBody }}
|
||||
align={plan.align}
|
||||
padX={0}
|
||||
padY={v.padY}
|
||||
headPadY={v.headPadY}
|
||||
border={v.rule}
|
||||
borderColor={v.ruleColor}
|
||||
headBorder={0}
|
||||
headColor={v.text}
|
||||
bleed={{ left: plan.bleedLeft, width: frame.page.width, bg: v.headBand }}
|
||||
tailH={plan.tailH}
|
||||
tail={<Tail ctx={ctx} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{notes}
|
||||
<Closing ctx={ctx} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { swooshKeepOuts } from "../../decor/CornerSwoosh";
|
||||
import { CHIP_PADDING } from "../../engine/logo";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
||||
import { frameForPrefs } from "../../render/invoice";
|
||||
import { loadCanvas, pagePng } from "../../testing/rasterize";
|
||||
import { SLOT_ID } from "../../blocks/slots";
|
||||
import { catalogEntry } 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 { planBand } from "./plan";
|
||||
import { slateBand, SLATE_VARIANT } from "./slate";
|
||||
import { tealSwoosh, TEAL_VARIANT } from "./teal";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const BAND = [slateBand, tealSwoosh];
|
||||
const VARIANTS = { "slate-band": SLATE_VARIANT, "teal-swoosh": TEAL_VARIANT } as const;
|
||||
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
||||
const out: LayoutNode[] = [];
|
||||
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
const withLogo = { name: "logo", items: 2, logo: "real" as const };
|
||||
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
||||
|
||||
/** The colour of one pixel of page 1, at 4x. */
|
||||
async function colourAt(bytes: Uint8Array, points: Array<[number, number]>): Promise<number[][]> {
|
||||
const lib = await loadCanvas();
|
||||
if (!lib) return [];
|
||||
const png = await pagePng(lib, bytes, 1, 4);
|
||||
const img = await lib.loadImage(png);
|
||||
const c = lib.createCanvas(img.width, img.height);
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
return points.map(([x, y]) => Array.from(ctx.getImageData(Math.round(x * 4), Math.round(y * 4), 1, 1).data).slice(0, 3));
|
||||
}
|
||||
const isWhite = ([r, g, b]: number[]) => r > 245 && g > 245 && b > 245;
|
||||
const isSlate = ([r, g, b]: number[]) => Math.abs(r - 0x4d) < 6 && Math.abs(g - 0x58) < 6 && Math.abs(b - 0x69) < 6;
|
||||
|
||||
describe("registry and catalog", () => {
|
||||
it("lists Slate Band and Teal Swoosh in the Band family, with their thumbnails on disk", () => {
|
||||
for (const t of BAND) {
|
||||
expect(getTemplate(t.id)).toBe(t);
|
||||
expect(t.family).toBe("band");
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("both are Montserrat throughout, and their slots are the measured Canva ones", () => {
|
||||
for (const t of BAND) for (const [name, token] of Object.entries(t.roles)) expect(token.face, `${t.id}.${name}`).toBe("Montserrat");
|
||||
expect(slateBand.logo.slot).toBe(SLOTS["slate-band"]);
|
||||
expect(tealSwoosh.logo.slot).toBe(SLOTS["teal-swoosh"]);
|
||||
expect(slateBand.logo.slot?.darkSurface).toBe(true);
|
||||
expect(tealSwoosh.logo.slot?.darkSurface).toBe(false);
|
||||
expect(slateBand.roles.title).toMatchObject({ weight: 800, size: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("page geometry", () => {
|
||||
it("template margins", () => {
|
||||
expect(marginsFor(slateBand, "template")).toEqual({ top: 55.75, right: 59.55, bottom: 20, left: 59.55 });
|
||||
expect(marginsFor(tealSwoosh, "template")).toEqual({ top: 55, right: 59.55, bottom: 24, left: 59.55 });
|
||||
});
|
||||
|
||||
it("the flow stays above the footer rule (Slate) and the bottom-left swoosh (Teal) on every preset and size", () => {
|
||||
for (const margins of ["template", "narrow", "normal"] as const) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
expect(frameForPrefs(prefsFor(size, { templateId: "slate-band", margins })).pad.bottom, `slate ${margins}/${size}`).toBeGreaterThanOrEqual(86.7 + 12 - 1e-6);
|
||||
expect(frameForPrefs(prefsFor(size, { templateId: "teal-swoosh", margins })).pad.bottom, `teal ${margins}/${size}`).toBeGreaterThanOrEqual(110 - 1e-6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("Letter keeps the physical margins", () => {
|
||||
for (const t of BAND) {
|
||||
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 table text is inset 9.8 pt and the header band bleeds to both page edges, on every page", async () => {
|
||||
for (const t of BAND) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "p", items: 30, logo: "none" }, prefsFor(size, { templateId: t.id }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
expect(r.result.pages).toBeGreaterThan(1);
|
||||
r.result.core.layout.forEach((page, i) => {
|
||||
const heads = nodes([page]).filter((n) => n.id === "tbl-head");
|
||||
if (heads.length === 0) return;
|
||||
expect(heads[0].box.x, `${t.id}/${size} p${i + 1} head x`).toBeCloseTo(frame.pad.left + 9.8, 1);
|
||||
// The fixed group around the header row is the band: page edge to page edge.
|
||||
const band = nodes([page]).find((n) => n.type === "view" && n.fixed && n.box.x < 0.5 && n.box.w > frame.page.width - 0.5 && n.box.h > 40);
|
||||
expect(band, `${t.id}/${size} p${i + 1} band`).toBeTruthy();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slate Band: dark band and logo", () => {
|
||||
it("the two full-bleed bands are page 1 only, from the page top, as wide as the page", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor(size, { templateId: "slate-band" }));
|
||||
const decor = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.decor && n.type === "view" && n.box.x < 0.5 && n.box.w > frameForPrefs(r.prefs).page.width - 0.5 && n.box.h > 20);
|
||||
expect(decor(0).length, size).toBeGreaterThanOrEqual(2);
|
||||
expect(Math.min(...decor(0).map((n) => n.box.y)), size).toBeCloseTo(0, 1);
|
||||
// 103.5 pt of slate, then 53.8 of blue, at the design's margins.
|
||||
const heights = decor(0).map((n) => n.box.h).sort((a, b) => a - b);
|
||||
expect(heights[0]).toBeGreaterThan(50);
|
||||
// 103.5 on A4; Letter takes up to 8.5 pt of the band's bottom padding.
|
||||
expect(heights[heights.length - 1]).toBeGreaterThan(94);
|
||||
expect(decor(1).filter((n) => n.box.y < 5), `${size} no band on page 2`).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("a logo with a white knockout draws the knockout on the band", async () => {
|
||||
const lib = await loadCanvas();
|
||||
const r = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
const [img] = imageBoxes(r);
|
||||
expect(imageBoxes(r)).toHaveLength(1);
|
||||
const slot = SLOTS["slate-band"]!.lockup!;
|
||||
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
|
||||
expect(img.box.h).toBeLessThanOrEqual(slot.h + 0.01);
|
||||
expect(img.box.w).toBeLessThanOrEqual(slot.w + 0.01);
|
||||
if (!lib) return;
|
||||
const [centre, beside] = await colourAt(r.result.bytes, [[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2], [img.box.x - 3, img.box.y + img.box.h / 2]]);
|
||||
expect(isWhite(centre), `knockout ink ${centre}`).toBe(true);
|
||||
expect(isSlate(beside), `band beside the logo ${beside}`).toBe(true);
|
||||
});
|
||||
|
||||
it("a logo without alpha sits on a white chip, padded by CHIP_PADDING, still inside the band", async () => {
|
||||
const lib = await loadCanvas();
|
||||
const r = await renderFixture({ ...withLogo, logo: "wide" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
const [img] = imageBoxes(r);
|
||||
if (!lib) return;
|
||||
const [inChip, outside, centre] = await colourAt(r.result.bytes, [
|
||||
[img.box.x - CHIP_PADDING + 1, img.box.y + img.box.h / 2],
|
||||
[img.box.x - CHIP_PADDING - 3, img.box.y + img.box.h / 2],
|
||||
[img.box.x + img.box.w / 2, img.box.y + img.box.h / 2],
|
||||
]);
|
||||
expect(isWhite(inChip), `chip ${inChip}`).toBe(true);
|
||||
expect(isSlate(outside), `band outside the chip ${outside}`).toBe(true);
|
||||
// The print image (navy) is what is drawn on the chip, not a white knockout.
|
||||
expect(isWhite(centre) || centre[2] > centre[0], `print ink ${centre}`).toBe(true);
|
||||
});
|
||||
|
||||
it("a mark keeps the business name beside it, in white; a wordmark replaces the name", async () => {
|
||||
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
|
||||
const wide = await renderFixture({ ...withLogo, logo: "knockout" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(0);
|
||||
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 110)).toHaveLength(1);
|
||||
// The number sits in the band, right-aligned to the content edge.
|
||||
const no = none.pdf.pages[0].items.find((i) => i.str.startsWith("NO."))!;
|
||||
expect(no.bottom).toBeLessThan(110);
|
||||
expect(no.x1).toBeLessThanOrEqual(frameForPrefs(none.prefs).page.width - frameForPrefs(none.prefs).pad.right + 0.6);
|
||||
});
|
||||
|
||||
it("the contact footer is on every page", async () => {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "slate-band" }));
|
||||
expect(r.result.pages).toBeGreaterThan(1);
|
||||
for (const [i, page] of r.pdf.pages.entries()) {
|
||||
const text = page.items.map((t) => t.str).join(" ");
|
||||
expect(text, `page ${i + 1} phone`).toContain("9000000000");
|
||||
expect(text, `page ${i + 1} e-mail`).toContain("vendor@example.com");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Teal Swoosh: corner art and keep-outs", () => {
|
||||
it("the bottom-left swoosh repeats on every page; the top-right one is page 1 only", async () => {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const svgs = (i: number) => nodes([r.result.core.layout[i]]).filter((n) => n.type === "svg" && n.decor);
|
||||
expect(svgs(0).length).toBe(2);
|
||||
for (let i = 1; i < r.result.pages; i++) {
|
||||
const s = svgs(i);
|
||||
expect(s, `page ${i + 1}`).toHaveLength(1);
|
||||
expect(s[0].box.x).toBeCloseTo(0, 1);
|
||||
expect(s[0].box.y + s[0].box.h).toBeCloseTo(frame.page.height, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("keep-outs: bottom-left on every page, top-right on page 1 only; both clusters on Letter too", () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const frame = frameForPrefs(prefsFor(size, { templateId: "teal-swoosh" }));
|
||||
const k = tealSwoosh.decor.keepOuts!(frame);
|
||||
const bl = swooshKeepOuts(frame, "bottom-left");
|
||||
const tr = swooshKeepOuts(frame, "top-right");
|
||||
expect(k).toHaveLength(bl.length + tr.length);
|
||||
expect(k.filter((r) => r.page === undefined)).toHaveLength(bl.length);
|
||||
expect(k.filter((r) => r.page === 1)).toHaveLength(tr.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("no flowing text or image enters the keep-outs, with a long vendor name, 60 rows, on A4 and Letter", async () => {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
for (const spec of [FIXTURES.find((f) => f.name === "id-tokens")!, FIXTURES.find((f) => f.name === "rows-60")!, FIXTURES.find((f) => f.name === "long-address")!] satisfies FixtureSpec[]) {
|
||||
const r = await renderFixture(spec, prefsFor(size, { templateId: "teal-swoosh" }));
|
||||
const keep = r.result.issues.filter((i) => i.code === "keepout");
|
||||
expect(keep.map((i) => i.message), `${size}/${spec.name}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the keep-out audit does fire for a block placed in the swoosh", async () => {
|
||||
// Teal with page margins pulled in so the closing block would reach the swoosh: the audit must say so.
|
||||
const r = await renderFixture({ name: "k", items: 2, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const { auditLayout } = await import("../../engine/audit");
|
||||
const closing = nodes(r.result.core.layout).find((n) => n.id === CLOSING_ID)!;
|
||||
const inside = { x: 0, y: closing.box.y - 1, w: frame.page.width, h: closing.box.h + 2 };
|
||||
expect(auditLayout({ pages: r.result.core.layout }, frame, { keepOuts: [inside] }).some((i) => i.code === "keepout")).toBe(true);
|
||||
});
|
||||
|
||||
it("the logo stays inside its measured lockup box; a wordmark replaces the name, a mark keeps it", async () => {
|
||||
const slot = SLOTS["teal-swoosh"]!.lockup!;
|
||||
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const [img] = imageBoxes(wide);
|
||||
expect(img.box.x).toBeGreaterThanOrEqual(slot.x - 0.6);
|
||||
expect(img.box.y).toBeGreaterThanOrEqual(slot.y - 0.6);
|
||||
expect(img.box.x + img.box.w).toBeLessThanOrEqual(slot.x + slot.w + 0.6);
|
||||
expect(img.box.y + img.box.h).toBeLessThanOrEqual(slot.y + slot.h + 0.6);
|
||||
expect(wide.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(0);
|
||||
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
expect(square.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
|
||||
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
expect(none.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 120)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the continuation band and footer stay out of the swoosh on page 2", async () => {
|
||||
const r = await renderFixture({ name: "p", items: 40, logo: "none" }, prefsFor("A4", { templateId: "teal-swoosh" }));
|
||||
const band = r.pdf.pages[1].items.find((t) => t.str.startsWith("Tax Invoice"))!;
|
||||
expect(band.x0).toBeCloseTo(frameForPrefs(r.prefs).pad.left, 0);
|
||||
const footer = r.pdf.pages[1].items.filter((t) => t.top > 790);
|
||||
for (const t of footer) expect(t.x0, t.str).toBeGreaterThan(190);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the TOTAL row and the totals stack", () => {
|
||||
it("TOTAL carries the accent dash and every tax row sits above it", async () => {
|
||||
for (const t of BAND) {
|
||||
const r = await renderFixture({ name: "t", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
|
||||
const text = r.pdf.pages.flatMap((p) => p.items.map((i) => i.str));
|
||||
// Tracked labels are extracted letter by letter, so compare without spaces.
|
||||
const flat = text.join("").replace(/\s+/g, "").toUpperCase();
|
||||
expect(flat, t.id).toContain("TOTAL");
|
||||
for (const word of ["SUBTOTAL", "CGST@9%", "SGST@9%", "TAXABLEVALUE"]) expect(flat, `${t.id} ${word}`).toContain(word);
|
||||
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.amountInWords, SLOT_ID.signatory]) expect(ids.has(id), `${t.id} #${id}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("an unregistered invoice carries none of the GST slots", async () => {
|
||||
for (const t of BAND) {
|
||||
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 BAND) {
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan matches the laid-out blocks", () => {
|
||||
for (const t of BAND) {
|
||||
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, so the plan made here is the one the layout made (a logo only changes the header).
|
||||
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 = planBand(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
|
||||
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail);
|
||||
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("design docs", () => {
|
||||
it("each Band doc records its implementation notes", () => {
|
||||
for (const f of ["09-slate-band.md", "10-teal-swoosh.md"]) {
|
||||
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
|
||||
expect(text, f).toContain("## Implementation notes");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
void buildFixture;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { TemplateProps } from "../contract";
|
||||
import { BandLayout } from "./Layout";
|
||||
import type { BandVariant } from "./variant";
|
||||
|
||||
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
|
||||
export function BandLayoutFor(variant: BandVariant): ComponentType<TemplateProps> {
|
||||
const Layout = (props: TemplateProps) => <BandLayout {...props} variant={variant} />;
|
||||
Layout.displayName = `BandLayout(${variant.id})`;
|
||||
return Layout;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
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, CLOSING_GAP, elastic, rateCell } from "../neutral/plan";
|
||||
import type { BandVariant } from "./variant";
|
||||
|
||||
export const WORDS_GAP = 12;
|
||||
export const REVERSE_GAP = 4;
|
||||
export const HEADING_AFTER = 4;
|
||||
/** Space between the closing block's two columns. */
|
||||
export const CLOSING_COLUMN_GAP = 16;
|
||||
/** Slate: space between a mark and the business name in the dark band. */
|
||||
const NAME_AFTER_LOGO = 7.8;
|
||||
/** Space above and below the thin rule that separates the stack from TOTAL. */
|
||||
export const STACK_RULE = { before: 2, after: 8, thickness: 0.75 };
|
||||
const MIN_NAME_W = 40;
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const leading = (t: TypeToken) => t.leading as number;
|
||||
|
||||
export interface MetaItem {
|
||||
label?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function metaColumns(model: RenderModel, v: BandVariant): { left: MetaItem[]; right: MetaItem[] } {
|
||||
const date: MetaItem = { label: "Date:", value: model.meta.date };
|
||||
const due = model.meta.dueDate ? { label: "Due:", value: model.meta.dueDate } : null;
|
||||
const po = model.client.poNumber ? { label: "PO No:", value: model.client.poNumber } : null;
|
||||
if (v.id === "slate-band") return { left: [date, ...(po ? [po] : [])], right: due ? [due] : [] };
|
||||
// Teal prints the number in the right column of the date row.
|
||||
const no = { value: `${model.meta.numberLabel === "Invoice No." ? "NO." : model.meta.numberLabel} ${model.meta.number}` };
|
||||
return { left: [date, ...(due ? [due] : [])], right: [no, ...(po ? [{ value: `PO: ${po.value}` }] : [])] };
|
||||
}
|
||||
|
||||
export function signatureOptions(v: BandVariant): SignatoryOptions {
|
||||
const r = v.roles;
|
||||
return {
|
||||
roles: { name: r.bodyStrong, designation: r.small, fine: r.fine },
|
||||
align: "left",
|
||||
rule: { width: v.rule > 1 ? 0.75 : v.rule, color: v.ruleColor },
|
||||
color: v.text,
|
||||
imageHeight: v.signatureImageH,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BandPlan {
|
||||
v: BandVariant;
|
||||
W: number;
|
||||
/** Table (rows, rules, totals) width: the content width less the inset on both sides. */
|
||||
ruleW: number;
|
||||
/** Page edge to the table's left edge (the header band's bleed). */
|
||||
bleedLeft: number;
|
||||
gaps: { top: number; titleGap: number; titleBottom: number; meta: number; party: number; table: number; tail: number; notes: number };
|
||||
header: {
|
||||
/** Height of the logo / name / number row. */
|
||||
rowH: number;
|
||||
logoTop: number;
|
||||
nameW: number;
|
||||
nameH: number;
|
||||
nameTop: number;
|
||||
numW: number;
|
||||
numTop: number;
|
||||
showName: boolean;
|
||||
titleW: number;
|
||||
/** Slate: heights of the dark band and the title band, from the page top. */
|
||||
topBandH: number;
|
||||
titleBandH: number;
|
||||
};
|
||||
meta: { left: MetaItem[]; right: MetaItem[]; labelWLeft: number; labelWRight: number; rows: number; h: number };
|
||||
leftW: number;
|
||||
rightW: number;
|
||||
columns: ColSpec[];
|
||||
headerCells: string[];
|
||||
rows: TableCellValue[][];
|
||||
align: Array<"left" | "center" | "right">;
|
||||
table: ItemsTablePlan;
|
||||
guardH: number;
|
||||
totals: { w: number; labelW: number; amountW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; rowH: number; stackH: number; grandH: number };
|
||||
wordsH: number;
|
||||
reverseH: number;
|
||||
tailH: number;
|
||||
notesInClosing: boolean;
|
||||
notesLeftW: number;
|
||||
closingLeftW: number;
|
||||
closingRightW: number;
|
||||
payH: number;
|
||||
signH: number;
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
/** Everything the layout must know before rendering, including the presence values (each a hair high, never low). */
|
||||
export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, logo: PlacedLogo | null, includesName: boolean): BandPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const ruleW = round2(W - 2 * v.inset);
|
||||
const hsn = Slot.HsnSac(model);
|
||||
|
||||
// --- header row ----------------------------------------------------------------------------------------
|
||||
const zone = v.brandZone;
|
||||
const logoTop = logo ? (logo.slotOffset?.y ?? 0) : 0;
|
||||
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
|
||||
const markW = logo?.w ?? 0;
|
||||
const slate = v.id === "slate-band";
|
||||
const numText = `NO. ${model.meta.number}`;
|
||||
const numW = slate ? Math.ceil(measureToken(m, r.bandNumber, numText)) + 2 : 0;
|
||||
const limit = v.brandRightLimit !== null ? v.brandRightLimit - frame.pad.left : W;
|
||||
const rightEdge = slate ? W - numW - 16 : Math.min(W, limit);
|
||||
const nameLeft = markW > 0 ? markW + (showName ? v.markGap : 0) : 0;
|
||||
let nameW = 0;
|
||||
let nameH = 0;
|
||||
if (showName) {
|
||||
const natural = Math.ceil(measureToken(m, r.brand, model.supplier.name)) + 1;
|
||||
nameW = Math.max(MIN_NAME_W, Math.min(natural, rightEdge - nameLeft));
|
||||
nameH = textHeight(r.brand, model.supplier.name, nameW);
|
||||
}
|
||||
const logoBottom = zone.top + (logo ? logoTop + logo.h : 0);
|
||||
const rowH = Math.max(zone.top + zone.h, logoBottom, slate ? nameH : zone.top + nameH);
|
||||
const nameTop = slate ? (rowH - nameH) / 2 : logo ? zone.top + Math.max(0, logoTop + (logo.h - nameH) / 2) : zone.top + Math.max(0, (zone.h - nameH) / 2);
|
||||
const numTop = slate ? (rowH - leading(r.bandNumber)) / 2 : 0;
|
||||
const titleW = Math.min(W, Math.ceil(measureToken(m, r.title, model.docTitle)) + 1);
|
||||
|
||||
// --- meta and parties ---------------------------------------------------------------------------------
|
||||
const cols = metaColumns(model, v);
|
||||
const labelOf = (items: MetaItem[]) => {
|
||||
const labelled = items.filter((i) => i.label);
|
||||
return labelled.length ? Math.ceil(Math.max(...labelled.map((i) => measureToken(m, r.label, i.label as string)))) + v.labelGap : 0;
|
||||
};
|
||||
const metaRows = Math.max(cols.left.length, cols.right.length);
|
||||
const metaH = metaRows * leading(r.dateValue);
|
||||
const leftW = round2(W * v.partyLeftShare);
|
||||
const rightW = round2(W - leftW - v.partyGap);
|
||||
const s = model.supplier;
|
||||
const c = model.client;
|
||||
let clientH = leading(r.label) + v.headingGap + textHeight(r.body, c.name, leftW);
|
||||
for (const l of c.addressLines) clientH += textHeight(r.body, l, leftW);
|
||||
clientH += slotHeights.gstinLines(model, "client") * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, leftW) * leading(r.body);
|
||||
let fromH = leading(r.label) + v.headingGap + textHeight(r.body, s.name, rightW);
|
||||
for (const l of s.addressLines) fromH += textHeight(r.body, l, rightW);
|
||||
fromH += ([s.phone, s.email, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier")) * leading(r.body);
|
||||
const partyH = Math.max(clientH, fromH);
|
||||
|
||||
// --- table: description | qty | price | total ------------------------------------------------------------
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
|
||||
const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1);
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const cw = {
|
||||
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"),
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"),
|
||||
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
|
||||
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
|
||||
const align: BandPlan["align"] = ["left", "center", "right", "right"];
|
||||
const rows: TableCellValue[][] = model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, cw.rate - 4, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [desc, item.qtyText, rateParts, item.amountText];
|
||||
});
|
||||
|
||||
// --- totals stack and TOTAL row ---------------------------------------------------------------------------
|
||||
const t = model.totals;
|
||||
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: t.subtotalText }];
|
||||
if (t.discountText) stack.push({ label: "Discount", amount: `- ${t.discountText}` });
|
||||
if (t.taxableText) stack.push({ label: "Taxable value", amount: t.taxableText });
|
||||
const slotRows = slotHeights.taxRowCount(model);
|
||||
const labelTexts = ["Subtotal", "Discount", "Taxable value", "UTGST @ 14%"];
|
||||
const amountW = Math.max(v.totals.amountW, Math.ceil(Math.max(widest(r.totalValue, [t.subtotalText, t.totalText, t.discountText ? `- ${t.discountText}` : "", t.taxableText ?? ""]), widest(r.grandValue, [t.totalText]))) + 2);
|
||||
const labelW = Math.max(v.totals.labelW, Math.ceil(Math.max(widest(r.totalLabel, labelTexts), widest(r.grand, ["Total"]))) + 8);
|
||||
const totalsW = Math.min(labelW + amountW, ruleW - v.totals.dash.w - 16);
|
||||
const stackRowH = Math.max(leading(r.totalLabel), leading(r.totalValue)) + v.totals.rowGap;
|
||||
const stackH = (stack.length + slotRows) * stackRowH;
|
||||
const grandH = Math.max(leading(r.grand), leading(r.grandValue));
|
||||
const wordsH = slotHeights.amountInWords(model, ruleW, { role: r.small, labelRole: r.wordsLabel });
|
||||
const reverseH = slotHeights.reverseCharge(model, ruleW, r.small);
|
||||
const tailBody = stackH + STACK_RULE.before + STACK_RULE.thickness + STACK_RULE.after + grandH + 1 + (wordsH ? WORDS_GAP + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
|
||||
|
||||
// --- closing -----------------------------------------------------------------------------------------------
|
||||
const closingRightW = Math.min(v.closingRightW, Math.floor(W * 0.5));
|
||||
const closingLeftW = ruleW - closingRightW - CLOSING_COLUMN_GAP;
|
||||
const lines = bankLines(model);
|
||||
let payH = 0;
|
||||
if (model.bank) {
|
||||
payH = leading(r.label) + HEADING_AFTER;
|
||||
for (const l of lines) payH += textHeight(r.small, l, slate ? closingLeftW : closingRightW);
|
||||
}
|
||||
const signH = signatoryHeight(model, closingRightW, signatureOptions(v));
|
||||
const notesLeftW = closingLeftW;
|
||||
const notesH = model.notesLines.length ? leading(r.label) + HEADING_AFTER + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, notesLeftW), 0) : 0;
|
||||
const notesInClosing = notesH > 0 && notesH <= v.notesBesideMax;
|
||||
const NOTES_UNDER_PAY = 10;
|
||||
const leftH = slate ? payH + (notesInClosing ? (payH ? NOTES_UNDER_PAY : 0) + notesH : 0) : notesInClosing ? notesH : 0;
|
||||
const rightH = slate ? signH : payH + (payH ? 14 : 0) + signH;
|
||||
let closingH = CLOSING_GAP + Math.max(leftH, rightH) + 1.5;
|
||||
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
|
||||
|
||||
// --- the header's vertical geometry and the elastic gaps ---------------------------------------------------
|
||||
const ownNotes = model.notesLines.length > 0 && !notesInClosing;
|
||||
const ownNotesH = ownNotes ? leading(r.label) + HEADING_AFTER + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, ruleW), 0) : 0;
|
||||
const bands = v.bands;
|
||||
const ranges = {
|
||||
top: bands ? bands.topPadBottom : { nominal: 0, min: 0 },
|
||||
titleGap: v.titleGap,
|
||||
meta: v.gaps.meta,
|
||||
party: v.gaps.party,
|
||||
table: v.gaps.table,
|
||||
tail: v.gaps.tail,
|
||||
notes: v.gaps.notes,
|
||||
};
|
||||
const titleBandH = bands ? bands.titlePadTop + leading(r.title) + bands.titlePadBottom : 0;
|
||||
const tailNominal = ranges.tail.nominal + tailBody;
|
||||
const table0 = planTable(v, ruleW, columns, headerCells, rows, frame, tailNominal);
|
||||
const tableTotal = table0.headH + table0.rowHeights.reduce((a, b) => a + b, 0);
|
||||
const headerBlock = slate
|
||||
? rowH + ranges.top.nominal + titleBandH
|
||||
: rowH + ranges.titleGap.nominal + leading(r.title);
|
||||
const nominalTotal =
|
||||
headerBlock + ranges.meta.nominal + metaH + ranges.party.nominal + partyH + ranges.table.nominal + tableTotal + tailNominal + (ownNotes ? ranges.notes.nominal + ownNotesH : 0) + closingH;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0);
|
||||
const g = elastic(ranges, deficit);
|
||||
const tailH = g.tail + tailBody;
|
||||
const table = planTable(v, ruleW, columns, headerCells, rows, frame, tailH);
|
||||
// Rows are unbreakable, so the guard asks for the whole first row (the shared 3-line cap leaves a header alone
|
||||
// above a taller row); only a row beyond 40% of the page is capped, so it cannot push the table off every page.
|
||||
// A one-row table has no previous row to carry the last row's own presence value (the row is first in its
|
||||
// container, where react-pdf never breaks before it), so the guard also holds the tail.
|
||||
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (rows.length === 1 ? tailH : 0);
|
||||
|
||||
return {
|
||||
v,
|
||||
W,
|
||||
ruleW,
|
||||
bleedLeft: round2(frame.pad.left + v.inset),
|
||||
gaps: { top: g.top, titleGap: g.titleGap, titleBottom: bands?.titlePadBottom ?? 0, meta: g.meta, party: g.party, table: g.table, tail: g.tail, notes: g.notes },
|
||||
header: {
|
||||
rowH,
|
||||
logoTop,
|
||||
nameW,
|
||||
nameH,
|
||||
nameTop,
|
||||
numW,
|
||||
numTop,
|
||||
showName,
|
||||
titleW,
|
||||
topBandH: slate ? round2(frame.pad.top + rowH + g.top) : 0,
|
||||
titleBandH,
|
||||
},
|
||||
meta: { ...cols, labelWLeft: labelOf(cols.left), labelWRight: labelOf(cols.right), rows: metaRows, h: metaH },
|
||||
leftW,
|
||||
rightW,
|
||||
columns,
|
||||
headerCells,
|
||||
rows,
|
||||
align,
|
||||
table,
|
||||
guardH,
|
||||
totals: { w: totalsW, labelW: totalsW - amountW, amountW, stack, slotRows, rowH: stackRowH, stackH, grandH },
|
||||
wordsH,
|
||||
reverseH,
|
||||
tailH,
|
||||
notesInClosing,
|
||||
notesLeftW,
|
||||
closingLeftW,
|
||||
closingRightW,
|
||||
payH,
|
||||
signH,
|
||||
closingH,
|
||||
};
|
||||
}
|
||||
|
||||
function planTable(v: BandVariant, ruleW: number, columns: ColSpec[], header: string[], rows: TableCellValue[][], frame: PageFrame, tailH: number): ItemsTablePlan {
|
||||
const r = v.roles;
|
||||
return planItemsTable({
|
||||
columns,
|
||||
gap: 0,
|
||||
width: ruleW,
|
||||
header,
|
||||
rows,
|
||||
measurer: getMeasurer(),
|
||||
roles: { head: r.tableHead, body: r.tableBody },
|
||||
padX: 0,
|
||||
padY: v.padY,
|
||||
headPadY: v.headPadY,
|
||||
border: v.rule,
|
||||
headBorder: 0,
|
||||
tailH,
|
||||
contentH: frame.contentHeight,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { View } from "@react-pdf/renderer";
|
||||
import { defineRoles } from "../../fonts/roles";
|
||||
import { FitText } from "../../blocks/FitText";
|
||||
import { Txt } from "../../blocks/Txt";
|
||||
import type { DecorItem } from "../../blocks/FirstPageHeader";
|
||||
import { CircledIcon } from "../../decor/CircledIcon";
|
||||
import type { IconName } from "../../decor/icons.generated";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { BandLayoutFor } from "./layoutFor";
|
||||
import type { BandRoles, BandVariant } from "./variant";
|
||||
|
||||
const F = "Montserrat" as const;
|
||||
const SLATE = "#4D5869";
|
||||
const BLUE = "#0278B2";
|
||||
const RULE = "#C1C5CE";
|
||||
|
||||
/**
|
||||
* Slate Band's type: Montserrat 400/500/700/800 (docs/templates/09-slate-band.md). Ascent 0.968, descent 0.251.
|
||||
* Tracked caps are labels, never data; data text stays at or under 0.05 em.
|
||||
*/
|
||||
export const SLATE_ROLES = defineRoles({
|
||||
title: { face: F, weight: 800, size: 30, leading: 36, tracking: 0.224, caps: true },
|
||||
brand: { face: F, weight: 500, size: 12, leading: 16.5, minSize: 9 },
|
||||
bandNumber: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.022 },
|
||||
label: { face: F, weight: 800, size: 12, leading: 16.5 },
|
||||
dateValue: { face: F, weight: 400, size: 13, leading: 16.5, tracking: 0.047 },
|
||||
body: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.023 },
|
||||
bodyStrong: { face: F, weight: 700, size: 12, leading: 16.5, tracking: 0.02 },
|
||||
tableHead: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.165, caps: true },
|
||||
tableBody: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.025 },
|
||||
tableSub: { face: F, weight: 400, size: 10, leading: 13 },
|
||||
totalLabel: { face: F, weight: 700, size: 11, leading: 15, tracking: 0.136, caps: true },
|
||||
totalValue: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.025 },
|
||||
grand: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.16, caps: true },
|
||||
grandValue: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.04 },
|
||||
small: { face: F, weight: 400, size: 11, leading: 15 },
|
||||
wordsLabel: { face: F, weight: 800, size: 11, leading: 15 },
|
||||
fine: { face: F, weight: 400, size: 8.5, leading: 12 },
|
||||
contact: { face: F, weight: 400, size: 10, leading: 13, minSize: 7 },
|
||||
footer: { face: F, weight: 400, size: 7 },
|
||||
} satisfies BandRoles);
|
||||
|
||||
/**
|
||||
* Canva A4 numbers: the logo starts at y 55.75, the dark band runs to y 103.5 and the blue one to 157.35
|
||||
* (53.8 tall); the table's text is inset 9.8 from the 59.55 content edge. The footer rule sits 86.7 above
|
||||
* the page bottom, so the bottom margin floor plus the footer reserve is at least 99.
|
||||
*/
|
||||
export const SLATE_VARIANT: BandVariant = {
|
||||
id: "slate-band",
|
||||
roles: SLATE_ROLES,
|
||||
ink: "#2D3743",
|
||||
text: SLATE,
|
||||
accent: BLUE,
|
||||
headBand: "#EEEFF1",
|
||||
ruleColor: RULE,
|
||||
margins: { top: 55.75, right: 59.55, bottom: 20, left: 59.55 },
|
||||
marginFloor: { top: 36, right: 36, bottom: 20, left: 36 },
|
||||
brandZone: { top: 0, h: 31.25 },
|
||||
markGap: 7.8,
|
||||
brandRightLimit: null,
|
||||
bands: { top: SLATE, title: BLUE, titleText: "#FDFCFD", topPadBottom: { nominal: 16.5, min: 8 }, titlePadTop: 6.5, titlePadBottom: 11.3 },
|
||||
titleGap: { nominal: 0, min: 0 },
|
||||
gaps: {
|
||||
meta: { nominal: 23.5, min: 11.5 },
|
||||
party: { nominal: 7.55, min: 5 },
|
||||
table: { nominal: 26.4, min: 14 },
|
||||
tail: { nominal: 27, min: 12 },
|
||||
notes: { nominal: 12, min: 8 },
|
||||
},
|
||||
inset: 9.8,
|
||||
partyLeftShare: 0.568,
|
||||
partyGap: 12,
|
||||
headingGap: 4.76,
|
||||
labelGap: 12.5,
|
||||
padY: 13.5,
|
||||
headPadY: 20.6,
|
||||
rule: 2.25,
|
||||
cols: { qty: 55, rate: 95, amount: 105 },
|
||||
totals: { labelW: 120, amountW: 105, rowGap: 5, dash: { w: 41.85, h: 3 } },
|
||||
closingRightW: 170,
|
||||
signatureImageH: 40,
|
||||
notesBesideMax: 100,
|
||||
chromeInset: 0,
|
||||
footerLeftW: 140,
|
||||
};
|
||||
|
||||
const RULE_FROM_BOTTOM = 86.7;
|
||||
const ICON = 19.2;
|
||||
const ICON_FROM_BOTTOM = 71.05;
|
||||
/** Footer groups share the table's inset width in these proportions (phone, e-mail, address). */
|
||||
const GROUPS: Array<{ icon: IconName; share: number }> = [
|
||||
{ icon: "phone", share: 0.24 },
|
||||
{ icon: "email", share: 0.37 },
|
||||
{ icon: "location", share: 0.39 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The every-page footer: a soft rule and up to three circled contacts. It is decor (fixed, page space), so
|
||||
* each text is fitted to one line (shrunk, then cut with an ellipsis); the full address is printed in the From block.
|
||||
*/
|
||||
function footerDecor(frame: PageFrame, model: RenderModel): DecorItem[] {
|
||||
const r = SLATE_ROLES.contact;
|
||||
const { width: pw, height: ph } = frame.page;
|
||||
const present = GROUPS.map((g) => ({
|
||||
...g,
|
||||
text: g.icon === "phone" ? model.supplier.phone : g.icon === "email" ? model.supplier.email : model.supplier.addressLines.join(", "),
|
||||
})).filter((g) => g.text.trim() !== "");
|
||||
const items: DecorItem[] = [
|
||||
{ x: frame.pad.left, y: ph - RULE_FROM_BOTTOM, width: frame.contentWidth, height: 2.25, children: <View style={{ height: 2.25, backgroundColor: RULE }} /> },
|
||||
];
|
||||
const total = present.reduce((sum, g) => sum + g.share, 0);
|
||||
const innerW = frame.contentWidth - 2 * SLATE_VARIANT.inset;
|
||||
let x = frame.pad.left + SLATE_VARIANT.inset;
|
||||
for (const g of present) {
|
||||
const w = (innerW * g.share) / total;
|
||||
const textW = w - ICON - 6 - 8;
|
||||
items.push({
|
||||
x,
|
||||
y: ph - ICON_FROM_BOTTOM,
|
||||
width: w,
|
||||
height: g.icon === "location" ? Math.max(ICON, 2 * (r.leading as number)) : ICON,
|
||||
children: (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", width: w }}>
|
||||
<CircledIcon name={g.icon} size={ICON} color={BLUE} ring={1.2} />
|
||||
{g.icon === "location" ? (
|
||||
// The address may take two lines (cut with an ellipsis after that); phone and e-mail are one fitted line.
|
||||
<View style={{ marginLeft: 6 }}>
|
||||
<Txt role={r} color={SLATE} width={textW} maxLines={2}>
|
||||
{g.text}
|
||||
</Txt>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginLeft: 6 }}>
|
||||
<FitText role={r} color={SLATE} width={textW}>
|
||||
{g.text}
|
||||
</FitText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
x += w;
|
||||
}
|
||||
void pw;
|
||||
return items;
|
||||
}
|
||||
|
||||
export const slateBand: TemplateDefinition = {
|
||||
...catalogEntry("slate-band"),
|
||||
version: 1,
|
||||
roles: SLATE_ROLES,
|
||||
margins: SLATE_VARIANT.margins,
|
||||
marginFloor: SLATE_VARIANT.marginFloor,
|
||||
decor: { ink: SLATE, everyPage: (frame, model) => footerDecor(frame, model) },
|
||||
chrome: {
|
||||
color: "#6f6f6f",
|
||||
rule: null,
|
||||
role: SLATE_ROLES.footer,
|
||||
inset: { left: SLATE_VARIANT.chromeInset, right: SLATE_VARIANT.chromeInset },
|
||||
leftWidth: SLATE_VARIANT.footerLeftW,
|
||||
// 20 + 79 = 99 = the rule's 86.7 plus 12 of air, on every margin preset.
|
||||
reserve: 79,
|
||||
bottom: 22,
|
||||
},
|
||||
logo: {
|
||||
box: { w: 99.25, h: 31.25 },
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: true,
|
||||
slotWidth: () => 99.25,
|
||||
slot: slotFor("slate-band"),
|
||||
available: () => ({ w: 99.25, h: 31.25 }),
|
||||
},
|
||||
Layout: BandLayoutFor(SLATE_VARIANT),
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { defineRoles } from "../../fonts/roles";
|
||||
import { cornerSwoosh, swooshKeepOuts } from "../../decor/CornerSwoosh";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { BandLayoutFor } from "./layoutFor";
|
||||
import type { BandRoles, BandVariant } from "./variant";
|
||||
|
||||
const F = "Montserrat" as const;
|
||||
const INK = "#333132";
|
||||
const TEAL = "#32B3C7";
|
||||
|
||||
/**
|
||||
* Teal Swoosh's type: Montserrat 400/700/800 (docs/templates/10-teal-swoosh.md), one colour. The business
|
||||
* name is set at 13 pt (the design's 10.75 pt belongs to a short placeholder).
|
||||
*/
|
||||
export const TEAL_ROLES = defineRoles({
|
||||
title: { face: F, weight: 800, size: 30, leading: 36, tracking: 0.26, caps: true },
|
||||
brand: { face: F, weight: 700, size: 13, leading: 17, minSize: 9 },
|
||||
bandNumber: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.025 },
|
||||
label: { face: F, weight: 800, size: 12, leading: 16.5, tracking: 0.025 },
|
||||
dateValue: { face: F, weight: 400, size: 13, leading: 16.5, tracking: 0.05 },
|
||||
body: { face: F, weight: 400, size: 12, leading: 16.5, tracking: 0.025 },
|
||||
bodyStrong: { face: F, weight: 700, size: 12, leading: 16.5, tracking: 0.025 },
|
||||
tableHead: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.2, caps: true },
|
||||
tableBody: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.03 },
|
||||
tableSub: { face: F, weight: 400, size: 10, leading: 13 },
|
||||
totalLabel: { face: F, weight: 700, size: 10.5, leading: 15, tracking: 0.152, caps: true },
|
||||
totalValue: { face: F, weight: 400, size: 12, leading: 14.7, tracking: 0.025 },
|
||||
grand: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.2, caps: true },
|
||||
grandValue: { face: F, weight: 700, size: 14, leading: 17.1, tracking: 0.05 },
|
||||
small: { face: F, weight: 400, size: 10.5, leading: 15 },
|
||||
wordsLabel: { face: F, weight: 800, size: 10.5, leading: 15 },
|
||||
fine: { face: F, weight: 400, size: 8.5, leading: 12 },
|
||||
contact: { face: F, weight: 400, size: 10, leading: 13 },
|
||||
footer: { face: F, weight: 400, size: 7 },
|
||||
} satisfies BandRoles);
|
||||
|
||||
/**
|
||||
* Canva A4 numbers: the logo mark's top is at y 59.5 (4.5 below the 55 pt margin), the title baseline at
|
||||
* 144, the table text inset 9.8. The bottom-left swoosh reaches up to y 654 on the page edge, so the bottom
|
||||
* padding is 110: the flow stays above its inner edge on every page.
|
||||
*/
|
||||
export const TEAL_VARIANT: BandVariant = {
|
||||
id: "teal-swoosh",
|
||||
roles: TEAL_ROLES,
|
||||
ink: INK,
|
||||
text: INK,
|
||||
accent: TEAL,
|
||||
headBand: "#EFF1F2",
|
||||
ruleColor: "#C2C7CF",
|
||||
margins: { top: 55, right: 59.55, bottom: 24, left: 59.55 },
|
||||
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
|
||||
brandZone: { top: 4.5, h: 29.25 },
|
||||
markGap: 6.5,
|
||||
// The silver crescent's inner edge is near x 478 at the brand row; the design keeps content within about x 440.
|
||||
brandRightLimit: 440,
|
||||
bands: null,
|
||||
titleGap: { nominal: 26.2, min: 14 },
|
||||
gaps: {
|
||||
meta: { nominal: 29.9, min: 16 },
|
||||
party: { nominal: 7.55, min: 5 },
|
||||
table: { nominal: 26.4, min: 14 },
|
||||
tail: { nominal: 27, min: 12 },
|
||||
notes: { nominal: 12, min: 8 },
|
||||
},
|
||||
inset: 9.8,
|
||||
partyLeftShare: 0.568,
|
||||
partyGap: 12,
|
||||
headingGap: 4.76,
|
||||
labelGap: 12.5,
|
||||
padY: 13.5,
|
||||
headPadY: 20.6,
|
||||
rule: 2.25,
|
||||
cols: { qty: 55, rate: 95, amount: 105 },
|
||||
totals: { labelW: 120, amountW: 105, rowGap: 5, dash: { w: 41.85, h: 3 } },
|
||||
closingRightW: 181,
|
||||
signatureImageH: 40,
|
||||
notesBesideMax: 100,
|
||||
chromeInset: 140,
|
||||
footerLeftW: 0,
|
||||
};
|
||||
|
||||
export const tealSwoosh: TemplateDefinition = {
|
||||
...catalogEntry("teal-swoosh"),
|
||||
version: 1,
|
||||
roles: TEAL_ROLES,
|
||||
margins: TEAL_VARIANT.margins,
|
||||
marginFloor: TEAL_VARIANT.marginFloor,
|
||||
decor: {
|
||||
ink: INK,
|
||||
// The top-right cluster is page 1 only (it sits in the first-page header): on continuation pages the
|
||||
// table band starts at the top margin, right under where the crescent would be.
|
||||
everyPage: (frame) => [cornerSwoosh(frame, "bottom-left")],
|
||||
keepOuts: (frame) => [...swooshKeepOuts(frame, "bottom-left"), ...swooshKeepOuts(frame, "top-right").map((r) => ({ ...r, page: 1 }))],
|
||||
},
|
||||
chrome: {
|
||||
color: "#6f6f6f",
|
||||
rule: null,
|
||||
role: TEAL_ROLES.footer,
|
||||
// The footer starts right of the bottom-left swoosh (its inner edge is at x 185 at the footer's height).
|
||||
inset: { left: TEAL_VARIANT.chromeInset, right: 0 },
|
||||
bandInset: { left: 0, right: 0 },
|
||||
leftWidth: 0,
|
||||
footerName: false,
|
||||
// 24 + 86 = 110.
|
||||
reserve: 86,
|
||||
bottom: 22,
|
||||
},
|
||||
logo: {
|
||||
box: { w: 125.75, h: 29.25 },
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: true,
|
||||
slotWidth: () => 125.75,
|
||||
slot: slotFor("teal-swoosh"),
|
||||
available: () => ({ w: 125.75, h: 29.25 }),
|
||||
},
|
||||
Layout: BandLayoutFor(TEAL_VARIANT),
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Sides } from "../../engine/geometry";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
import type { GapRange } from "../neutral/variant";
|
||||
|
||||
/** Type roles of a Band template (Montserrat throughout). */
|
||||
export interface BandRoles {
|
||||
/** "INVOICE" / "TAX INVOICE", widely tracked caps. */
|
||||
title: TypeToken;
|
||||
/** The business name beside the logo (white in Slate's band). */
|
||||
brand: TypeToken;
|
||||
/** "NO. 000001" in Slate's band. */
|
||||
bandNumber: TypeToken;
|
||||
/** "Date:", "Billed to:", "From:", "Payment details:", "Note:". */
|
||||
label: TypeToken;
|
||||
dateValue: TypeToken;
|
||||
body: TypeToken;
|
||||
bodyStrong: TypeToken;
|
||||
tableHead: TypeToken;
|
||||
tableBody: TypeToken;
|
||||
/** Grey sub-lines under a cell: the HSN/SAC code, the rate unit. */
|
||||
tableSub: TypeToken;
|
||||
/** SUBTOTAL, DISCOUNT, CGST ... labels (tracked caps) and their amounts. */
|
||||
totalLabel: TypeToken;
|
||||
totalValue: TypeToken;
|
||||
/** The TOTAL row. */
|
||||
grand: TypeToken;
|
||||
grandValue: TypeToken;
|
||||
small: TypeToken;
|
||||
wordsLabel: TypeToken;
|
||||
fine: TypeToken;
|
||||
/** Slate's footer contacts. */
|
||||
contact: TypeToken;
|
||||
/** Footer and continuation band: a render-prop role (no leading). */
|
||||
footer: TypeToken;
|
||||
}
|
||||
|
||||
export type BandId = "slate-band" | "teal-swoosh";
|
||||
|
||||
/**
|
||||
* What differs between Slate Band and Teal Swoosh. They share one body (date row, two parties, a
|
||||
* full-bleed table band, airy rows, the TOTAL row, closing); the header and the page art differ.
|
||||
* y values are design distances measured on the Canva A4 page, converted to flow gaps.
|
||||
*/
|
||||
export interface BandVariant {
|
||||
id: BandId;
|
||||
roles: BandRoles;
|
||||
/** Labels, party text, the title (Teal). */
|
||||
ink: string;
|
||||
/** Table text, totals, payment text (the same as `ink` in Teal). */
|
||||
text: string;
|
||||
accent: string;
|
||||
/** The table header's full-bleed band. */
|
||||
headBand: string;
|
||||
/** Row rules and the footer rule. */
|
||||
ruleColor: string;
|
||||
margins: Sides;
|
||||
marginFloor: Sides;
|
||||
|
||||
/** The brand zone of the header row: where the slot starts below the row top, and its height. */
|
||||
brandZone: { top: number; h: number };
|
||||
/** Gap between a mark and the name beside it. */
|
||||
markGap: number;
|
||||
/** Rightmost x of the brand block, measured from the page's left edge (Teal keeps clear of the swoosh). */
|
||||
brandRightLimit: number | null;
|
||||
/** Slate: the dark band's colours and the paddings that turn flow positions into band edges. */
|
||||
bands: { top: string; title: string; titleText: string; topPadBottom: GapRange; titlePadTop: number; titlePadBottom: number } | null;
|
||||
/** Teal: space between the brand row and the title. */
|
||||
titleGap: GapRange;
|
||||
|
||||
gaps: {
|
||||
/** The header's last element to the date row. */
|
||||
meta: GapRange;
|
||||
/** Date row to the party headings. */
|
||||
party: GapRange;
|
||||
/** Party blocks to the table band. */
|
||||
table: GapRange;
|
||||
/** Last row rule to the totals stack. */
|
||||
tail: GapRange;
|
||||
notes: GapRange;
|
||||
};
|
||||
/** Content edge to the table's text (the table's inset). */
|
||||
inset: number;
|
||||
/** Width of the Billed to column as a share of the content width, and the gap to the From column. */
|
||||
partyLeftShare: number;
|
||||
partyGap: number;
|
||||
headingGap: number;
|
||||
/** Space between the date row's label and its value. */
|
||||
labelGap: number;
|
||||
|
||||
/** Row paddings; the header band is taller than a row. */
|
||||
padY: number;
|
||||
headPadY: number;
|
||||
rule: number;
|
||||
cols: { qty: number; rate: number; amount: number };
|
||||
/** The totals stack: label column minimum, and the pitch of its rows. */
|
||||
totals: { labelW: number; amountW: number; rowGap: number; dash: { w: number; h: number } };
|
||||
closingRightW: number;
|
||||
signatureImageH: number;
|
||||
notesBesideMax: number;
|
||||
chromeInset: number;
|
||||
footerLeftW: number;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { catalogEntry, FAMILY_LABELS, TEMPLATE_CATALOG } from "./catalog";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("groups the C4 templates into the Ledger and Band families", () => {
|
||||
const byFamily = (f: string) => TEMPLATE_CATALOG.filter((e) => e.family === f).map((e) => e.id);
|
||||
expect(byFamily("ledger")).toEqual(["crimson-grid", "tangerine-ledger"]);
|
||||
expect(byFamily("band")).toEqual(["slate-band", "teal-swoosh"]);
|
||||
expect(byFamily("neutral")).toEqual(["linea", "monogram", "serenity"]);
|
||||
expect(FAMILY_LABELS.ledger).toBe("Ledger");
|
||||
expect(FAMILY_LABELS.band).toBe("Band");
|
||||
});
|
||||
|
||||
it("every entry has a name, a family label and a thumbnail on disk; every definition agrees", () => {
|
||||
for (const e of TEMPLATE_CATALOG) {
|
||||
expect(e.name.length, e.id).toBeGreaterThan(0);
|
||||
expect(FAMILY_LABELS[e.family], e.id).toBeTruthy();
|
||||
expect(e.thumbnail).toBe(`/templates/${e.id}.png`);
|
||||
expect(existsSync(path.join(process.cwd(), "public", e.thumbnail)), e.thumbnail).toBe(true);
|
||||
expect(TEMPLATES[e.id].family).toBe(e.family);
|
||||
expect(TEMPLATES[e.id].name).toBe(e.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to Classic for an unknown id", () => {
|
||||
expect(catalogEntry("no-such").id).toBe("classic");
|
||||
expect(catalogEntry("slate-band").name).toBe("Slate Band");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
export interface TemplateCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
family: "classic" | "neutral";
|
||||
family: "classic" | "neutral" | "ledger" | "band";
|
||||
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
|
||||
thumbnail: string;
|
||||
badge?: "Matches your original format";
|
||||
@@ -16,6 +16,8 @@ export interface TemplateCatalogEntry {
|
||||
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
|
||||
classic: "Classic",
|
||||
neutral: "Neutral",
|
||||
ledger: "Ledger",
|
||||
band: "Band",
|
||||
};
|
||||
|
||||
/** Picker order: Classic first, Linea (the vendor's original format) second. */
|
||||
@@ -24,6 +26,10 @@ export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
|
||||
{ id: "linea", name: "Linea", family: "neutral", thumbnail: "/templates/linea.png", badge: "Matches your original format", logoOptIn: true },
|
||||
{ id: "monogram", name: "Monogram", family: "neutral", thumbnail: "/templates/monogram.png" },
|
||||
{ id: "serenity", name: "Serenity", family: "neutral", thumbnail: "/templates/serenity.png" },
|
||||
{ id: "crimson-grid", name: "Crimson Grid", family: "ledger", thumbnail: "/templates/crimson-grid.png" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("classic plan matches the laid-out blocks", () => {
|
||||
|
||||
describe("registry", () => {
|
||||
it("has Classic and falls back to it for an unknown or missing id", () => {
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity", "crimson-grid", "tangerine-ledger", "slate-band", "teal-swoosh"]);
|
||||
expect(getTemplate("classic")).toBe(classic);
|
||||
expect(getTemplate("no-such-template")).toBe(classic);
|
||||
expect(getTemplate(undefined)).toBe(classic);
|
||||
|
||||
@@ -26,10 +26,13 @@ export interface TemplateDecor {
|
||||
background?: string;
|
||||
/** Default text colour of the page. */
|
||||
ink: string;
|
||||
/** Fixed every-page decor in page space; must stay clear of the content column. */
|
||||
everyPage?: (frame: PageFrame) => DecorItem[];
|
||||
/** Page-space boxes the flow must stay out of (audited as `keepout`). */
|
||||
keepOuts?: (frame: PageFrame) => Rect[];
|
||||
/**
|
||||
* Fixed every-page decor in page space; must stay clear of the content column. It gets the model for
|
||||
* supplier contact lines (a footer band); decor is exempt from the audit, so regulated slots never go here.
|
||||
*/
|
||||
everyPage?: (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 }>;
|
||||
}
|
||||
|
||||
/** Footer and page-label chrome of a template. */
|
||||
@@ -45,6 +48,16 @@ export interface TemplateChrome {
|
||||
leftWidth: number;
|
||||
/** Height reserved at the foot of every page for the footer line; default FOOTER_RESERVE (it includes Classic's rule). */
|
||||
reserve?: number;
|
||||
/** Footer row offset from the page bottom, pt. Default: the bottom margin. A template with a footer band keeps the text inside it whatever the margin preset. */
|
||||
bottom?: number;
|
||||
/** The continuation band's own inset (page 2+, top). Default: `inset`. Corner art at the top right moves the band's right edge in. */
|
||||
bandInset?: { left: number; right: number };
|
||||
/** Colour of the continuation band's text; default `color` (a template whose footer sits on a dark band needs its own). */
|
||||
bandColor?: string;
|
||||
/** Show "continued" at the band's right end. Default true. */
|
||||
bandRight?: boolean;
|
||||
/** Show the supplier name at the footer's left. Default true. */
|
||||
footerName?: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateProps {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/** The per-render assertions shared by the harness test files. */
|
||||
import { expect } from "vitest";
|
||||
import type { AuditIssue } from "../engine/audit";
|
||||
import { PAGE_SIZES } from "../engine/geometry";
|
||||
import { PAGE_LABEL_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, CLOSING_ID, TOTALS_ID } from "../engine/ids";
|
||||
import { walk, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
|
||||
import { forbiddenProbe, SLOT_RULES, slotContent, type SlotName } from "../model/slots";
|
||||
import { frameForPrefs } from "../render/invoice";
|
||||
import { SLOT_ID } from "../blocks/slots";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "./classic/ids";
|
||||
import { HEADER_GAP, headerCols } from "./classic/plan";
|
||||
import { FIXTURE_GSTIN, type Rendered } from "./harness";
|
||||
|
||||
/**
|
||||
* Template harness: every registered template x {A4, Letter} x the fixture matrix, plus an item-count
|
||||
* sweep. A failure here means the template or a primitive is wrong, not the assertion.
|
||||
*/
|
||||
|
||||
const SIZE_PT = { A4: [PAGE_SIZES.A4.width, PAGE_SIZES.A4.height], LETTER: [612, 792] } as const;
|
||||
|
||||
const squash = (s: string) => s.replace(/\s+/g, "").toLowerCase();
|
||||
const textOf = (r: Rendered) => r.pdf.pages.flatMap((p) => p.items.map((t) => t.str)).join(" ");
|
||||
|
||||
const nodesOf = (page: LayoutPage) => {
|
||||
const out: LayoutNode[] = [];
|
||||
walk(page.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
const withId = (page: LayoutPage, id: string) => nodesOf(page).filter((n) => n.id === id);
|
||||
const withIdPrefix = (page: LayoutPage, prefix: string) => nodesOf(page).filter((n) => n.id?.startsWith(prefix));
|
||||
const hits = (a: Rect, b: Rect, tol = 0.5) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > tol && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > tol;
|
||||
|
||||
function textLinesUnder(root: LayoutNode): Rect[] {
|
||||
const out: Rect[] = [];
|
||||
const visit = (n: LayoutNode) => {
|
||||
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ x: l.x, y: l.y, w: Math.max(0, l.w - (n.letterSpacing ?? 0)), h: l.h });
|
||||
n.children.forEach(visit);
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function checkRendered(r: Rendered, label: string) {
|
||||
const { model } = r.fixture;
|
||||
const layout = r.result.core.layout;
|
||||
const n = r.pdf.pages.length;
|
||||
const ctx = (msg: string) => `${label}: ${msg}`;
|
||||
|
||||
// Page geometry as pdf.js reads it from the PDF.
|
||||
const [w, h] = SIZE_PT[r.prefs.pageSize];
|
||||
r.pdf.pages.forEach((p, i) => {
|
||||
expect(Math.abs(p.width - w), ctx(`page ${i + 1} width ${p.width}`)).toBeLessThanOrEqual(0.5);
|
||||
expect(Math.abs(p.height - h), ctx(`page ${i + 1} height ${p.height}`)).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
expect(r.result.pages).toBe(n);
|
||||
|
||||
// The audit: overlap, overflow-x, safe zone, truncation, empty render text, header-only page,
|
||||
// split blocks, closing block position.
|
||||
const errors = r.result.issues.filter((i: AuditIssue) => i.severity === "error");
|
||||
expect(errors.map((i) => `${i.code} p${i.page}: ${i.message}`), ctx("audit errors")).toEqual([]);
|
||||
|
||||
// The reported bug: supplier and address boxes must stay clear of the title box.
|
||||
const supplier = withId(layout[0], SUPPLIER_ID)[0];
|
||||
const title = withId(layout[0], TITLE_ID)[0];
|
||||
const meta = withId(layout[0], META_ID)[0];
|
||||
expect(supplier && title && meta, ctx("supplier, title and meta nodes exist")).toBeTruthy();
|
||||
expect(hits(supplier.box, title.box), ctx(`supplier box ${JSON.stringify(supplier.box)} meets title box ${JSON.stringify(title.box)}`)).toBe(false);
|
||||
expect(hits(supplier.box, meta.box), ctx("supplier box meets meta box")).toBe(false);
|
||||
const titleInk = textLinesUnder(title);
|
||||
for (const line of textLinesUnder(supplier)) for (const t of titleInk) expect(hits(line, t), ctx("a supplier line meets the title text")).toBe(false);
|
||||
|
||||
// Classic's header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
|
||||
// supplier text stays inside its column and every meta line starts at or right of the meta column. The
|
||||
// Neutral templates have no shared header row; the box checks above and the audit cover them.
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
if (r.prefs.templateId === "classic") {
|
||||
const [supplierW] = headerCols(frame.contentWidth);
|
||||
const supplierRight = frame.pad.left + supplierW;
|
||||
const metaLeft = supplierRight + HEADER_GAP;
|
||||
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
|
||||
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
|
||||
} else {
|
||||
// Supplier lines stay inside the text column; the meta block never reaches left of the client column's end.
|
||||
const margin = { left: frame.pad.left, right: frame.page.width - frame.pad.right };
|
||||
for (const line of textLinesUnder(supplier)) {
|
||||
expect(line.x, ctx("supplier line inside the page margins")).toBeGreaterThanOrEqual(margin.left - 0.5);
|
||||
expect(line.x + line.w, ctx("supplier line inside the page margins")).toBeLessThanOrEqual(margin.right + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Header repeats on every continuation page that has rows; no page shows a header without rows.
|
||||
layout.forEach((page, i) => {
|
||||
const rows = withIdPrefix(page, TABLE_ROW_PREFIX).length;
|
||||
const heads = withId(page, TABLE_HEAD_ID).length;
|
||||
if (i > 0 && rows > 0) expect(heads, ctx(`page ${i + 1} repeats the table header`)).toBe(1);
|
||||
if (heads > 0) expect(rows, ctx(`page ${i + 1} has a header but no rows`)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Totals and signatory are never split and never duplicated.
|
||||
for (const id of [TOTALS_ID, CLOSING_ID, SLOT_ID.signatory]) {
|
||||
const pages = layout.flatMap((p, i) => (withId(p, id).length > 0 ? [i + 1] : []));
|
||||
expect(pages, ctx(`#${id} appears on exactly one page`)).toHaveLength(1);
|
||||
}
|
||||
expect(withId(layout[n - 1], CLOSING_ID), ctx("closing block is on the last page")).toHaveLength(1);
|
||||
|
||||
// "Page k of N" from the layout tree and from the extracted text.
|
||||
layout.forEach((page, i) => {
|
||||
const want = `Page ${i + 1} of ${n}`;
|
||||
expect(withId(page, PAGE_LABEL_ID).flatMap((nd) => (nd.lines ?? []).map((l) => l.text)), ctx(`page ${i + 1} label (layout)`)).toEqual([want]);
|
||||
const labels = r.pdf.pages[i].items.map((t) => t.str.trim()).filter((s) => /^Page \d+ of \d+$/.test(s));
|
||||
expect(labels, ctx(`page ${i + 1} label (text)`)).toEqual([want]);
|
||||
});
|
||||
|
||||
// Extracted text.
|
||||
const text = textOf(r);
|
||||
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica");
|
||||
expect(text, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
|
||||
expect(text).toContain("₹");
|
||||
// A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
|
||||
// its own item right before the digits, so the sign counts when it ends the previous item.
|
||||
// pdf.js also splits an amount at a strongly kerned pair (Inter's "7," and "7."), so a fragment that starts
|
||||
// with a digit-group separator is joined to the item before it first.
|
||||
for (const page of r.pdf.pages) {
|
||||
const items: string[] = [];
|
||||
for (const t of page.items) {
|
||||
const s = t.str.trim();
|
||||
if (/^[.,]\d/.test(s) && items.length > 0) items[items.length - 1] += s;
|
||||
else items.push(t.str);
|
||||
}
|
||||
items.forEach((str, i) => {
|
||||
if (/\d\.\d\d$/.test(str.trim())) {
|
||||
const carried = str.includes("₹") || (i > 0 && items[i - 1].trim().endsWith("₹"));
|
||||
expect(carried, ctx(`amount "${str}" carries the rupee sign`)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
const flat = squash(text);
|
||||
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
|
||||
expect(flat, ctx("total")).toContain(squash(model.totals.totalText));
|
||||
expect(text.match(/Generated with Voiced/g)?.length, ctx("footer on every page")).toBe(n);
|
||||
if (model.docType === "tax_invoice") expect(flat, ctx("supplier GSTIN")).toContain(squash(FIXTURE_GSTIN.length ? (model.supplier.gstin ?? "") : ""));
|
||||
|
||||
// Contract slots.
|
||||
const rules = SLOT_RULES[model.docType];
|
||||
for (const slot of rules.required) {
|
||||
const content = slotContent(model, slot);
|
||||
if (model.docType === "tax_invoice") expect(content.length, ctx(`fixture has content for required slot ${slot}`)).toBeGreaterThan(0);
|
||||
for (const s of content) expect(flat, ctx(`slot ${slot} prints "${s}"`)).toContain(squash(s));
|
||||
}
|
||||
const ids = new Set(layout.flatMap((p) => nodesOf(p).flatMap((nd) => (nd.id ? [nd.id] : []))));
|
||||
const idFor: Partial<Record<SlotName, string>> = {
|
||||
gstin: `${SLOT_ID.gstin}-supplier`,
|
||||
pos: SLOT_ID.pos,
|
||||
taxRows: SLOT_ID.taxRows,
|
||||
amountInWords: SLOT_ID.amountInWords,
|
||||
signatory: SLOT_ID.signatory,
|
||||
reverseCharge: SLOT_ID.reverseCharge,
|
||||
};
|
||||
for (const slot of rules.required) {
|
||||
const id = idFor[slot];
|
||||
if (id) expect(ids.has(id), ctx(`slot node #${id}`)).toBe(true);
|
||||
}
|
||||
for (const slot of rules.forbidden) {
|
||||
const id = idFor[slot];
|
||||
if (id) expect(ids.has(id), ctx(`forbidden slot node #${id}`)).toBe(false);
|
||||
const probes = forbiddenProbe(model, slot, r.fixture.spec.registered === false ? "27ZZZZZ9999Z1Z9" : undefined);
|
||||
for (const p of probes) expect(flat, ctx(`forbidden slot ${slot} leaked "${p}"`)).not.toContain(squash(p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeItems, prefsFor, PAGE_SIZE_IDS, renderFixture, type FixtureSpec } from "./harness";
|
||||
import { checkRendered } from "./harness.check";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
// Its own file so vitest runs the long item-count sweeps in parallel with the fixture matrix.
|
||||
describe("template harness: item-count sweep", () => {
|
||||
for (const template of Object.values(TEMPLATES)) {
|
||||
for (const size of PAGE_SIZE_IDS) {
|
||||
it(`${template.id}: 1..45 items on ${size}`, async () => {
|
||||
const prefs = prefsFor(size, { templateId: template.id });
|
||||
for (let count = 1; count <= 45; count++) {
|
||||
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
|
||||
const r = await renderFixture(spec, prefs);
|
||||
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,151 +19,9 @@ import { TEMPLATES } from "./registry";
|
||||
* sweep. A failure here means the template or a primitive is wrong, not the assertion.
|
||||
*/
|
||||
|
||||
import { checkRendered } from "./harness.check";
|
||||
|
||||
const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
|
||||
const SIZE_PT = { A4: [PAGE_SIZES.A4.width, PAGE_SIZES.A4.height], LETTER: [612, 792] } as const;
|
||||
|
||||
const squash = (s: string) => s.replace(/\s+/g, "").toLowerCase();
|
||||
const textOf = (r: Rendered) => r.pdf.pages.flatMap((p) => p.items.map((t) => t.str)).join(" ");
|
||||
|
||||
const nodesOf = (page: LayoutPage) => {
|
||||
const out: LayoutNode[] = [];
|
||||
walk(page.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
const withId = (page: LayoutPage, id: string) => nodesOf(page).filter((n) => n.id === id);
|
||||
const withIdPrefix = (page: LayoutPage, prefix: string) => nodesOf(page).filter((n) => n.id?.startsWith(prefix));
|
||||
const hits = (a: Rect, b: Rect, tol = 0.5) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > tol && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > tol;
|
||||
|
||||
function textLinesUnder(root: LayoutNode): Rect[] {
|
||||
const out: Rect[] = [];
|
||||
const visit = (n: LayoutNode) => {
|
||||
if (n.type === "text") for (const l of n.lines ?? []) if (l.text.trim()) out.push({ x: l.x, y: l.y, w: Math.max(0, l.w - (n.letterSpacing ?? 0)), h: l.h });
|
||||
n.children.forEach(visit);
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkRendered(r: Rendered, label: string) {
|
||||
const { model } = r.fixture;
|
||||
const layout = r.result.core.layout;
|
||||
const n = r.pdf.pages.length;
|
||||
const ctx = (msg: string) => `${label}: ${msg}`;
|
||||
|
||||
// Page geometry as pdf.js reads it from the PDF.
|
||||
const [w, h] = SIZE_PT[r.prefs.pageSize];
|
||||
r.pdf.pages.forEach((p, i) => {
|
||||
expect(Math.abs(p.width - w), ctx(`page ${i + 1} width ${p.width}`)).toBeLessThanOrEqual(0.5);
|
||||
expect(Math.abs(p.height - h), ctx(`page ${i + 1} height ${p.height}`)).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
expect(r.result.pages).toBe(n);
|
||||
|
||||
// The audit: overlap, overflow-x, safe zone, truncation, empty render text, header-only page,
|
||||
// split blocks, closing block position.
|
||||
const errors = r.result.issues.filter((i: AuditIssue) => i.severity === "error");
|
||||
expect(errors.map((i) => `${i.code} p${i.page}: ${i.message}`), ctx("audit errors")).toEqual([]);
|
||||
|
||||
// The reported bug: supplier and address boxes must stay clear of the title box.
|
||||
const supplier = withId(layout[0], SUPPLIER_ID)[0];
|
||||
const title = withId(layout[0], TITLE_ID)[0];
|
||||
const meta = withId(layout[0], META_ID)[0];
|
||||
expect(supplier && title && meta, ctx("supplier, title and meta nodes exist")).toBeTruthy();
|
||||
expect(hits(supplier.box, title.box), ctx(`supplier box ${JSON.stringify(supplier.box)} meets title box ${JSON.stringify(title.box)}`)).toBe(false);
|
||||
expect(hits(supplier.box, meta.box), ctx("supplier box meets meta box")).toBe(false);
|
||||
const titleInk = textLinesUnder(title);
|
||||
for (const line of textLinesUnder(supplier)) for (const t of titleInk) expect(hits(line, t), ctx("a supplier line meets the title text")).toBe(false);
|
||||
|
||||
// Classic's header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too):
|
||||
// supplier text stays inside its column and every meta line starts at or right of the meta column. The
|
||||
// Neutral templates have no shared header row; the box checks above and the audit cover them.
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
if (r.prefs.templateId === "classic") {
|
||||
const [supplierW] = headerCols(frame.contentWidth);
|
||||
const supplierRight = frame.pad.left + supplierW;
|
||||
const metaLeft = supplierRight + HEADER_GAP;
|
||||
for (const line of textLinesUnder(supplier)) expect(line.x + line.w, ctx(`supplier line [${line.x}..${line.x + line.w}] past its column edge ${supplierRight}`)).toBeLessThanOrEqual(supplierRight + 0.5);
|
||||
for (const line of textLinesUnder(meta)) expect(line.x, ctx(`meta line at ${line.x} left of the meta column ${metaLeft}`)).toBeGreaterThanOrEqual(metaLeft - 0.5);
|
||||
} else {
|
||||
// Supplier lines stay inside the text column; the meta block never reaches left of the client column's end.
|
||||
const margin = { left: frame.pad.left, right: frame.page.width - frame.pad.right };
|
||||
for (const line of textLinesUnder(supplier)) {
|
||||
expect(line.x, ctx("supplier line inside the page margins")).toBeGreaterThanOrEqual(margin.left - 0.5);
|
||||
expect(line.x + line.w, ctx("supplier line inside the page margins")).toBeLessThanOrEqual(margin.right + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Header repeats on every continuation page that has rows; no page shows a header without rows.
|
||||
layout.forEach((page, i) => {
|
||||
const rows = withIdPrefix(page, TABLE_ROW_PREFIX).length;
|
||||
const heads = withId(page, TABLE_HEAD_ID).length;
|
||||
if (i > 0 && rows > 0) expect(heads, ctx(`page ${i + 1} repeats the table header`)).toBe(1);
|
||||
if (heads > 0) expect(rows, ctx(`page ${i + 1} has a header but no rows`)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Totals and signatory are never split and never duplicated.
|
||||
for (const id of [TOTALS_ID, CLOSING_ID, SLOT_ID.signatory]) {
|
||||
const pages = layout.flatMap((p, i) => (withId(p, id).length > 0 ? [i + 1] : []));
|
||||
expect(pages, ctx(`#${id} appears on exactly one page`)).toHaveLength(1);
|
||||
}
|
||||
expect(withId(layout[n - 1], CLOSING_ID), ctx("closing block is on the last page")).toHaveLength(1);
|
||||
|
||||
// "Page k of N" from the layout tree and from the extracted text.
|
||||
layout.forEach((page, i) => {
|
||||
const want = `Page ${i + 1} of ${n}`;
|
||||
expect(withId(page, PAGE_LABEL_ID).flatMap((nd) => (nd.lines ?? []).map((l) => l.text)), ctx(`page ${i + 1} label (layout)`)).toEqual([want]);
|
||||
const labels = r.pdf.pages[i].items.map((t) => t.str.trim()).filter((s) => /^Page \d+ of \d+$/.test(s));
|
||||
expect(labels, ctx(`page ${i + 1} label (text)`)).toEqual([want]);
|
||||
});
|
||||
|
||||
// Extracted text.
|
||||
const text = textOf(r);
|
||||
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica");
|
||||
expect(text, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
|
||||
expect(text).toContain("₹");
|
||||
// A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
|
||||
// its own item right before the digits, so the sign counts when it ends the previous item.
|
||||
for (const page of r.pdf.pages) {
|
||||
page.items.forEach((t, i) => {
|
||||
if (/\d\.\d\d$/.test(t.str.trim())) {
|
||||
const carried = t.str.includes("₹") || (i > 0 && page.items[i - 1].str.trim().endsWith("₹"));
|
||||
expect(carried, ctx(`amount "${t.str}" carries the rupee sign`)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
const flat = squash(text);
|
||||
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
|
||||
expect(flat, ctx("total")).toContain(squash(model.totals.totalText));
|
||||
expect(text.match(/Generated with Voiced/g)?.length, ctx("footer on every page")).toBe(n);
|
||||
if (model.docType === "tax_invoice") expect(flat, ctx("supplier GSTIN")).toContain(squash(FIXTURE_GSTIN.length ? (model.supplier.gstin ?? "") : ""));
|
||||
|
||||
// Contract slots.
|
||||
const rules = SLOT_RULES[model.docType];
|
||||
for (const slot of rules.required) {
|
||||
const content = slotContent(model, slot);
|
||||
if (model.docType === "tax_invoice") expect(content.length, ctx(`fixture has content for required slot ${slot}`)).toBeGreaterThan(0);
|
||||
for (const s of content) expect(flat, ctx(`slot ${slot} prints "${s}"`)).toContain(squash(s));
|
||||
}
|
||||
const ids = new Set(layout.flatMap((p) => nodesOf(p).flatMap((nd) => (nd.id ? [nd.id] : []))));
|
||||
const idFor: Partial<Record<SlotName, string>> = {
|
||||
gstin: `${SLOT_ID.gstin}-supplier`,
|
||||
pos: SLOT_ID.pos,
|
||||
taxRows: SLOT_ID.taxRows,
|
||||
amountInWords: SLOT_ID.amountInWords,
|
||||
signatory: SLOT_ID.signatory,
|
||||
reverseCharge: SLOT_ID.reverseCharge,
|
||||
};
|
||||
for (const slot of rules.required) {
|
||||
const id = idFor[slot];
|
||||
if (id) expect(ids.has(id), ctx(`slot node #${id}`)).toBe(true);
|
||||
}
|
||||
for (const slot of rules.forbidden) {
|
||||
const id = idFor[slot];
|
||||
if (id) expect(ids.has(id), ctx(`forbidden slot node #${id}`)).toBe(false);
|
||||
const probes = forbiddenProbe(model, slot, r.fixture.spec.registered === false ? "27ZZZZZ9999Z1Z9" : undefined);
|
||||
for (const p of probes) expect(flat, ctx(`forbidden slot ${slot} leaked "${p}"`)).not.toContain(squash(p));
|
||||
}
|
||||
}
|
||||
|
||||
describe("template harness: fixtures", () => {
|
||||
for (const template of Object.values(TEMPLATES)) {
|
||||
describe(template.id, () => {
|
||||
@@ -199,21 +57,6 @@ describe("template harness: determinism", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("template harness: item-count sweep", () => {
|
||||
for (const template of Object.values(TEMPLATES)) {
|
||||
for (const size of PAGE_SIZE_IDS) {
|
||||
it(`${template.id}: 1..45 items on ${size}`, async () => {
|
||||
const prefs = prefsFor(size, { templateId: template.id });
|
||||
for (let count = 1; count <= 45; count++) {
|
||||
const spec: FixtureSpec = { name: `sweep-${count}`, items: makeItems(count, true), invoice: { notes: count % 3 === 0 ? "Payment due within 30 days.\nThank you." : "" }, signature: count % 2 === 0 };
|
||||
const r = await renderFixture(spec, prefs);
|
||||
checkRendered(r, `${template.id}/items=${count}/${size} (${r.result.pages} pages)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!OUT_DIR)("template harness: samples for visual review", () => {
|
||||
const samples: Array<{ file: string; spec: FixtureSpec; size: "A4" | "LETTER"; pages: number }> = [
|
||||
{ file: "classic-A4-tax", spec: { name: "s1", items: 3, signature: true, invoice: { poNumber: "PO-2026-0042", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." } }, size: "A4", pages: 1 },
|
||||
|
||||
@@ -12,7 +12,7 @@ import { setupPdfTest } from "../testing/setup";
|
||||
|
||||
/** Fixtures and helpers shared by the template harness and the sample renderer. */
|
||||
|
||||
export type LogoKind = "none" | "real" | "wide" | "square";
|
||||
export type LogoKind = "none" | "real" | "wide" | "square" | "knockout";
|
||||
|
||||
export interface FixtureSpec {
|
||||
name: string;
|
||||
@@ -130,6 +130,33 @@ export function solidPng(w: number, h: number): Buffer {
|
||||
]);
|
||||
}
|
||||
|
||||
/** A transparent-margin w x h PNG in one colour: the shape of a logo that has alpha, so it can have a white knockout. */
|
||||
function inkPng(w: number, h: number, rgb: [number, number, number]): Buffer {
|
||||
const row = Buffer.alloc(1 + w * 4);
|
||||
const raw = Buffer.alloc(row.length * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const inside = x >= w * 0.08 && x < w * 0.92 && y >= h * 0.12 && y < h * 0.88;
|
||||
const o = y * row.length + 1 + x * 4;
|
||||
raw[o] = rgb[0];
|
||||
raw[o + 1] = rgb[1];
|
||||
raw[o + 2] = rgb[2];
|
||||
raw[o + 3] = inside ? 255 : 0;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
const dataUri = (png: Buffer) => `data:image/png;base64,${png.toString("base64")}`;
|
||||
|
||||
const cache = new Map<string, LogoInput>();
|
||||
@@ -142,6 +169,19 @@ export function fixtureLogo(kind: LogoKind): LogoInput | null {
|
||||
const png = readFileSync(path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"));
|
||||
const [width, height] = [png.readUInt32BE(16), png.readUInt32BE(20)];
|
||||
hit = { dataUri: dataUri(png), width, height, aspect: width / height, density: 0.21, kind: "wordmark", includesName: true };
|
||||
} else if (kind === "knockout") {
|
||||
// A wordmark with alpha: navy print image plus its white knockout, as the logo pipeline writes them.
|
||||
hit = {
|
||||
dataUri: dataUri(inkPng(460, 100, [24, 52, 120])),
|
||||
knockoutDataUri: dataUri(inkPng(460, 100, [255, 255, 255])),
|
||||
hasKnockout: true,
|
||||
width: 460,
|
||||
height: 100,
|
||||
aspect: 4.6,
|
||||
density: 0.6,
|
||||
kind: "wordmark",
|
||||
includesName: true,
|
||||
};
|
||||
} else if (kind === "wide") {
|
||||
// The trimmed Test Vendor ink box is 4.6:1.
|
||||
hit = { dataUri: dataUri(solidPng(460, 100)), width: 460, height: 100, aspect: 4.6, density: 1, kind: "wordmark", includesName: true };
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Image, View } from "@react-pdf/renderer";
|
||||
import { type ReactNode } from "react";
|
||||
import { FILL, pinned } from "../../engine/columns";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { AvailableWidthProvider } from "../../blocks/AvailableWidth";
|
||||
import { BottomSpacer } from "../../blocks/BottomSpacer";
|
||||
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
|
||||
import { FitText } from "../../blocks/FitText";
|
||||
import { IdText } from "../../blocks/IdText";
|
||||
import { TableRows } from "../../blocks/ItemsTable";
|
||||
import { Cell, Row } from "../../blocks/Row";
|
||||
import { SafeTxt } from "../../blocks/SafeTxt";
|
||||
import { Slot } from "../../blocks/slots";
|
||||
import { TableGuard } from "../../blocks/TableGuard";
|
||||
import { Txt } from "../../blocks/Txt";
|
||||
import { AccentRule } from "../../decor/AccentRule";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
|
||||
import type { PlacedLogo, TemplateProps } from "../contract";
|
||||
import { includesName } from "../logoName";
|
||||
import { bankLines, CANCELLED_INK, CANCELLED_SENTENCE, CLOSING_GAP } from "../neutral/plan";
|
||||
import { HEADING_AFTER, planLedger, signatureOptions, TAIL_COLUMN_GAP, WORDS_GAP, REVERSE_GAP, type LedgerPlan } from "./plan";
|
||||
import type { LedgerVariant } from "./variant";
|
||||
|
||||
interface Ctx {
|
||||
model: RenderModel;
|
||||
plan: LedgerPlan;
|
||||
v: LedgerVariant;
|
||||
logo: PlacedLogo | null;
|
||||
}
|
||||
|
||||
function Spacer({ h }: { h: number }) {
|
||||
return h > 0 ? <View style={{ height: h }} /> : null;
|
||||
}
|
||||
|
||||
// --- header ---------------------------------------------------------------------------------------------
|
||||
|
||||
function Brand({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v, logo } = ctx;
|
||||
const b = plan.brand;
|
||||
if (b.w <= 0) return null;
|
||||
return (
|
||||
<View style={{ ...pinned(b.w), flexDirection: "row", marginTop: v.brandZone.top } as never}>
|
||||
{logo ? (
|
||||
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: b.logoTop, objectFit: "contain" } as never} />
|
||||
) : null}
|
||||
{b.showName ? (
|
||||
<View style={{ ...pinned(b.nameW), marginLeft: b.nameLeft - (logo?.w ?? 0), marginTop: b.nameTop } as never}>
|
||||
<AvailableWidthProvider width={b.nameW}>
|
||||
<SafeTxt role={v.roles.brand} color={v.ink}>
|
||||
{model.supplier.name}
|
||||
</SafeTxt>
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderRow({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const title = (
|
||||
<View style={{ ...pinned(plan.title.w), marginTop: v.titleTop } as never}>
|
||||
<FitText id={TITLE_ID} role={v.roles.title} color={v.ink} width={plan.title.w} minSize={Math.round(v.roles.title.size * 0.6)}>
|
||||
{model.docTitle}
|
||||
</FitText>
|
||||
</View>
|
||||
);
|
||||
const brand = <Brand ctx={ctx} />;
|
||||
return (
|
||||
<View style={{ flexDirection: "row", ...pinned(plan.W), minHeight: plan.headerH } as never}>
|
||||
{v.mirrored ? brand : title}
|
||||
<View style={FILL} />
|
||||
{v.mirrored ? title : brand}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelledTag({ ctx }: { ctx: Ctx }) {
|
||||
const textW = Math.ceil(measureToken(getMeasurer(), ctx.v.roles.label, "Cancelled")) + 2;
|
||||
const boxW = textW + 12 + 1.6;
|
||||
return (
|
||||
<View style={{ borderWidth: 0.8, borderColor: CANCELLED_INK, paddingVertical: 2, paddingHorizontal: 6, marginBottom: 6, ...pinned(boxW) } as never}>
|
||||
<Txt role={ctx.v.roles.label} color={CANCELLED_INK} width={textW}>
|
||||
Cancelled
|
||||
</Txt>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
return (
|
||||
<View id={META_ID} style={pinned(plan.W) as never}>
|
||||
{model.cancelled ? <CancelledTag ctx={ctx} /> : null}
|
||||
{plan.meta.map((line, i) => (
|
||||
<SafeTxt key={i} role={v.roles.body} color={v.ink} style={i > 0 ? { marginTop: v.metaGap } : undefined}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SupplierBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const s = model.supplier;
|
||||
return (
|
||||
<View id={SUPPLIER_ID} style={pinned(plan.leftW) as never}>
|
||||
<SafeTxt role={r.label} color={v.ink}>
|
||||
{s.name}
|
||||
</SafeTxt>
|
||||
<Spacer h={v.headingGap} />
|
||||
{s.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
{s.phone ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{s.phone}
|
||||
</IdText>
|
||||
) : null}
|
||||
{s.email ? (
|
||||
<IdText role={r.body} color={v.ink}>
|
||||
{s.email}
|
||||
</IdText>
|
||||
) : null}
|
||||
{s.pan ? <IdText role={r.body} color={v.ink}>{`PAN: ${s.pan}`}</IdText> : null}
|
||||
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientBlock({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const c = model.client;
|
||||
return (
|
||||
<View style={pinned(plan.rightW) as never}>
|
||||
<Txt role={r.label} color={v.ink} width={plan.rightW}>
|
||||
Bill to
|
||||
</Txt>
|
||||
<Spacer h={v.headingGap} />
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{c.name}
|
||||
</SafeTxt>
|
||||
{c.addressLines.map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
<Slot.Gstin model={model} party="client" role={r.body} color={v.ink} />
|
||||
<Slot.PlaceOfSupply model={model} role={r.body} color={v.ink} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// --- totals box ------------------------------------------------------------------------------------------
|
||||
|
||||
/** One ruled row of the totals box: label cell, value cell, the row's own top rule. */
|
||||
function BoxRow({ ctx, label, amount, total }: { ctx: Ctx; label: string; amount: string; total?: boolean }) {
|
||||
const { plan, v } = ctx;
|
||||
const t = plan.totals;
|
||||
const g = v.rule;
|
||||
const filled = total && v.totalFilled;
|
||||
const role = total ? v.roles.total : v.roles.totals;
|
||||
const color = filled ? v.onAccent : v.ink;
|
||||
const sep = filled ? v.onAccent : v.ink;
|
||||
const padY = v.totals.padY;
|
||||
return (
|
||||
<Row
|
||||
widths={[{ fixed: t.labelW }, { fixed: t.valueW }]}
|
||||
width={t.boxW}
|
||||
align="stretch"
|
||||
style={{ borderTopWidth: g, borderTopColor: filled ? v.accent : v.ink, ...(filled ? { backgroundColor: v.accent } : {}) }}
|
||||
>
|
||||
<Cell
|
||||
padding={{ top: padY, bottom: padY, left: v.padX, right: v.padX }}
|
||||
borderLeft={g}
|
||||
borderRight={g}
|
||||
style={{ flexGrow: 1, flexBasis: "auto", borderLeftColor: filled ? v.accent : v.ink, borderRightColor: sep }}
|
||||
>
|
||||
<Txt role={role} color={color} width={t.labelW - 2 * v.padX - 2 * g}>
|
||||
{label}
|
||||
</Txt>
|
||||
</Cell>
|
||||
<Cell
|
||||
padding={{ top: padY, bottom: padY, left: v.padX, right: v.padX }}
|
||||
borderRight={g}
|
||||
style={{ flexGrow: 1, flexBasis: "auto", borderRightColor: filled ? v.accent : v.ink }}
|
||||
>
|
||||
<Txt role={role} color={color} width={t.valueW - 2 * v.padX - g} align="right">
|
||||
{amount}
|
||||
</Txt>
|
||||
</Cell>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalsBox({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const t = plan.totals;
|
||||
return (
|
||||
<View style={{ ...pinned(t.boxW), borderBottomWidth: v.rule, borderBottomColor: v.ink } as never}>
|
||||
<AvailableWidthProvider width={t.boxW}>
|
||||
{t.rows.map((row, i) => (
|
||||
<BoxRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
|
||||
))}
|
||||
<Slot.TaxRows
|
||||
model={model}
|
||||
role={v.roles.totals}
|
||||
color={v.ink}
|
||||
amountWidth={t.valueW}
|
||||
format={(label, rate) => (rate ? `${label} @ ${rate}` : label)}
|
||||
renderRow={(row) => <BoxRow ctx={ctx} label={row.label} amount={row.amount} />}
|
||||
/>
|
||||
<BoxRow ctx={ctx} label="Total" amount={model.totals.totalText} total />
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesBlock({ ctx, width }: { ctx: Ctx; width: number }) {
|
||||
const { model, v } = ctx;
|
||||
const r = v.roles;
|
||||
if (model.notesLines.length === 0) return null;
|
||||
return (
|
||||
<View style={pinned(width) as never}>
|
||||
<AvailableWidthProvider width={width}>
|
||||
{/* The label travels with the first line, and no paragraph splits (a cut Text leaves a zero-line node). */}
|
||||
<View wrap={false}>
|
||||
<Txt role={r.label} color={v.ink} width={width} style={{ marginBottom: v.headingGap }}>
|
||||
Notes / Terms:
|
||||
</Txt>
|
||||
<SafeTxt role={r.body} color={v.ink}>
|
||||
{model.notesLines[0]}
|
||||
</SafeTxt>
|
||||
</View>
|
||||
{model.notesLines.slice(1).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.body} color={v.ink} wrap={false}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</AvailableWidthProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Tail({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
return (
|
||||
<View id={TOTALS_ID} wrap={false}>
|
||||
<View style={{ marginTop: plan.gaps.tail }}>
|
||||
<Row widths={[{ fill: true }, { fixed: plan.totals.boxW }]} gap={TAIL_COLUMN_GAP}>
|
||||
{plan.notesBeside ? <NotesBlock ctx={ctx} width={plan.notesW} /> : <View />}
|
||||
<TotalsBox ctx={ctx} />
|
||||
</Row>
|
||||
{plan.wordsH > 0 ? (
|
||||
<View style={{ marginTop: WORDS_GAP }}>
|
||||
<Slot.AmountInWords model={model} role={r.small} labelRole={r.wordsLabel} color={v.ink} />
|
||||
</View>
|
||||
) : null}
|
||||
{plan.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: REVERSE_GAP }} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Closing({ ctx }: { ctx: Ctx }) {
|
||||
const { model, plan, v } = ctx;
|
||||
const r = v.roles;
|
||||
const opts = signatureOptions(v);
|
||||
return (
|
||||
<>
|
||||
<BottomSpacer closingH={plan.closingH} />
|
||||
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: CLOSING_GAP }}>
|
||||
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
|
||||
{model.bank ? (
|
||||
<View style={pinned(plan.leftW) as never}>
|
||||
<Txt role={r.label} color={v.ink} width={plan.leftW} style={{ marginBottom: HEADING_AFTER }}>
|
||||
Payment method
|
||||
</Txt>
|
||||
{bankLines(model).map((line, i) => (
|
||||
<SafeTxt key={i} role={r.small} color={v.ink}>
|
||||
{line}
|
||||
</SafeTxt>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
<View style={pinned(plan.signW) as never}>
|
||||
<Txt role={r.label} color={v.ink} width={plan.signW} style={{ marginBottom: HEADING_AFTER }}>
|
||||
Authorised signatory
|
||||
</Txt>
|
||||
<Slot.Signatory model={model} width={plan.signW} {...opts} />
|
||||
</View>
|
||||
</Row>
|
||||
{model.cancelled ? (
|
||||
<Txt role={r.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
|
||||
{CANCELLED_SENTENCE}
|
||||
</Txt>
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- the layout -----------------------------------------------------------------------------------------
|
||||
|
||||
export function LedgerLayout({ model, frame, logo, prefs, variant }: TemplateProps & { variant: LedgerVariant }) {
|
||||
const plan = planLedger(model, frame, variant, logo, includesName({ model, prefs }));
|
||||
const ctx: Ctx = { model, plan, v: variant, logo };
|
||||
const v = variant;
|
||||
const r = v.roles;
|
||||
let notes: ReactNode = null;
|
||||
if (model.notesLines.length > 0 && !plan.notesBeside) {
|
||||
notes = (
|
||||
<View style={{ marginTop: plan.gaps.notes }}>
|
||||
<NotesBlock ctx={ctx} width={plan.W} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FirstPageHeader pad={frame.pad}>
|
||||
<HeaderRow ctx={ctx} />
|
||||
{v.headerRule ? (
|
||||
<>
|
||||
<Spacer h={plan.gaps.rule} />
|
||||
<AccentRule width={plan.W} thickness={v.headerRule.thickness} color={v.accent} />
|
||||
</>
|
||||
) : null}
|
||||
<Spacer h={plan.gaps.meta} />
|
||||
<MetaBlock ctx={ctx} />
|
||||
<Spacer h={plan.gaps.party} />
|
||||
<Row widths={[{ fixed: plan.leftW }, { fill: true }]} gap={v.partyGap}>
|
||||
<SupplierBlock ctx={ctx} />
|
||||
<ClientBlock ctx={ctx} />
|
||||
</Row>
|
||||
</FirstPageHeader>
|
||||
|
||||
<Spacer h={plan.gaps.table} />
|
||||
|
||||
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
|
||||
<View style={pinned(plan.W) as never}>
|
||||
<TableRows
|
||||
columns={plan.columns}
|
||||
gap={0}
|
||||
width={plan.W}
|
||||
header={plan.header}
|
||||
rows={plan.rows}
|
||||
roles={{ head: r.tableHead, body: r.tableBody }}
|
||||
align={plan.align}
|
||||
padX={v.padX}
|
||||
padY={v.padY}
|
||||
border={v.rule}
|
||||
borderColor={v.ink}
|
||||
headColor={v.onAccent}
|
||||
headerStyle={{ backgroundColor: v.accent, ...(v.totalFilled ? { borderBottomColor: v.accent } : {}) }}
|
||||
grid={{ width: v.rule, color: v.ink, headerColor: v.onAccent, headerEdge: v.totalFilled ? v.accent : v.ink }}
|
||||
tailH={plan.tailH}
|
||||
tail={<Tail ctx={ctx} />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{notes}
|
||||
<Closing ctx={ctx} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { defineRoles } from "../../fonts/roles";
|
||||
import { fullBleedBand } from "../../decor/FullBleedBand";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { FitText } from "../../blocks/FitText";
|
||||
import { LedgerLayoutFor } from "./layoutFor";
|
||||
import type { LedgerRoles, LedgerVariant } from "./variant";
|
||||
|
||||
const INK = "#000000";
|
||||
const ACCENT = "#A00000";
|
||||
const CREAM = "#EFEDDF";
|
||||
const F = "Poppins" as const;
|
||||
/** The full-bleed footer band: its height and the room kept clear above it. */
|
||||
export const BAND_H = 63;
|
||||
const BAND_CLEAR = 16;
|
||||
|
||||
/**
|
||||
* Crimson Grid's type: Poppins only (docs/templates/07-crimson-grid.md). Poppins ascent 1.05, descent 0.35,
|
||||
* cap 0.70. The design's tracking (0.0085 em on the title, 0.064 em on the footer line) is dropped or kept
|
||||
* at 0.05 em so text extraction stays clean.
|
||||
*/
|
||||
export const CRIMSON_ROLES = defineRoles({
|
||||
title: { face: F, weight: 700, size: 43.8, leading: 52, caps: true },
|
||||
brand: { face: F, weight: 600, size: 15.44, leading: 14.47, caps: true, minSize: 10 },
|
||||
label: { face: F, weight: 600, size: 12, leading: 16.5, caps: true },
|
||||
body: { face: F, weight: 400, size: 12, leading: 16.5 },
|
||||
bodyStrong: { face: F, weight: 600, size: 12, leading: 16.5 },
|
||||
tableHead: { face: F, weight: 600, size: 11, leading: 15.4 },
|
||||
tableBody: { face: F, weight: 400, size: 11, leading: 15.4 },
|
||||
tableSub: { face: F, weight: 400, size: 8.5, leading: 12 },
|
||||
totals: { face: F, weight: 400, size: 11, leading: 15.4 },
|
||||
total: { face: F, weight: 700, size: 12, leading: 16.5 },
|
||||
small: { face: F, weight: 400, size: 10.5, leading: 14.5 },
|
||||
wordsLabel: { face: F, weight: 600, size: 10.5, leading: 14.5 },
|
||||
fine: { face: F, weight: 400, size: 8.5, leading: 12 },
|
||||
contact: { face: F, weight: 400, size: 11, leading: 15, tracking: 0.05, minSize: 7 },
|
||||
footer: { face: F, weight: 400, size: 7 },
|
||||
} satisfies LedgerRoles);
|
||||
|
||||
/**
|
||||
* Canva A4 numbers: logo top 53, the title's cap top 69.7 (its box starts 1.6 below the row top), a 1.5 pt
|
||||
* rule at y 128.85, a 59.55 side margin. The flow must never reach the 63 pt band, so the bottom margin
|
||||
* floor plus the footer reserve is at least 79 on every preset.
|
||||
*/
|
||||
export const CRIMSON_VARIANT: LedgerVariant = {
|
||||
id: "crimson-grid",
|
||||
roles: CRIMSON_ROLES,
|
||||
accent: ACCENT,
|
||||
ink: INK,
|
||||
onAccent: "#FFFFFF",
|
||||
margins: { top: 53, right: 59.55, bottom: 26, left: 59.55 },
|
||||
marginFloor: { top: 36, right: 36, bottom: 26, left: 36 },
|
||||
mirrored: false,
|
||||
titleTop: 1.6,
|
||||
brandZone: { top: 0, h: 45.75 },
|
||||
brandMaxShare: 0.4,
|
||||
brandGap: 24,
|
||||
markGap: 13.7,
|
||||
headerRule: { thickness: 1.5 },
|
||||
totalFilled: false,
|
||||
art: "footer-band",
|
||||
gaps: {
|
||||
rule: { nominal: 21.5, min: 12 },
|
||||
meta: { nominal: 31.4, min: 20 },
|
||||
party: { nominal: 22.3, min: 14 },
|
||||
table: { nominal: 27.4, min: 16 },
|
||||
tail: { nominal: 11.3, min: 6 },
|
||||
notes: { nominal: 12, min: 8 },
|
||||
},
|
||||
metaGap: 2.18,
|
||||
headingGap: 7.48,
|
||||
partyLeftShare: 0.4804,
|
||||
partyGap: 16,
|
||||
rule: 0.75,
|
||||
padX: 9.4,
|
||||
padY: 12.55,
|
||||
cols: { hsn: 56, rate: 100, qty: 45, amount: 100 },
|
||||
totals: { labelW: 96, valueW: 100, padY: 6, maxShare: 0.55 },
|
||||
notesBesideMax: 110,
|
||||
signatureW: 200,
|
||||
signatureImageH: 40,
|
||||
chromeInset: 0,
|
||||
footerLeftW: 140,
|
||||
};
|
||||
|
||||
/** Footer band height used by decor and by the footer's own placement. */
|
||||
export const CRIMSON_FOOTER_BOTTOM = 14;
|
||||
|
||||
export const crimsonGrid: TemplateDefinition = {
|
||||
...catalogEntry("crimson-grid"),
|
||||
version: 1,
|
||||
roles: CRIMSON_ROLES,
|
||||
margins: CRIMSON_VARIANT.margins,
|
||||
marginFloor: CRIMSON_VARIANT.marginFloor,
|
||||
decor: {
|
||||
ink: INK,
|
||||
everyPage: (frame, model) => {
|
||||
const items = [fullBleedBand(frame, { bottom: 0, height: BAND_H, fill: ACCENT })];
|
||||
const contact = [model.supplier.email, model.supplier.phone].filter(Boolean).join(" | ");
|
||||
if (contact) {
|
||||
const r = CRIMSON_ROLES.contact;
|
||||
items.push({
|
||||
x: frame.pad.left,
|
||||
y: frame.page.height - BAND_H + 13,
|
||||
width: frame.contentWidth,
|
||||
height: r.leading as number,
|
||||
children: (
|
||||
<FitText role={r} color={CREAM} width={frame.contentWidth} align="center">
|
||||
{contact}
|
||||
</FitText>
|
||||
),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
},
|
||||
chrome: {
|
||||
color: CREAM,
|
||||
bandColor: "#6f6f6f",
|
||||
rule: null,
|
||||
role: CRIMSON_ROLES.footer,
|
||||
leftWidth: CRIMSON_VARIANT.footerLeftW,
|
||||
// 53 + the 26 pt margin floor = 79 = band + 16 clear, on every margin preset.
|
||||
reserve: BAND_H + BAND_CLEAR - 26,
|
||||
bottom: CRIMSON_FOOTER_BOTTOM,
|
||||
},
|
||||
logo: {
|
||||
box: { w: 113.25, h: 45.75 },
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: true,
|
||||
slotWidth: () => 113.25,
|
||||
slot: slotFor("crimson-grid"),
|
||||
available: () => ({ w: 113.25, h: 45.75 }),
|
||||
},
|
||||
Layout: LedgerLayoutFor(CRIMSON_VARIANT),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { TemplateProps } from "../contract";
|
||||
import { LedgerLayout } from "./Layout";
|
||||
import type { LedgerVariant } from "./variant";
|
||||
|
||||
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
|
||||
export function LedgerLayoutFor(variant: LedgerVariant): ComponentType<TemplateProps> {
|
||||
const Layout = (props: TemplateProps) => <LedgerLayout {...props} variant={variant} />;
|
||||
Layout.displayName = `LedgerLayout(${variant.id})`;
|
||||
return Layout;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PAGE_SIZES } from "../../engine/geometry";
|
||||
import { CLOSING_ID, TOTALS_ID } from "../../engine/ids";
|
||||
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
||||
import { frameForPrefs } from "../../render/invoice";
|
||||
import { loadCanvas, inkPixels } from "../../testing/rasterize";
|
||||
import { SLOT_ID } from "../../blocks/slots";
|
||||
import { catalogEntry } 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 { planLedger } from "./plan";
|
||||
import { crimsonGrid, CRIMSON_VARIANT, BAND_H } from "./crimson";
|
||||
import { tangerineLedger, TANGERINE_VARIANT } from "./tangerine";
|
||||
|
||||
ensureSetup();
|
||||
|
||||
const LEDGER = [crimsonGrid, tangerineLedger];
|
||||
const VARIANTS = { "crimson-grid": CRIMSON_VARIANT, "tangerine-ledger": TANGERINE_VARIANT } as const;
|
||||
const nodes = (layout: { nodes: LayoutNode[] }[]) => {
|
||||
const out: LayoutNode[] = [];
|
||||
for (const p of layout) walk(p.nodes, (n) => out.push(n));
|
||||
return out;
|
||||
};
|
||||
const textOf = (r: Awaited<ReturnType<typeof renderFixture>>) => r.pdf.pages.map((p) => p.items.map((i) => i.str).join(" "));
|
||||
const withLogo = { name: "logo", items: 2, logo: "real" as const };
|
||||
|
||||
describe("registry and catalog", () => {
|
||||
it("lists Crimson Grid and Tangerine Ledger in the Ledger family, with their thumbnails on disk", () => {
|
||||
for (const t of LEDGER) {
|
||||
expect(getTemplate(t.id)).toBe(t);
|
||||
expect(t.family).toBe("ledger");
|
||||
expect(catalogEntry(t.id).name).toBe(t.name);
|
||||
expect(existsSync(path.join(process.cwd(), "public", t.thumbnail)), t.thumbnail).toBe(true);
|
||||
expect(t.badge).toBeUndefined();
|
||||
}
|
||||
expect(crimsonGrid.name).toBe("Crimson Grid");
|
||||
expect(tangerineLedger.name).toBe("Tangerine Ledger");
|
||||
expect(Object.keys(TEMPLATES)).toContain("crimson-grid");
|
||||
});
|
||||
|
||||
it("each template declares its own type: Crimson all Poppins, Tangerine Poppins Bold display over Inter", () => {
|
||||
for (const token of Object.values(crimsonGrid.roles)) expect(token.face).toBe("Poppins");
|
||||
expect(tangerineLedger.roles.title).toMatchObject({ face: "Poppins", weight: 700, size: 33 });
|
||||
expect(tangerineLedger.roles.brand).toMatchObject({ face: "Poppins", weight: 700, size: 18 });
|
||||
expect(tangerineLedger.roles.body.face).toBe("Inter");
|
||||
expect(tangerineLedger.roles.tableBody.face).toBe("Inter");
|
||||
expect(crimsonGrid.roles.title).toMatchObject({ weight: 700, size: 43.8 });
|
||||
});
|
||||
|
||||
it("both logo slots are the measured Canva ones", () => {
|
||||
expect(crimsonGrid.logo.slot).toBe(SLOTS["crimson-grid"]);
|
||||
expect(tangerineLedger.logo.slot).toBe(SLOTS["tangerine-ledger"]);
|
||||
expect(crimsonGrid.logo.defaultOn && tangerineLedger.logo.defaultOn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page geometry and art clearance", () => {
|
||||
it("template margins", () => {
|
||||
expect(marginsFor(crimsonGrid, "template")).toEqual({ top: 53, right: 59.55, bottom: 26, left: 59.55 });
|
||||
expect(marginsFor(tangerineLedger, "template")).toEqual({ top: 46, right: 59.55, bottom: 60, left: 59.55 });
|
||||
});
|
||||
|
||||
it("the flow never reaches Crimson's 63 pt band on any margin preset or page size", () => {
|
||||
for (const margins of ["template", "narrow", "normal"] as const) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const frame = frameForPrefs(prefsFor(size, { templateId: "crimson-grid", margins }));
|
||||
expect(frame.pad.bottom, `${margins}/${size}`).toBeGreaterThanOrEqual(BAND_H + 16 - 1e-6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the footer text sits inside the band whatever the margin preset", () => {
|
||||
expect(crimsonGrid.chrome.bottom).toBeDefined();
|
||||
expect((crimsonGrid.chrome.bottom as number) + 12).toBeLessThan(BAND_H);
|
||||
});
|
||||
|
||||
it("no side margin goes under Tangerine's 35 pt tabs plus 10 pt of air", () => {
|
||||
for (const margins of ["template", "narrow", "normal"] as const) {
|
||||
const m = marginsFor(tangerineLedger, margins);
|
||||
expect(m.left, margins).toBeGreaterThanOrEqual(45);
|
||||
expect(m.right, margins).toBeGreaterThanOrEqual(45);
|
||||
}
|
||||
});
|
||||
|
||||
it("Letter keeps the physical margins and gives the extra width to the content", () => {
|
||||
for (const t of LEDGER) {
|
||||
const a4 = frameForPrefs(prefsFor("A4", { templateId: t.id }));
|
||||
const letter = frameForPrefs(prefsFor("LETTER", { templateId: t.id }));
|
||||
expect(letter.pad).toEqual(a4.pad);
|
||||
expect(letter.contentWidth - a4.contentWidth).toBeCloseTo(PAGE_SIZES.LETTER.width - PAGE_SIZES.A4.width, 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("A4 numbers match the Canva grid: columns 249 / 98 / 41 / 88.5 widen only where an amount needs it", () => {
|
||||
const frame = frameForPrefs(prefsFor("A4", { templateId: "crimson-grid" }));
|
||||
const { model } = buildFixture({ name: "p", items: 1, registered: false, logo: "none" });
|
||||
const plan = planLedger(model, frame, CRIMSON_VARIANT, null, false);
|
||||
expect(plan.table.widths.length).toBe(4);
|
||||
expect(plan.table.widths.reduce((a, b) => a + b, 0)).toBeCloseTo(frame.contentWidth, 1);
|
||||
// Money columns take at least 100 pt; the description keeps the rest.
|
||||
expect(plan.table.widths[3]).toBeGreaterThanOrEqual(100);
|
||||
expect(plan.table.widths[0]).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the header does with a logo", () => {
|
||||
const imageBoxes = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout).filter((n) => n.type === "image" && !n.decor);
|
||||
|
||||
it("Crimson: a wordmark fills the lockup box and replaces the name; a mark sits left of the name", async () => {
|
||||
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "crimson-grid" }));
|
||||
const [img] = imageBoxes(wide);
|
||||
const lockup = SLOTS["crimson-grid"]!.lockup!;
|
||||
expect(img.box.y + img.box.h).toBeLessThanOrEqual(lockup.y + lockup.h + 0.6);
|
||||
expect(img.box.h).toBeLessThanOrEqual(lockup.h + 0.01);
|
||||
expect(img.box.w).toBeLessThanOrEqual(lockup.w + 0.01);
|
||||
expect(img.box.x + img.box.w).toBeLessThanOrEqual(frameForPrefs(wide.prefs).page.width - frameForPrefs(wide.prefs).pad.right + 0.5);
|
||||
// The business name is not repeated as text in the header (the party heading still prints it once).
|
||||
expect(textOf(wide)[0].match(/Sample P/g)?.length).toBe(1);
|
||||
|
||||
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "crimson-grid" }));
|
||||
const [mark] = imageBoxes(square);
|
||||
const markBox = SLOTS["crimson-grid"]!.mark!;
|
||||
expect(mark.box.h).toBeLessThanOrEqual(markBox.h + 0.01);
|
||||
expect(mark.box.w).toBeLessThanOrEqual(markBox.w + 0.01);
|
||||
expect(textOf(square)[0].match(/Sample P/g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
it("Tangerine: the logo stays in its 220 x 36 allowance at the left", async () => {
|
||||
const wide = await renderFixture(withLogo, prefsFor("A4", { templateId: "tangerine-ledger" }));
|
||||
const [img] = imageBoxes(wide);
|
||||
const box = SLOTS["tangerine-ledger"]!.allowance;
|
||||
// The logo's left edge is the content edge (59.55), a point left of the Canva text's ink start (60.5).
|
||||
expect(img.box.x).toBeGreaterThanOrEqual(box.x - 1.1);
|
||||
expect(img.box.x + img.box.w).toBeLessThanOrEqual(box.x + box.w + 0.6);
|
||||
expect(img.box.y).toBeGreaterThanOrEqual(box.y - 0.6);
|
||||
expect(img.box.y + img.box.h).toBeLessThanOrEqual(box.y + box.h + 0.6);
|
||||
const square = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "tangerine-ledger" }));
|
||||
const [mark] = imageBoxes(square);
|
||||
expect(mark.box.h).toBeLessThanOrEqual(36.01);
|
||||
});
|
||||
|
||||
it("with no logo the vendor name stands in, in the template's own type", async () => {
|
||||
for (const t of LEDGER) {
|
||||
const r = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
|
||||
expect(imageBoxes(r), t.id).toHaveLength(0);
|
||||
// The brand text in the header, the party heading and the footer: three prints of the name, one of them at the top.
|
||||
const names = r.pdf.pages[0].items.filter((i) => /^Sample p$/i.test(i.str.trim()));
|
||||
expect(names.length, t.id).toBe(3);
|
||||
expect(names.filter((i) => i.bottom < 150).length, t.id).toBe(1);
|
||||
expect(r.pdf.raw, t.id).toContain("Poppins");
|
||||
expect(r.pdf.raw, t.id).not.toContain("/BaseFont /Helvetica");
|
||||
}
|
||||
const tang = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: "tangerine-ledger" }));
|
||||
expect(tang.pdf.raw).toContain("Inter");
|
||||
});
|
||||
|
||||
it("a logo with includesName off keeps the name beside the mark", async () => {
|
||||
const r = await renderFixture({ ...withLogo, logo: "square" }, prefsFor("A4", { templateId: "tangerine-ledger" }));
|
||||
// The brand beside the mark and the footer (the party heading is upper case).
|
||||
expect(textOf(r)[0].match(/Test Vendor/g)?.length).toBe(2);
|
||||
expect(r.pdf.pages[0].items.filter((i) => i.str.trim() === "Test Vendor" && i.bottom < 150)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ruled grid", () => {
|
||||
const multiline: FixtureSpec = {
|
||||
name: "grid",
|
||||
logo: "none",
|
||||
items: [
|
||||
{ description: "Voice-over for the onboarding course\nModule 1 - introduction\nModule 2 - safety basics\nIncludes two rounds of revisions", mode: "rate", rate: 1500, unit: "hour", quantity: 3, amount: 0, sortOrder: 0, hsnSac: "998397" },
|
||||
{ description: "Studio hire", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 1, hsnSac: "997212" },
|
||||
],
|
||||
};
|
||||
|
||||
it("draws the vertical rules the full height of a multi-line row and the outer frame", async () => {
|
||||
const canvas = await loadCanvas();
|
||||
if (!canvas) return;
|
||||
for (const t of LEDGER) {
|
||||
const r = await renderFixture(multiline, prefsFor("A4", { templateId: t.id }));
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
const row = nodes(r.result.core.layout).find((n) => n.id === "tbl-row-0")!;
|
||||
expect(row.box.h).toBeGreaterThan(60);
|
||||
const plan = planLedger(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
|
||||
const x1 = frame.pad.left + plan.table.widths.slice(0, -1).reduce((a, b) => a + b, 0);
|
||||
const scale = 4;
|
||||
const vert = async (x: number) => inkPixels(canvas, r.result.bytes, { x0: x - 0.7, y0: row.box.y + 3, x1: x + 0.7, y1: row.box.y + row.box.h - 3 }, scale);
|
||||
const full = (row.box.h - 6) * scale;
|
||||
// The column rule before Amount, and the outer left edge.
|
||||
expect(await vert(x1), `${t.id} inner rule`).toBeGreaterThan(full * 0.8);
|
||||
expect(await vert(frame.pad.left + 0.375), `${t.id} left edge`).toBeGreaterThan(full * 0.8);
|
||||
expect(await vert(frame.pad.left + frame.contentWidth - 0.375), `${t.id} right edge`).toBeGreaterThan(full * 0.8);
|
||||
}
|
||||
});
|
||||
|
||||
it("the header row repeats on the next page with its rules, and each row owns its bottom rule", async () => {
|
||||
const canvas = await loadCanvas();
|
||||
for (const t of LEDGER) {
|
||||
const r = await renderFixture({ name: "p", items: 60, logo: "none" }, prefsFor("A4", { templateId: t.id }));
|
||||
expect(r.result.pages).toBeGreaterThan(1);
|
||||
const frame = frameForPrefs(r.prefs);
|
||||
for (const [i, page] of r.result.core.layout.entries()) {
|
||||
const heads = nodes([page]).filter((n) => n.id === "tbl-head");
|
||||
if (i > 0) expect(heads, `${t.id} p${i + 1}`).toHaveLength(1);
|
||||
if (i > 0) expect(heads[0].box.y).toBeCloseTo(frame.pad.top, 0);
|
||||
}
|
||||
if (!canvas) continue;
|
||||
// The first row of page 1 (inkPixels reads page 1): a rule runs under it across the whole table width.
|
||||
const row = nodes([r.result.core.layout[0]]).find((n) => n.id === "tbl-row-0")!;
|
||||
const bottom = row.box.y + row.box.h;
|
||||
const strip = await inkPixels(canvas, r.result.bytes, { x0: frame.pad.left + 5, y0: bottom - 0.9, x1: frame.pad.left + frame.contentWidth - 5, y1: bottom }, 4);
|
||||
expect(strip, `${t.id} row bottom rule`).toBeGreaterThan((frame.contentWidth - 10) * 4 * 0.9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the slots keep a home in the new layouts", () => {
|
||||
it("a tax invoice carries every GST slot; an unregistered one carries none of the regulated ones", async () => {
|
||||
for (const t of LEDGER) {
|
||||
const tax = await renderFixture(FIXTURES.find((f) => f.name === "cgst-sgst")!, prefsFor("A4", { templateId: t.id }));
|
||||
const ids = new Set(nodes(tax.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 plain = await renderFixture(FIXTURES.find((f) => f.name === "unregistered")!, prefsFor("A4", { templateId: t.id }));
|
||||
const plainIds = new Set(nodes(plain.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(plainIds.has(id), `${t.id} #${id}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("the HSN/SAC column is a fifth grid column on a tax invoice", async () => {
|
||||
const r = await renderFixture({ name: "h", items: 2, logo: "none" }, prefsFor("A4", { templateId: "crimson-grid" }));
|
||||
expect(r.pdf.pages[0].items.some((i) => i.str.includes("HSN/SAC"))).toBe(true);
|
||||
const plain = await renderFixture({ name: "h", items: 2, logo: "none", registered: false }, prefsFor("A4", { templateId: "crimson-grid" }));
|
||||
expect(plain.pdf.pages[0].items.some((i) => i.str.includes("HSN/SAC"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notes", () => {
|
||||
it("short notes sit beside the totals box, long ones flow below the table", () => {
|
||||
const frame = frameForPrefs(prefsFor("A4", { templateId: "crimson-grid" }));
|
||||
const short = buildFixture({ name: "n", items: 2, invoice: { notes: "Payment due within 30 days." } }).model;
|
||||
expect(planLedger(short, frame, CRIMSON_VARIANT, null, false).notesBeside).toBe(true);
|
||||
const long = buildFixture(FIXTURES.find((f) => f.name === "long-notes")!).model;
|
||||
expect(planLedger(long, frame, CRIMSON_VARIANT, null, false).notesBeside).toBe(false);
|
||||
const none = buildFixture({ name: "n", items: 2 }).model;
|
||||
expect(planLedger(none, frame, CRIMSON_VARIANT, null, false).notesBeside).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("long text", () => {
|
||||
it("long names, addresses and unbroken tokens raise no audit error and stay inside the page", async () => {
|
||||
for (const t of LEDGER) {
|
||||
for (const size of ["A4", "LETTER"] as const) {
|
||||
const r = await renderFixture(FIXTURES.find((f) => f.name === "id-tokens")!, prefsFor(size, { templateId: t.id }));
|
||||
expect(r.result.issues.filter((i) => i.severity === "error"), `${t.id}/${size}`).toEqual([]);
|
||||
for (const n of nodes(r.result.core.layout)) if (n.type === "text" && !n.fixed && !n.decor) for (const l of n.lines ?? []) expect(l.x + l.w, `${t.id}/${size} "${l.text}"`).toBeLessThanOrEqual(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right + 0.6);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan matches the laid-out blocks", () => {
|
||||
// The totals tail and the closing block carry presence values predicted from the measurer; too low and a
|
||||
// block can start a page without its spacer.
|
||||
for (const t of LEDGER) {
|
||||
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, so the plan made here is the one the layout made (a logo only changes the header).
|
||||
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 = planLedger(r.fixture.model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null, false);
|
||||
// Wrap is predicted greedily and can only err by whole lines, on the safe side.
|
||||
expect(plan.tailH - find(TOTALS_ID), "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.tailH - find(TOTALS_ID)).toBeLessThanOrEqual(15 + plan.gaps.tail);
|
||||
expect(plan.closingH - find(CLOSING_ID), "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
|
||||
expect(plan.closingH - find(CLOSING_ID)).toBeLessThanOrEqual(15);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("design docs", () => {
|
||||
it("each Ledger doc records its implementation notes", () => {
|
||||
for (const f of ["07-crimson-grid.md", "11-tangerine-ledger.md"]) {
|
||||
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
|
||||
expect(text, f).toContain("## Implementation notes");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import { colsDetailed, type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { textHeight, textLines } from "../../engine/textplan";
|
||||
import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
import type { PlacedLogo } from "../contract";
|
||||
import { bankLines, CANCELLED_SENTENCE, CLOSING_GAP, elastic, rateCell } from "../neutral/plan";
|
||||
import type { LedgerVariant } from "./variant";
|
||||
|
||||
export const WORDS_GAP = 10;
|
||||
export const REVERSE_GAP = 4;
|
||||
/** Space between the closing block's heading and its content. */
|
||||
export const HEADING_AFTER = 4;
|
||||
/** Space between the tail's left column (notes) and the totals box. */
|
||||
export const TAIL_COLUMN_GAP = 16;
|
||||
const MIN_NAME_W = 40;
|
||||
/** Narrowest the title may be squeezed to share the row with a brand block. */
|
||||
const MIN_BRAND_W = 90;
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const leading = (t: TypeToken) => t.leading as number;
|
||||
|
||||
export function metaLines(model: RenderModel): string[] {
|
||||
const label = model.meta.numberLabel === "Invoice No." ? "Invoice Number" : model.meta.numberLabel;
|
||||
const lines = [`${label}: ${model.meta.number}`, `Invoice Date: ${model.meta.date}`];
|
||||
if (model.meta.dueDate) lines.push(`Due Date: ${model.meta.dueDate}`);
|
||||
if (model.client.poNumber) lines.push(`PO No: ${model.client.poNumber}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function signatureOptions(v: LedgerVariant): SignatoryOptions {
|
||||
const r = v.roles;
|
||||
return {
|
||||
roles: { name: r.bodyStrong, designation: r.small, fine: r.fine },
|
||||
align: "left",
|
||||
rule: { width: v.rule, color: v.ink },
|
||||
color: v.ink,
|
||||
imageHeight: v.signatureImageH,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TotalsRowSpec {
|
||||
label: string;
|
||||
amount: string;
|
||||
kind: "plain" | "total";
|
||||
}
|
||||
|
||||
export interface LedgerPlan {
|
||||
v: LedgerVariant;
|
||||
W: number;
|
||||
leftW: number;
|
||||
rightW: number;
|
||||
gaps: { rule: number; meta: number; party: number; table: number; tail: number; notes: number };
|
||||
title: { token: TypeToken; w: number };
|
||||
brand: {
|
||||
w: number;
|
||||
showName: boolean;
|
||||
nameW: number;
|
||||
nameH: number;
|
||||
/** Offsets inside the brand block (which starts at the zone's top). */
|
||||
logoTop: number;
|
||||
nameTop: number;
|
||||
nameLeft: number;
|
||||
};
|
||||
headerH: number;
|
||||
meta: string[];
|
||||
columns: ColSpec[];
|
||||
header: string[];
|
||||
rows: TableCellValue[][];
|
||||
align: Array<"left" | "center" | "right">;
|
||||
table: ItemsTablePlan;
|
||||
guardH: number;
|
||||
totals: { labelW: number; valueW: number; boxW: number; rowH: number; totalRowH: number; rows: TotalsRowSpec[]; slotRows: number; boxH: number };
|
||||
notesBeside: boolean;
|
||||
notesW: number;
|
||||
wordsH: number;
|
||||
reverseH: number;
|
||||
tailH: number;
|
||||
closingH: number;
|
||||
payH: number;
|
||||
signH: number;
|
||||
signW: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the layout must know before rendering: the brand block, columns, the totals box and the
|
||||
* predicted heights of the totals tail and the closing block (their minPresenceAhead values), each a hair
|
||||
* high rather than low.
|
||||
*/
|
||||
export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVariant, logo: PlacedLogo | null, includesName: boolean): LedgerPlan {
|
||||
const m = getMeasurer();
|
||||
const r = v.roles;
|
||||
const W = frame.contentWidth;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
|
||||
// --- header: title and brand block ------------------------------------------------------------------
|
||||
const markW = logo?.w ?? 0;
|
||||
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
|
||||
const titleNatural = Math.ceil(measureToken(m, r.title, model.docTitle)) + 1;
|
||||
const brandCap = Math.floor(W * v.brandMaxShare);
|
||||
const minBrand = Math.max(markW, MIN_BRAND_W);
|
||||
const titleW = Math.min(titleNatural, W - v.brandGap - minBrand);
|
||||
const brandAvail = Math.min(brandCap, W - titleW - v.brandGap);
|
||||
const markBlock = markW > 0 && showName ? markW + v.markGap : markW;
|
||||
let nameW = 0;
|
||||
let nameH = 0;
|
||||
if (showName) {
|
||||
const natural = Math.ceil(measureToken(m, r.brand, model.supplier.name)) + 1;
|
||||
nameW = Math.max(MIN_NAME_W, Math.min(natural, brandAvail - markBlock));
|
||||
nameH = textHeight(r.brand, model.supplier.name, nameW);
|
||||
}
|
||||
const brandW = markBlock + nameW;
|
||||
const zone = v.brandZone;
|
||||
const logoTop = logo ? (logo.slotOffset?.y ?? 0) : 0;
|
||||
const nameTop = logo ? Math.max(0, logoTop + (logo.h - nameH) / 2) : Math.max(0, (zone.h - nameH) / 2);
|
||||
const brandBottom = zone.top + Math.max(logo ? logoTop + logo.h : 0, showName ? nameTop + nameH : 0);
|
||||
const headerH = Math.max(v.titleTop + leading(r.title), zone.top + zone.h, brandBottom);
|
||||
|
||||
// --- parties ---------------------------------------------------------------------------------------
|
||||
const leftW = round2(W * v.partyLeftShare);
|
||||
const rightW = round2(W - leftW - v.partyGap);
|
||||
const s = model.supplier;
|
||||
let supplierH = textHeight(r.label, s.name, leftW) + v.headingGap;
|
||||
for (const l of s.addressLines) supplierH += textHeight(r.body, l, leftW);
|
||||
supplierH += ([s.email, s.phone, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier")) * leading(r.body);
|
||||
const c = model.client;
|
||||
let clientH = leading(r.label) + v.headingGap + textHeight(r.body, c.name, rightW);
|
||||
for (const l of c.addressLines) clientH += textHeight(r.body, l, rightW);
|
||||
clientH += (slotHeights.gstinLines(model, "client") + 0) * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, rightW) * leading(r.body);
|
||||
const partyH = Math.max(supplierH, clientH);
|
||||
const meta = metaLines(model);
|
||||
const metaH = meta.length * leading(r.body) + (meta.length - 1) * v.metaGap;
|
||||
|
||||
// --- table -------------------------------------------------------------------------------------------
|
||||
const hsnCol = hsn;
|
||||
const ruleCols = (i: number) => (i === 0 ? 2 : 1) * v.rule;
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
|
||||
const colW = (base: number, texts: string[], headText: string, idx: number) =>
|
||||
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1);
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const nCols = hsnCol ? 5 : 4;
|
||||
const idx = (base: number) => (hsnCol ? base + 1 : base);
|
||||
const c0 = {
|
||||
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0,
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit Price", idx(1)),
|
||||
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)),
|
||||
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }];
|
||||
const header = ["Item & Description", ...(hsnCol ? [hsnCol.header] : []), "Unit Price", "Qty", "Amount"];
|
||||
const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
|
||||
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
|
||||
const tableRows: TableCellValue[][] = model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateInner, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
|
||||
});
|
||||
void nCols;
|
||||
|
||||
// --- totals box --------------------------------------------------------------------------------------
|
||||
const t = model.totals;
|
||||
const labelTexts = ["Sub-Total", "Discount", "Taxable value", "UTGST @ 14%"];
|
||||
const labelW = Math.max(v.totals.labelW, Math.ceil(Math.max(widest(r.totals, labelTexts), widest(r.total, ["Total"]))) + 2 * v.padX + 2 * v.rule + 1);
|
||||
const amounts = [t.subtotalText, t.totalText, t.discountText ? `- ${t.discountText}` : "", t.taxableText ?? ""];
|
||||
const valueW = Math.max(v.totals.valueW, Math.ceil(Math.max(widest(r.totals, amounts), widest(r.total, [t.totalText]))) + 2 * v.padX + v.rule + 1);
|
||||
const boxW = Math.min(labelW + valueW, Math.floor(W * v.totals.maxShare) + 40);
|
||||
const boxRows: TotalsRowSpec[] = [{ label: "Sub-Total", amount: t.subtotalText, kind: "plain" }];
|
||||
if (t.discountText) boxRows.push({ label: "Discount", amount: `- ${t.discountText}`, kind: "plain" });
|
||||
if (t.taxableText) boxRows.push({ label: "Taxable value", amount: t.taxableText, kind: "plain" });
|
||||
const slotRows = slotHeights.taxRowCount(model);
|
||||
const rowH = leading(r.totals) + 2 * v.totals.padY + v.rule;
|
||||
const totalRowH = leading(r.total) + 2 * v.totals.padY + v.rule;
|
||||
const boxH = (boxRows.length + slotRows) * rowH + totalRowH + v.rule;
|
||||
|
||||
// --- tail: notes beside the box, then the words ------------------------------------------------------
|
||||
const notesW = Math.max(0, W - boxW - TAIL_COLUMN_GAP);
|
||||
const notesH = model.notesLines.length
|
||||
? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, notesW), 0)
|
||||
: 0;
|
||||
const notesBeside = notesH > 0 && notesW >= 140 && notesH <= v.notesBesideMax;
|
||||
const wordsH = slotHeights.amountInWords(model, W, { role: r.small, labelRole: r.wordsLabel });
|
||||
const reverseH = slotHeights.reverseCharge(model, W, r.small);
|
||||
const tailBody = (notesBeside ? Math.max(boxH, notesH) : boxH) + 1 + (wordsH ? WORDS_GAP + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
|
||||
|
||||
// --- closing -----------------------------------------------------------------------------------------
|
||||
const signW = Math.min(v.signatureW, rightW);
|
||||
const lines = bankLines(model);
|
||||
let payH = 0;
|
||||
if (model.bank) {
|
||||
payH = leading(r.label) + HEADING_AFTER;
|
||||
for (const l of lines) payH += textHeight(r.small, l, leftW);
|
||||
}
|
||||
const signH = leading(r.label) + HEADING_AFTER + signatoryHeight(model, signW, signatureOptions(v));
|
||||
let closingH = CLOSING_GAP + Math.max(payH, signH) + 1.5;
|
||||
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
|
||||
|
||||
// --- elastic gaps ------------------------------------------------------------------------------------
|
||||
const ranges = { rule: v.headerRule ? v.gaps.rule : { nominal: 0, min: 0 }, meta: v.gaps.meta, party: v.gaps.party, table: v.gaps.table, tail: v.gaps.tail, notes: v.gaps.notes };
|
||||
const nominalTail = ranges.tail.nominal + tailBody;
|
||||
const table0 = planTable(model, v, W, columns, header, tableRows, nCols, frame, nominalTail);
|
||||
const tableTotal = table0.headH + table0.rowHeights.reduce((a, b) => a + b, 0);
|
||||
const ownNotes = model.notesLines.length > 0 && !notesBeside;
|
||||
const ownNotesH = ownNotes ? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, W), 0) : 0;
|
||||
const nominalTotal =
|
||||
headerH +
|
||||
ranges.rule.nominal +
|
||||
(v.headerRule?.thickness ?? 0) +
|
||||
ranges.meta.nominal +
|
||||
metaH +
|
||||
ranges.party.nominal +
|
||||
partyH +
|
||||
ranges.table.nominal +
|
||||
tableTotal +
|
||||
nominalTail +
|
||||
(ownNotes ? ranges.notes.nominal + ownNotesH : 0) +
|
||||
closingH;
|
||||
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
||||
const overflow = nominalTotal - frame.contentHeight;
|
||||
const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0);
|
||||
const g = elastic(ranges, deficit);
|
||||
|
||||
const tailH = g.tail + tailBody;
|
||||
const table = planTable(model, v, W, columns, header, tableRows, nCols, frame, tailH);
|
||||
// Rows are unbreakable, so the guard asks for the whole first row (the shared 3-line cap leaves a header alone
|
||||
// above a taller row); only a row beyond 40% of the page is capped, so it cannot push the table off every page.
|
||||
// A one-row table has no previous row to carry the last row's own presence value (the row is first in its
|
||||
// container, where react-pdf never breaks before it), so the guard also holds the tail.
|
||||
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (tableRows.length === 1 ? tailH : 0);
|
||||
|
||||
return {
|
||||
v,
|
||||
W,
|
||||
leftW,
|
||||
rightW,
|
||||
gaps: { rule: g.rule, meta: g.meta, party: g.party, table: g.table, tail: g.tail, notes: g.notes },
|
||||
title: { token: r.title, w: titleW },
|
||||
brand: { w: brandW, showName, nameW, nameH, logoTop, nameTop, nameLeft: markBlock },
|
||||
headerH,
|
||||
meta,
|
||||
columns,
|
||||
header,
|
||||
rows: tableRows,
|
||||
align,
|
||||
table,
|
||||
guardH,
|
||||
totals: { labelW, valueW, boxW, rowH, totalRowH, rows: boxRows, slotRows, boxH },
|
||||
notesBeside,
|
||||
notesW,
|
||||
wordsH,
|
||||
reverseH,
|
||||
tailH,
|
||||
closingH,
|
||||
payH,
|
||||
signH,
|
||||
signW,
|
||||
};
|
||||
}
|
||||
|
||||
function planTable(
|
||||
model: RenderModel,
|
||||
v: LedgerVariant,
|
||||
W: number,
|
||||
columns: ColSpec[],
|
||||
header: string[],
|
||||
rows: TableCellValue[][],
|
||||
nCols: number,
|
||||
frame: PageFrame,
|
||||
tailH: number,
|
||||
): ItemsTablePlan {
|
||||
const r = v.roles;
|
||||
void model;
|
||||
return planItemsTable({
|
||||
columns,
|
||||
gap: 0,
|
||||
width: W,
|
||||
header,
|
||||
rows,
|
||||
measurer: getMeasurer(),
|
||||
roles: { head: r.tableHead, body: r.tableBody },
|
||||
padX: v.padX,
|
||||
padY: v.padY,
|
||||
border: v.rule,
|
||||
padRight: gridPadRight(nCols, v.rule),
|
||||
// The header's rule above its cells.
|
||||
headerTailH: v.rule,
|
||||
tailH,
|
||||
contentH: frame.contentHeight,
|
||||
});
|
||||
}
|
||||
|
||||
export { colsDetailed, textLines };
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineRoles } from "../../fonts/roles";
|
||||
import { pennant, TANGERINE_TABS } from "../../decor/Pennant";
|
||||
import { catalogEntry } from "../catalog";
|
||||
import type { TemplateDefinition } from "../contract";
|
||||
import { slotFor } from "../slots.optin";
|
||||
import { LedgerLayoutFor } from "./layoutFor";
|
||||
import type { LedgerRoles, LedgerVariant } from "./variant";
|
||||
|
||||
const INK = "#000000";
|
||||
const ACCENT = "#FF5F2F";
|
||||
const D = "Poppins" as const;
|
||||
const B = "Inter" as const;
|
||||
/** Height reserved for the footer line (10 pt of text plus air; there is no footer rule). */
|
||||
const FOOTER_RESERVE = 16;
|
||||
|
||||
/**
|
||||
* Tangerine Ledger's type: Poppins Bold for the brand and title, Inter for everything else
|
||||
* (docs/templates/11-tangerine-ledger.md). Inter ascent 0.969, descent 0.241.
|
||||
*/
|
||||
export const TANGERINE_ROLES = defineRoles({
|
||||
title: { face: D, weight: 700, size: 33, leading: 40, caps: true },
|
||||
brand: { face: D, weight: 700, size: 18, leading: 26, minSize: 11 },
|
||||
label: { face: B, weight: 600, size: 12, leading: 16.5, caps: true },
|
||||
body: { face: B, weight: 400, size: 12, leading: 16.5 },
|
||||
bodyStrong: { face: B, weight: 600, size: 12, leading: 16.5 },
|
||||
tableHead: { face: B, weight: 600, size: 11, leading: 15.4 },
|
||||
tableBody: { face: B, weight: 400, size: 11, leading: 15.4 },
|
||||
tableSub: { face: B, weight: 400, size: 8.5, leading: 12 },
|
||||
totals: { face: B, weight: 400, size: 11, leading: 15.4 },
|
||||
total: { face: B, weight: 700, size: 12, leading: 16.5 },
|
||||
small: { face: B, weight: 400, size: 10.5, leading: 14.5 },
|
||||
wordsLabel: { face: B, weight: 600, size: 10.5, leading: 14.5 },
|
||||
fine: { face: B, weight: 400, size: 8.5, leading: 12 },
|
||||
contact: { face: B, weight: 400, size: 10.5, leading: 14.5 },
|
||||
footer: { face: B, weight: 400, size: 7 },
|
||||
} satisfies LedgerRoles);
|
||||
|
||||
/**
|
||||
* Canva A4 numbers: the INVOICE box starts at y 46 (baseline 80.6), the brand text is centred in the
|
||||
* 36 pt allowance at y 62-98, the content runs x 59.5-536.7. The tabs are 35 pt wide, so no side margin
|
||||
* goes under 45 whatever the preset.
|
||||
*/
|
||||
export const TANGERINE_VARIANT: LedgerVariant = {
|
||||
id: "tangerine-ledger",
|
||||
roles: TANGERINE_ROLES,
|
||||
accent: ACCENT,
|
||||
ink: INK,
|
||||
onAccent: "#FFFFFF",
|
||||
margins: { top: 46, right: 59.55, bottom: 60, left: 59.55 },
|
||||
marginFloor: { top: 36, right: 45, bottom: 36, left: 45 },
|
||||
mirrored: true,
|
||||
titleTop: 0,
|
||||
brandZone: { top: 16, h: 36 },
|
||||
brandMaxShare: 0.5,
|
||||
brandGap: 24,
|
||||
markGap: 10,
|
||||
headerRule: null,
|
||||
totalFilled: true,
|
||||
art: "tabs",
|
||||
gaps: {
|
||||
rule: { nominal: 0, min: 0 },
|
||||
meta: { nominal: 59, min: 32 },
|
||||
party: { nominal: 20.8, min: 12 },
|
||||
table: { nominal: 25.4, min: 14 },
|
||||
tail: { nominal: 23.3, min: 10 },
|
||||
notes: { nominal: 12, min: 8 },
|
||||
},
|
||||
metaGap: 2.2,
|
||||
headingGap: 6.7,
|
||||
partyLeftShare: 0.4453,
|
||||
partyGap: 12.1,
|
||||
rule: 0.75,
|
||||
padX: 9.8,
|
||||
padY: 10.8,
|
||||
cols: { hsn: 56, rate: 100, qty: 45, amount: 100 },
|
||||
totals: { labelW: 100, valueW: 105, padY: 6, maxShare: 0.55 },
|
||||
notesBesideMax: 110,
|
||||
signatureW: 200,
|
||||
signatureImageH: 40,
|
||||
chromeInset: 0,
|
||||
footerLeftW: 140,
|
||||
};
|
||||
|
||||
export const tangerineLedger: TemplateDefinition = {
|
||||
...catalogEntry("tangerine-ledger"),
|
||||
version: 1,
|
||||
roles: TANGERINE_ROLES,
|
||||
margins: TANGERINE_VARIANT.margins,
|
||||
marginFloor: TANGERINE_VARIANT.marginFloor,
|
||||
decor: {
|
||||
ink: INK,
|
||||
everyPage: (frame) => [pennant(frame, { ...TANGERINE_TABS.topRight, color: ACCENT }), pennant(frame, { ...TANGERINE_TABS.bottomLeft, color: ACCENT })],
|
||||
},
|
||||
chrome: {
|
||||
color: "#6f6f6f",
|
||||
rule: null,
|
||||
role: TANGERINE_ROLES.footer,
|
||||
leftWidth: TANGERINE_VARIANT.footerLeftW,
|
||||
reserve: FOOTER_RESERVE,
|
||||
},
|
||||
logo: {
|
||||
box: { w: 220, h: 36 },
|
||||
stackBelowTextWidth: 0,
|
||||
defaultOn: true,
|
||||
slotWidth: () => 220,
|
||||
slot: slotFor("tangerine-ledger"),
|
||||
available: () => ({ w: 220, h: 36 }),
|
||||
},
|
||||
Layout: LedgerLayoutFor(TANGERINE_VARIANT),
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Sides } from "../../engine/geometry";
|
||||
import type { TypeToken } from "../../fonts/roles";
|
||||
import type { GapRange } from "../neutral/variant";
|
||||
|
||||
/** Type roles of a Ledger template (Crimson Grid: all Poppins; Tangerine Ledger: Poppins Bold display over Inter). */
|
||||
export interface LedgerRoles {
|
||||
/** "INVOICE" / "TAX INVOICE". */
|
||||
title: TypeToken;
|
||||
/** The brand text beside (or instead of) the logo. */
|
||||
brand: TypeToken;
|
||||
/** Section headings: vendor name, BILL TO, NOTES / TERMS, PAYMENT METHOD (caps). */
|
||||
label: TypeToken;
|
||||
/** Meta, party and notes lines. */
|
||||
body: TypeToken;
|
||||
bodyStrong: TypeToken;
|
||||
tableHead: TypeToken;
|
||||
tableBody: TypeToken;
|
||||
/** The unit line under a rate ("per hour"). */
|
||||
tableSub: TypeToken;
|
||||
/** Totals labels and amounts. */
|
||||
totals: TypeToken;
|
||||
/** The Total row. */
|
||||
total: TypeToken;
|
||||
/** Bank lines, amount in words, notes under the table. */
|
||||
small: TypeToken;
|
||||
wordsLabel: TypeToken;
|
||||
fine: TypeToken;
|
||||
/** The contact line in Crimson's footer band. */
|
||||
contact: TypeToken;
|
||||
/** Footer and continuation band: a render-prop role (no leading). */
|
||||
footer: TypeToken;
|
||||
}
|
||||
|
||||
export type LedgerId = "crimson-grid" | "tangerine-ledger";
|
||||
|
||||
/**
|
||||
* What differs between Crimson Grid and Tangerine Ledger. y values are design distances measured on the
|
||||
* Canva A4 page and converted to flow gaps; x values are relative to the content edge.
|
||||
*/
|
||||
export interface LedgerVariant {
|
||||
id: LedgerId;
|
||||
roles: LedgerRoles;
|
||||
accent: string;
|
||||
/** Text, grid lines and the total's outline. */
|
||||
ink: string;
|
||||
/** Text on the accent (table header, filled Total). */
|
||||
onAccent: string;
|
||||
margins: Sides;
|
||||
marginFloor: Sides;
|
||||
|
||||
/** Brand left and title right (Tangerine), or the title left and the brand right (Crimson). */
|
||||
mirrored: boolean;
|
||||
/** Offset of the title box below the header row's top (Poppins's tall ascent puts the cap top lower). */
|
||||
titleTop: number;
|
||||
/** The slot's area, row-relative: where the brand zone starts and how tall it is. */
|
||||
brandZone: { top: number; h: number };
|
||||
/** Widest the brand block may be, as a share of the content width. */
|
||||
brandMaxShare: number;
|
||||
/** Space between the title and the brand block. */
|
||||
brandGap: number;
|
||||
/** Space between a mark and the brand name. */
|
||||
markGap: number;
|
||||
/** A rule under the header (Crimson). */
|
||||
headerRule: { thickness: number } | null;
|
||||
/** The Total row is filled with the accent (Tangerine). */
|
||||
totalFilled: boolean;
|
||||
/** Footer art: a full-bleed band with the contact line (Crimson) or edge tabs (Tangerine). */
|
||||
art: "footer-band" | "tabs";
|
||||
|
||||
gaps: {
|
||||
/** Header row bottom to the rule (Crimson only). */
|
||||
rule: GapRange;
|
||||
/** Header (or rule) bottom to the meta lines. */
|
||||
meta: GapRange;
|
||||
/** Last meta line to the party headings. */
|
||||
party: GapRange;
|
||||
/** Party blocks to the table. */
|
||||
table: GapRange;
|
||||
/** Last row to the totals box. */
|
||||
tail: GapRange;
|
||||
notes: GapRange;
|
||||
};
|
||||
/** Extra space between meta lines beyond the leading. */
|
||||
metaGap: number;
|
||||
/** Heading to its first line. */
|
||||
headingGap: number;
|
||||
/** Width of the vendor column as a share of the content width; the BILL TO column takes the rest. */
|
||||
partyLeftShare: number;
|
||||
partyGap: number;
|
||||
|
||||
/** Rule width of the grid. */
|
||||
rule: number;
|
||||
padX: number;
|
||||
/** Vertical padding of the header and body rows. */
|
||||
padY: number;
|
||||
/** Minimum widths; a column grows to its widest token. */
|
||||
cols: { hsn: number; rate: number; qty: number; amount: number };
|
||||
/** Totals box: minimum label and value column widths, and row padding. */
|
||||
totals: { labelW: number; valueW: number; padY: number; maxShare: number };
|
||||
/** Notes beside the totals box up to this height (pt); longer notes flow below the table. */
|
||||
notesBesideMax: number;
|
||||
signatureW: number;
|
||||
signatureImageH: number;
|
||||
/** Footer chrome. */
|
||||
chromeInset: number;
|
||||
footerLeftW: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { TemplateProps } from "./contract";
|
||||
|
||||
/** The logo already carries the business name (the user's setting, resolved from Automatic): the template then hides its own name text. */
|
||||
export function includesName(props: Pick<TemplateProps, "model" | "prefs">): boolean {
|
||||
return props.prefs.logo?.useWordmark ?? props.model.logo?.includesName ?? false;
|
||||
}
|
||||
@@ -27,8 +27,8 @@ const nodes = (layout: LayoutNode[]) => {
|
||||
};
|
||||
|
||||
describe("registry and catalog", () => {
|
||||
it("lists Classic first and Linea second, then Monogram and Serenity", () => {
|
||||
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
|
||||
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(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { TemplateDefinition } from "./contract";
|
||||
import { classic } from "./classic";
|
||||
import { linea } from "./neutral/linea";
|
||||
import { crimsonGrid } from "./ledger/crimson";
|
||||
import { tangerineLedger } from "./ledger/tangerine";
|
||||
import { slateBand } from "./band/slate";
|
||||
import { tealSwoosh } from "./band/teal";
|
||||
import { monogram } from "./neutral/monogram";
|
||||
import { serenity } from "./neutral/serenity";
|
||||
|
||||
@@ -10,6 +14,10 @@ export const TEMPLATES: Record<string, TemplateDefinition> = {
|
||||
linea,
|
||||
monogram,
|
||||
serenity,
|
||||
"crimson-grid": crimsonGrid,
|
||||
"tangerine-ledger": tangerineLedger,
|
||||
"slate-band": slateBand,
|
||||
"teal-swoosh": tealSwoosh,
|
||||
};
|
||||
|
||||
export const DEFAULT_TEMPLATE_ID = "classic";
|
||||
|
||||
@@ -25,7 +25,7 @@ function sources(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const full = path.join(dir, name);
|
||||
if (statSync(full).isDirectory()) return sources(full);
|
||||
return /\.(ts|tsx)$/.test(name) && !/\.test\.|harness\.ts$/.test(name) ? [full] : [];
|
||||
return /\.(ts|tsx)$/.test(name) && !/\.test\.|harness(\.check)?\.ts$/.test(name) ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user