Add the Neutral template family, template picker and text-only logo allowances

Linea (the vendor's original format.pdf design), Monogram and Serenity are
rebuilt from the Canva PDFs as one parametric family on the engine, with
the user's logo in the measured slot, a home for every GST field and
A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and
a generated Ms Madi outline for Serenity's vertical word. Text-only logo
placeholders (Monolith, Tangerine) use a doc-derived allowance box. A
Carbon TemplatePicker with committed thumbnails opens from the page
setup controls.
This commit is contained in:
2026-10-04 07:27:12 +05:30
parent 86aaa1a790
commit 04d92aa149
61 changed files with 3278 additions and 110 deletions
+17
View File
@@ -139,3 +139,20 @@ Page is 595.5 x 842.25pt (Canva's A4). In react-pdf use A4 (595.28 x 841.89); th
- **Byte-identical page content to /media/Toto/Documents/bongbetic/invoice/format.pdf.** It is the same Canva page; only the Info/XMP title, export timestamps, Canva design ID, xref offsets and trailer /ID differ. So this IS the vendor's original format, still carrying Canva's sample data (Daniel Gallego / Richard Sanchez / Borcele Bank). - **Byte-identical page content to /media/Toto/Documents/bongbetic/invoice/format.pdf.** It is the same Canva page; only the Info/XMP title, export timestamps, Canva design ID, xref offsets and trailer /ID differ. So this IS the vendor's original format, still carrying Canva's sample data (Daniel Gallego / Richard Sanchez / Borcele Bank).
- **Not a duplicate of the in-app template.** The current src/pdf/InvoiceDocument.tsx only loosely adapts it (see group_notes). - **Not a duplicate of the in-app template.** The current src/pdf/InvoiceDocument.tsx only loosely adapts it (see group_notes).
- **Sibling of 5.pdf and 6.pdf.** They share the grid: rules 69.64-525.86, column centres 325.2 / 416.4, money right edge 514.4, meta label/value anchors about 447 / 519, totals label anchor 460.3. 5 changes fonts and decoration; 6 shifts the grid right. Build 4, 5 and 6 as one configurable 'Neutral' component family. - **Sibling of 5.pdf and 6.pdf.** They share the grid: rules 69.64-525.86, column centres 325.2 / 416.4, money right edge 514.4, meta label/value anchors about 447 / 519, totals label anchor 460.3. 5 changes fonts and decoration; 6 shifts the grid right. Build 4, 5 and 6 as one configurable 'Neutral' component family.
## Implementation notes
Built as `src/pdf/templates/neutral/` (one family component, `Layout.tsx`, with tokens in `linea.ts`; planning in `plan.ts`). Measured against `4.pdf` with `npm run templates:compare` (`/tmp/invoice-c3/compare-linea.png`, left Canva, right ours).
**Matches the design (measured, A4):** title baseline 161.6 pt and x 59.5 as in Canva; hairline starting 20 pt after the title and ending on the margin; 'ISSUED TO' at x 77.4; the Montserrat glyph widths are metrically identical to the PDF (label and value widths agree to 0.1 pt); header baseline to first row 36.3 pt; row pitch 21.4 pt (Canva 21.46); table rules at x 69.6-525.6 (Canva 69.64-525.86); one colour, #3D3B3A.
**Intentional deviations, and why:**
- **Margins.** The template margin is 59.55 pt left and right, 56 top and 60 bottom, plus a 16 pt footer reserve. The doc's 77.4 pt text column with negative margins would put the title (x 59.5) and the rules (x 69.6) outside the margin box, which the audit forbids for flowing content. Here the title starts on the margin, the rules start 10.09 pt inside it and the text 17.9 pt inside it, so every x is relative to the margin and Narrow/Normal work. The content area ends at y 766 (page height less 60 + 16); Canva's signature ink ends at 772.
- **Supplier block (FROM)** is right-aligned under the title rule (docs: band x 290-535, y 170-255), max 245 pt wide. Its height pushes the info row down by the lines it needs beyond the design's empty band; a long address can never touch the title.
- **PAY TO** stays under ISSUED TO as in the design, with Bank, Account Name, Account No., Branch and IFSC.
- **Table.** An HSN/SAC column appears only when an item carries a code. UNIT PRICE shows the amount with a short unit ('₹1,200.00/hr'); when that does not fit the column it goes on two lines (amount, then 'per hour'). Fixed columns widen to their widest token (an amount cannot wrap) and the description takes the rest, so the table never stacks. The 4 pt indent of the cells (Canva artefact) is dropped: text starts at x 77.4 in the header and the rows.
- **Totals.** SUBTOTAL runs full width; DISCOUNT, TAXABLE VALUE, `CGST @ 9%` / `SGST @ 9%` (or UTGST, IGST) with their amounts and TOTAL form the right-anchored column (236 pt, amounts 92 pt). Amount in words and the reverse-charge line sit left of that column; below 150 pt of room they move under it.
- **Closing.** Only the signature is bottom-right; the user's signature image or, without one, the signatory name and designation plus the computer-generated line. Canva's script signature is not reproduced. The block is pushed to the foot of the last page by the bottom spacer.
- **Logo.** None by default (so Linea matches `format.pdf`); `prefs.logo.show` (the Logo toggle next to the template button) puts the logo in the optional band above the title, at most 150 x 48 pt, bottom-aligned 4.85 pt above the title box.
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): the band above the title (68.85 to 33.85 pt), the supplier-to-info gap (down to 14 pt) and the info-to-table gap (49.5 to 24 pt). Type is never scaled. 'TAX INVOICE' drops its tracking to 0.12 em only if the hairline would be under 40 pt.
- Footer ('Generated with Voiced', page n of N) at 7 pt Montserrat in the same ink; the design has none.
+16
View File
@@ -145,3 +145,19 @@ Page is 595.5x842.25 (A4). Same grid as Linea: rules x 69.64-525.86; text column
- 'RATE' instead of 'UNIT PRICE', and a bold SUBTOTAL. - 'RATE' instead of 'UNIT PRICE', and a bold SUBTOTAL.
- **Not similar to the current in-app template** beyond normal invoice structure. - **Not similar to the current in-app template** beyond normal invoice structure.
- **Shares its type system with 6.pdf:** Open Sans sizes and weights, 1pt tracking, Medium address lines. - **Shares its type system with 6.pdf:** Open Sans sizes and weights, 1pt tracking, Medium address lines.
## Implementation notes
Built on the shared Neutral family (`src/pdf/templates/neutral/`, tokens in `monogram.ts`). Measured against `5.pdf` with `npm run templates:compare` (`/tmp/invoice-c3/compare-monogram.png`).
**Matches the design (measured, A4):** title baseline 144.0 pt (Canva 143.98) and x 69.6 (flush with the rule start, 69.3 in Canva); ISSUED TO and INVOICE NO baselines 325.4 pt and meta pitch 15 pt; header baseline to first row 36.3 pt; label and value widths agree to 0.1 pt (Open Sans at 0.10 em tracking); rules x 69.6-525.6; ink #3D3B3A only.
**Intentional deviations, and why:**
- **Data text is tracked 0.05 em, not 0.10 em.** Wider tracking makes PDF text extraction split words, so the table body, address lines and values use 0.05 em (layout rule 7). Titles, labels and table heads keep 0.10 em. Body lines are therefore a little narrower than Canva's.
- **Logo, not the ring.** The user's logo goes in the measured slot (up to 142.75 x 116.5 pt, right edge on the margin, vertically centred on the old ring centre). A wordmark such as The sample vendor's comes out about 100 x 22 pt because the weight factor matches the visual weight of the ring (ink density 0.14). Without a logo the vendor's initials are set in Open Sans Bold 46 pt in the same slot, with no ring and no caption; a vendor name is always printed as text in the supplier block.
- **Supplier block (FROM)** is right-aligned under the logo (max 240 pt), as the doc suggests, and may push the info row down.
- **Margins.** Left and right 69.64 pt (the rule edge, text 7.8 pt inside), top 58, bottom 60 plus a 16 pt footer reserve. The logo may not overshoot the rule end by Canva's 5 pt (the audit keeps flowing content inside the margin box); it sits 4.6 pt further left.
- **Payment block** sits in the bottom row with the signature, bottoms aligned; it grows with IFSC, Branch and Account Name, and the user's signature image replaces the script name (name and designation without one). 'Payment info:' keeps the design's label.
- **Totals** as Linea, except SUBTOTAL, TOTAL and the labels are bold and the rows 18.6 pt apart; the right column shows tax amounts (`CGST @ 9%`), not a rate.
- **Table.** RATE is shown as in Linea (amount plus short unit), HSN/SAC is a column when present.
- **Letter:** the band above the logo shrinks (23.25 to 8 pt after the 58 pt margin) and the header-to-info and info-to-table gaps take the rest; type is untouched.
+17
View File
@@ -158,3 +158,20 @@ Implied margins: left 162.9 (rules) / 171.8 (text); right 64.1 / 70.8; top about
- **Same internal grid as 4 and 5, shifted right:** rules 162.91-531.39 instead of 69.64-525.86 (left edge +93.3, right edge +5.5). - **Same internal grid as 4 and 5, shifted right:** rules 162.91-531.39 instead of 69.64-525.86 (left edge +93.3, right edge +5.5).
- **Unique within the trio:** the paper texture, vertical script title, circular-text stamp, drawn signature, and the 'BANK DETAILS' / 'THANK YOU' bottom row. - **Unique within the trio:** the paper texture, vertical script title, circular-text stamp, drawn signature, and the 'BANK DETAILS' / 'THANK YOU' bottom row.
- **No resemblance to the in-app template.** - **No resemblance to the in-app template.**
## Implementation notes
Built on the shared Neutral family (`src/pdf/templates/neutral/`, tokens in `serenity.ts`, decor in `src/pdf/decor/ScriptWord.tsx`). Measured against `6.pdf` with `npm run templates:compare` (`/tmp/invoice-c3/compare-serenity.png`).
**Matches the design (measured, A4):** text column at x 171.8 and rules from x 162.91; Open Sans label and value widths agree to 0.1 pt; header baseline to first row 36.3 pt; paper #ECE8E1 as the Page `backgroundColor`; ink #3D3B3A; the vertical word and swash occupy Canva's band (word x 38.5-120, y 420-780; swash joined to the word's end and running up to y 143).
**Intentional deviations, and why:**
- **The word 'invoice' is outline art, not text.** Canva's SimpleSerenityScript is not shipped or traced. The word is drawn from Ms Madi Regular (SIL OFL 1.1; chosen over Sacramento because its hairline strokes are closer to Canva's) as SVG paths generated at build time by `scripts/decor/script-word.mjs` into `src/pdf/decor/scriptWord.generated.ts`, with the licence note in a comment. The font file stays in `scripts/decor/source/` only; it is never bundled, registered or embedded. The glyph contours are merged and shrunk by 0.95 pt per side (clipper-lib, a build-time dev dependency) to thin Ms Madi to a script-like weight; it is still heavier than Canva's. The swash is our own drawing (a tapered spine), not Canva's path, and the word is not tilted 1.1 degrees. The band is `fixed` page-level decor on every page, shifted up 32 pt on Letter. A real text 'TAX INVOICE' / 'INVOICE' label sits above the meta block, so the document is searchable.
- **No paper texture.** Canva's JPEG is stock and is not bundled; the page is the flat measured colour #ECE8E1.
- **Logo, not the stamp.** The user's logo goes in the measured stamp slot (82 x 82 pt, right edge on the text column). No logo, nothing: no ring, no stamp, no caption.
- **Data text is tracked 0.05 em** (layout rule 7); labels and heads keep 0.10 em.
- **Table.** The column is only 368 pt wide, so HSN/SAC is not a column: it is an 8 pt second line under each description that carries a code. Rate is amount plus short unit, and falls back to two lines in the 90 pt column when it does not fit.
- **Totals.** The right-anchored column is 210 pt wide; the amount in words sits full width under it (the design's 200 pt left column does not exist at this width).
- **Bottom row.** BANK DETAILS (inset 10.9 pt, as in the design), then THANK YOU above the signature image or the name, designation and computer-generated line; bottoms aligned. Canva's drawn signature is not reproduced.
- **Margins.** Left 162.91 pt (never less, whatever the preset: the band is x 38-155), right 64.1, top 56, bottom 60 plus the 16 pt footer reserve. The header starts where the stamp slot does (y 131.75) and flows; without a logo the supplier block starts there instead.
- **Elastic gaps:** top 75.75 to 40 pt, supplier-to-info down to 14 pt, info-to-table 61 to 28 pt; Letter also moves the band up.
+8
View File
@@ -27,6 +27,7 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"clipper-lib": "^6.4.2",
"pdfjs-dist": "6.4.299", "pdfjs-dist": "6.4.299",
"sass": "^1.105.1", "sass": "^1.105.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -2839,6 +2840,13 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clipper-lib": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/clipper-lib/-/clipper-lib-6.4.2.tgz",
"integrity": "sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ==",
"dev": true,
"license": "BSL"
},
"node_modules/clone": { "node_modules/clone": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+5 -1
View File
@@ -18,7 +18,10 @@
"fonts:verify": "node scripts/fonts/verify.mjs", "fonts:verify": "node scripts/fonts/verify.mjs",
"fonts:manifest": "node scripts/fonts/build-manifest.mjs", "fonts:manifest": "node scripts/fonts/build-manifest.mjs",
"logo:measure": "node scripts/logo/measure-placeholders.mjs", "logo:measure": "node scripts/logo/measure-placeholders.mjs",
"logo:sheet": "node scripts/logo/contact-sheet.mjs" "logo:sheet": "node scripts/logo/contact-sheet.mjs",
"decor:script-word": "node scripts/decor/script-word.mjs",
"thumbnails": "node scripts/thumbnails.mjs",
"templates:compare": "node scripts/templates/compare.mjs"
}, },
"dependencies": { "dependencies": {
"@carbon/icons-react": "^11.89.0", "@carbon/icons-react": "^11.89.0",
@@ -40,6 +43,7 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"clipper-lib": "^6.4.2",
"pdfjs-dist": "6.4.299", "pdfjs-dist": "6.4.299",
"sass": "^1.105.1", "sass": "^1.105.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+144
View File
@@ -152,6 +152,74 @@
} }
] ]
}, },
{
"family": "Montserrat",
"folder": "montserrat",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "Montserrat-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "ef9cf99f0175bef530b88934bd904fcf56f773cec6fd4dfb8ccaf7ce2bbd395e",
"fsType": 0,
"upm": 1000,
"ascent": 968,
"descent": -251,
"lineGap": 0,
"capHeight": 700,
"xHeight": 525,
"hasRupee": true,
"hasTabularNums": true
},
{
"file": "Montserrat-Medium.ttf",
"weight": 500,
"style": "normal",
"sha256": "cd161cba3a3cdcf68ad721920fcd164c87e7b7cc9e6bad510278c3fa378d91d2",
"fsType": 0,
"upm": 1000,
"ascent": 968,
"descent": -251,
"lineGap": 0,
"capHeight": 700,
"xHeight": 530,
"hasRupee": true,
"hasTabularNums": true
},
{
"file": "Montserrat-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "7fa93d7daa5c2f881a6727229c211782de6b76008fbd6346b3d7664e8fc83f43",
"fsType": 0,
"upm": 1000,
"ascent": 968,
"descent": -251,
"lineGap": 0,
"capHeight": 700,
"xHeight": 538,
"hasRupee": true,
"hasTabularNums": true
},
{
"file": "Montserrat-ExtraBold.ttf",
"weight": 800,
"style": "normal",
"sha256": "5cf0a23133ccce35f9e115b8dbbf90434201827bc2465d12f9d91bbf02cf9466",
"fsType": 0,
"upm": 1000,
"ascent": 968,
"descent": -251,
"lineGap": 0,
"capHeight": 700,
"xHeight": 542,
"hasRupee": true,
"hasTabularNums": true
}
]
},
{ {
"family": "Noto Sans Devanagari", "family": "Noto Sans Devanagari",
"folder": "noto-sans-devanagari", "folder": "noto-sans-devanagari",
@@ -227,6 +295,82 @@
"hasTabularNums": false "hasTabularNums": false
} }
] ]
},
{
"family": "Open Sans",
"folder": "open-sans",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "OpenSans-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "c53aceea2dcf5b4098099c0c4d0a061d17e178a049317b42a422b1a9f7f8eb59",
"fsType": 0,
"upm": 2048,
"ascent": 2189,
"descent": -600,
"lineGap": 0,
"capHeight": 1462,
"xHeight": 1096,
"hasRupee": false,
"hasTabularNums": true
},
{
"file": "OpenSans-Medium.ttf",
"weight": 500,
"style": "normal",
"sha256": "15862e9090bf116e51192e608c817b141b9bae15d3edd450c29377d98b2667af",
"fsType": 0,
"upm": 2048,
"ascent": 2189,
"descent": -600,
"lineGap": 0,
"capHeight": 1462,
"xHeight": 1096,
"hasRupee": false,
"hasTabularNums": true
},
{
"file": "OpenSans-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "27da758f4dcac9a65abe914c13b463b42982b9909bc65713424099f4810bd1e6",
"fsType": 0,
"upm": 2048,
"ascent": 2189,
"descent": -600,
"lineGap": 0,
"capHeight": 1462,
"xHeight": 1118,
"hasRupee": false,
"hasTabularNums": true
}
]
},
{
"family": "Poppins",
"folder": "poppins",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "Poppins-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "7e65201e9b79159e2300267cc885e16c8dcef2424cdfa09a29bfb0980a94a7ba",
"fsType": 0,
"upm": 1000,
"ascent": 1050,
"descent": -350,
"lineGap": 100,
"capHeight": 698,
"xHeight": 548,
"hasRupee": true,
"hasTabularNums": false
}
]
} }
] ]
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Montserrat.Git Project Authors (https://github.com/JulietaUla/Montserrat.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+92
View File
@@ -0,0 +1,92 @@
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+225
View File
@@ -0,0 +1,225 @@
// Generates src/pdf/decor/scriptWord.generated.ts: the word "invoice" as SVG outline paths (Serenity's
// vertical title) plus a hand-built swash. Run: npm run decor:script-word
//
// The outlines come from Ms Madi Regular (SIL OFL 1.1), read with fontkit at build time. The font file lives in
// scripts/decor/source/ for reproducibility only; it is never copied into public/fonts, never registered with
// react-pdf and never embedded as a font: the PDF carries filled paths, so this is art, not text. Canva's
// SimpleSerenityScript is not used or traced.
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ClipperLib from "clipper-lib";
import * as fontkit from "fontkit";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const FONT = path.join(ROOT, "scripts/decor/source/MsMadi-Regular.ttf");
const OUT = path.join(ROOT, "src/pdf/decor/scriptWord.generated.ts");
const WORD = "invoice";
/** Length of the word's ink along its reading direction, pt (Canva's word runs from y 780 to y 410). */
const INK_LENGTH = 362;
/**
* Ms Madi's strokes are heavier than Canva's hairline script at this size. The glyph contours are merged
* into one outline and shrunk by this much on every side (pt), which thins the strokes without a
* paper-coloured stroke trick at render time.
*/
const THIN_BY = 0.95;
/** Clipper works in integers: coordinates are multiplied by this. */
const K = 1000;
const r2 = (v) => Math.round(v * 100) / 100;
/** Flattened outline commands of the laid-out word, in font units (y up, origin on the baseline start). */
function wordCommands(font) {
const run = font.layout(WORD);
const cmds = [];
let x = 0;
run.glyphs.forEach((g, i) => {
const pos = run.positions[i];
for (const c of g.path.commands) {
const args = [];
for (let k = 0; k < c.args.length; k += 2) args.push(c.args[k] + x + pos.xOffset, c.args[k + 1] + pos.yOffset);
cmds.push({ command: c.command, args });
}
x += pos.xAdvance;
});
return cmds;
}
/**
* Rotates the word 90 degrees counter-clockwise so it reads bottom to top, scales it to INK_LENGTH and moves
* its bounding box to the origin (the shrink in `thinned` then re-origins it). In page space (y down) a font point (x, yUp) lands at (-yUp, -x).
*/
function rotate(cmds) {
const pts = cmds.flatMap((c) => c.args);
const xs = pts.filter((_, i) => i % 2 === 0);
const ys = pts.filter((_, i) => i % 2 === 1);
const inkLen = Math.max(...xs) - Math.min(...xs);
const s = INK_LENGTH / inkLen;
const map = (x, y) => [-y * s, -x * s];
const mapped = cmds.map((c) => {
const a = [];
for (let k = 0; k < c.args.length; k += 2) a.push(...map(c.args[k], c.args[k + 1]));
return { command: c.command, args: a };
});
const mx = mapped.flatMap((c) => c.args.filter((_, i) => i % 2 === 0));
const my = mapped.flatMap((c) => c.args.filter((_, i) => i % 2 === 1));
const [x0, y0] = [Math.min(...mx), Math.min(...my)];
const width = Math.max(...mx) - x0;
const height = Math.max(...my) - y0;
const shifted = mapped.map((c) => ({ command: c.command, args: c.args.map((v, i) => (i % 2 === 0 ? v - x0 : v - y0)) }));
return { cmds: shifted, width, height, scale: s };
}
/** Flattens the outline commands into closed polylines (pt). */
function flatten(cmds) {
const polys = [];
let cur = [];
let last = [0, 0];
const close = () => {
if (cur.length > 2) polys.push(cur);
cur = [];
};
for (const c of cmds) {
if (c.command === "moveTo") {
close();
cur = [c.args];
last = c.args;
} else if (c.command === "lineTo") {
cur.push(c.args);
last = c.args;
} else if (c.command === "quadraticCurveTo") {
const [cx, cy, x, y] = c.args;
for (let i = 1; i <= 10; i++) {
const t = i / 10;
const u = 1 - t;
cur.push([u * u * last[0] + 2 * u * t * cx + t * t * x, u * u * last[1] + 2 * u * t * cy + t * t * y]);
}
last = [x, y];
} else if (c.command === "bezierCurveTo") {
const [c1x, c1y, c2x, c2y, x, y] = c.args;
for (let i = 1; i <= 14; i++) {
const t = i / 14;
const u = 1 - t;
cur.push([
u * u * u * last[0] + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x,
u * u * u * last[1] + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y,
]);
}
last = [x, y];
} else if (c.command === "closePath") close();
}
close();
return polys;
}
/** Union of all contours, shrunk by THIN_BY, as one path. Returns { d, width, height, shiftX } with the box moved to the origin. */
function thinned(cmds) {
const subj = flatten(cmds).map((poly) => poly.map(([x, y]) => ({ X: Math.round(x * K), Y: Math.round(y * K) })));
const clipper = new ClipperLib.Clipper();
clipper.AddPaths(subj, ClipperLib.PolyType.ptSubject, true);
const merged = new ClipperLib.Paths();
clipper.Execute(ClipperLib.ClipType.ctUnion, merged, ClipperLib.PolyFillType.pftNonZero, ClipperLib.PolyFillType.pftNonZero);
const offset = new ClipperLib.ClipperOffset(2, 0.05 * K);
offset.AddPaths(merged, ClipperLib.JoinType.jtRound, ClipperLib.EndType.etClosedPolygon);
const eroded = new ClipperLib.Paths();
offset.Execute(eroded, -THIN_BY * K);
const clean = ClipperLib.Clipper.CleanPolygons(eroded, 0.04 * K);
const pts = clean.flat();
const x0 = Math.min(...pts.map((p) => p.X)) / K;
const y0 = Math.min(...pts.map((p) => p.Y)) / K;
const x1 = Math.max(...pts.map((p) => p.X)) / K;
const y1 = Math.max(...pts.map((p) => p.Y)) / K;
const d = clean
.filter((poly) => poly.length > 2)
.map((poly) => `M${poly.map((p) => `${r2(p.X / K - x0)} ${r2(p.Y / K - y0)}`).join("L")}Z`)
.join("");
// Where the word ends (its topmost point): the swash continues from here.
const top = pts.reduce((a, p) => (p.Y < a.Y ? p : a), pts[0]);
return { d, width: x1 - x0, height: y1 - y0, anchorX: top.X / K - x0 };
}
/** The swash spine is drawn this much wider than first designed, to match the 25 pt width of Canva's swash. */
const SWASH_X_SCALE = 1.4;
const bez = (p0, p1, p2, p3, t) => {
const u = 1 - t;
return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * p1[k] + 3 * u * t * t * p2[k] + t * t * t * p3[k]);
};
/**
* A tapered swash: a spine of cubic segments, thickened by a sine envelope into one filled outline.
* Own drawing, in a 28 x 270 pt box; it continues upward from the end of the word like Canva's does.
*/
function swash() {
const segs = [
[[19, 270], [-2, 222], [30, 176], [13, 130]],
[[13, 130], [-4, 86], [26, 52], [15, 14]],
[[15, 14], [11, 2], [22, -1], [24, 7]],
];
const spine = [];
segs.forEach((s, si) => {
for (let i = si === 0 ? 0 : 1; i <= 40; i++) { const q = bez(s[0], s[1], s[2], s[3], i / 40); spine.push([q[0] * SWASH_X_SCALE, q[1]]); }
});
const n = spine.length;
const left = [];
const right = [];
spine.forEach((p, i) => {
const a = spine[Math.max(0, i - 1)];
const b = spine[Math.min(n - 1, i + 1)];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
const nx = -(b[1] - a[1]) / len;
const ny = (b[0] - a[0]) / len;
const u = i / (n - 1);
const half = 0.25 + 1.25 * Math.pow(Math.sin(Math.PI * Math.min(1, u * 1.15)), 0.9);
left.push([p[0] + nx * half, p[1] + ny * half]);
right.push([p[0] - nx * half, p[1] - ny * half]);
});
const all = [...left, ...right.reverse()];
const minY = Math.min(...all.map((p) => p[1]));
const minX = Math.min(...all.map((p) => p[0]));
const maxX = Math.max(...all.map((p) => p[0]));
const maxY = Math.max(...all.map((p) => p[1]));
const pts = all.map(([x, y]) => [x - minX, y - minY]);
// The spine starts at the bottom of the box: that point meets the end of the word.
const anchorX = spine[0][0] - minX;
const d = `M${pts.map((p) => p.map(r2).join(" ")).join("L")}Z`;
return { d, width: r2(maxX - minX), height: r2(maxY - minY), anchorX: r2(anchorX) };
}
const font = fontkit.create(readFileSync(FONT));
const rotated = rotate(wordCommands(font));
const thin = thinned(rotated.cmds);
const word = { d: thin.d, width: thin.width, height: thin.height, anchorX: thin.anchorX };
const sw = swash();
const ts = `// Generated by \`npm run decor:script-word\` (scripts/decor/script-word.mjs). Do not edit by hand.
//
// Outline paths of the word "${WORD}" traced from Ms Madi Regular, rotated 90 degrees counter-clockwise (reads
// bottom to top), plus a hand-drawn swash. Ms Madi is Copyright 2018 The Ms Madi Project Authors
// (https://github.com/googlefonts/ms-madi), licensed under the SIL Open Font License 1.1 (scripts/decor/source/OFL-MsMadi.txt).
// The font itself is not bundled or registered: only these vector outlines are drawn, as art.
export interface ScriptArt {
/** SVG path data in a box of \`width\` x \`height\` pt starting at the origin (page space, y down). */
d: string;
width: number;
height: number;
/** x inside the box where the word ends (word) or the swash starts (swash): they are joined here. */
anchorX: number;
}
export const SCRIPT_WORD: ScriptArt = {
d: ${JSON.stringify(word.d)},
width: ${r2(word.width)},
height: ${r2(word.height)},
anchorX: ${r2(word.anchorX)},
};
export const SCRIPT_SWASH: ScriptArt = {
d: ${JSON.stringify(sw.d)},
width: ${sw.width},
height: ${sw.height},
anchorX: ${sw.anchorX},
};
`;
writeFileSync(OUT, ts);
console.log(`scriptWord.generated.ts: word ${r2(word.width)} x ${r2(word.height)} pt swash ${sw.width} x ${sw.height} pt`);
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Ms Madi Project Authors (https://github.com/googlefonts/ms-madi)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+11 -2
View File
@@ -136,7 +136,14 @@ async function main() {
for (const id of ids) { for (const id of ids) {
const slot = slotFor(id); const slot = slotFor(id);
const page = await renderPage(TEMPLATE_PDF[id]); const page = await renderPage(TEMPLATE_PDF[id]);
const L = slot.lockup; // The region covers the placeholder and, for a text-only one, its (larger) allowance.
const L = slot.textOnly
? { x: Math.min(slot.lockup.x, slot.allowance.x), y: Math.min(slot.lockup.y, slot.allowance.y), w: 0, h: 0 }
: slot.lockup;
if (slot.textOnly) {
L.w = Math.max(slot.lockup.x + slot.lockup.w, slot.allowance.x + slot.allowance.w) - L.x;
L.h = Math.max(slot.lockup.y + slot.lockup.h, slot.allowance.y + slot.allowance.h) - L.y;
}
const region = { const region = {
x: Math.max(0, Math.floor(L.x - PAD)), x: Math.max(0, Math.floor(L.x - PAD)),
y: Math.max(0, Math.floor(L.y - PAD)), y: Math.max(0, Math.floor(L.y - PAD)),
@@ -158,7 +165,7 @@ async function main() {
ctx.font = "bold 16px sans-serif"; ctx.font = "bold 16px sans-serif";
ctx.fillText("Logo placement sign-off sheet (Canva placeholder, then four test logos)", 8, 22); ctx.fillText("Logo placement sign-off sheet (Canva placeholder, then four test logos)", 8, 22);
ctx.font = "12px sans-serif"; ctx.font = "12px sans-serif";
ctx.fillText("Red outline = measured placeholder box, blue = mark box. Dashed = opt-in area (no Canva placeholder). Green = logo box as placed.", 8, 40); ctx.fillText("Red = measured placeholder box, blue = mark box, orange dashed = allowance of a text-only placeholder, red dashed = opt-in area (no Canva placeholder). Green = logo box as placed.", 8, 40);
const heads = ["Canva placeholder", ...logos.map((l) => `${l.label} (${l.aspect.toFixed(2)}:1, d ${l.density.toFixed(2)})`)]; const heads = ["Canva placeholder", ...logos.map((l) => `${l.label} (${l.aspect.toFixed(2)}:1, d ${l.density.toFixed(2)})`)];
ctx.font = "bold 11px sans-serif"; ctx.font = "bold 11px sans-serif";
heads.forEach((t, i) => ctx.fillText(t, LABEL_W + GUTTER + i * (cellW + GUTTER), HEAD_H - 6)); heads.forEach((t, i) => ctx.fillText(t, LABEL_W + GUTTER + i * (cellW + GUTTER), HEAD_H - 6));
@@ -182,6 +189,7 @@ async function main() {
const lines = [ const lines = [
slot.optIn ? "opt-in, off by default" : "Canva placeholder", slot.optIn ? "opt-in, off by default" : "Canva placeholder",
`lockup ${f(slot.lockup)}`, `lockup ${f(slot.lockup)}`,
...(slot.textOnly ? [`text-only: allowance ${f(slot.allowance)}`] : []),
`mark ${f(slot.mark)}`, `mark ${f(slot.mark)}`,
`density ${slot.lockupDensity.toFixed(2)} / ${slot.markDensity.toFixed(2)}`, `density ${slot.lockupDensity.toFixed(2)} / ${slot.markDensity.toFixed(2)}`,
`${slot.anchor}, ${slot.vAlign}${slot.darkSurface ? ", dark surface" : ""}`, `${slot.anchor}, ${slot.vAlign}${slot.darkSurface ? ", dark surface" : ""}`,
@@ -214,6 +222,7 @@ async function main() {
if (slot.optIn) box(ctx, slot.lockup, region, "#dd0000", true); if (slot.optIn) box(ctx, slot.lockup, region, "#dd0000", true);
else { else {
box(ctx, slot.lockup, region, "#dd0000"); box(ctx, slot.lockup, region, "#dd0000");
if (slot.textOnly) box(ctx, slot.allowance, region, "#e08a00", true);
if (slot.mark && slot.mark !== slot.lockup && slot.mark.w !== slot.lockup.w) box(ctx, slot.mark, region, "#0066cc"); if (slot.mark && slot.mark !== slot.lockup && slot.mark.w !== slot.lockup.w) box(ctx, slot.mark, region, "#0066cc");
} }
c.restore(); c.restore();
+28 -3
View File
@@ -39,6 +39,30 @@ const SEARCH_RECTS = {
13: { id: "citrus-split", rect: { x: 325, y: 65, w: 165, h: 55 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false }, 13: { id: "citrus-split", rect: { x: 325, y: 65, w: 165, h: 55 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
}; };
/*
* Allowances for text-only placeholders: the placeholder's own box (46.8 x 9 pt, 82.5 x 14.8 pt) would
* make a real logo unreadable, so the target box comes from the template docs and keeps the text's
* anchor edge.
* monolith docs/templates/03-monolith.md "Mapping to Voiced fields": 'CAVEIN' becomes the vendor logo,
* right-aligned, at most 28 pt tall so the 3-line stack keeps its 21 pt pitch (a 3:1 PNG is
* 84 x 28); up to 160 wide, ending at the same right edge (x 560); top at the text's cap top.
* tangerine-ledger docs/templates/11-tangerine-ledger.md "Mapping": 'Rimberio' becomes the vendor logo, maxHeight 36
* at x 59.5, y about 62; up to 220 wide, left edge kept.
*/
const ALLOWANCES = {
monolith: { w: 160, h: 28, top: 54.75, vAlign: "top" },
"tangerine-ledger": { w: 220, h: 36, top: 62, vAlign: "middle" },
};
const r2 = (v) => Math.round(v * 100) / 100;
/** The allowance box for a text-only slot: its width and height from the docs, anchored where the text is. */
function allowanceBox(id, lockup, anchor) {
const a = ALLOWANCES[id];
const x = anchor === "right" ? lockup.x + lockup.w - a.w : lockup.x;
return { x: r2(x), y: a.top, w: a.w, h: a.h };
}
const TEMPLATE_IDS = [ const TEMPLATE_IDS = [
"classic", "marble", "highlighter", "monolith", "linea", "monogram", "serenity", "crimson-grid", "classic", "marble", "highlighter", "monolith", "linea", "monogram", "serenity", "crimson-grid",
"cobalt-stripe", "slate-band", "teal-swoosh", "tangerine-ledger", "purple-pop", "citrus-split", "cobalt-stripe", "slate-band", "teal-swoosh", "tangerine-ledger", "purple-pop", "citrus-split",
@@ -125,8 +149,6 @@ function firstColumnGroup(mask, w, h, box) {
return null; return null;
} }
const r2 = (v) => Math.round(v * 100) / 100;
function toPt(rect, box) { function toPt(rect, box) {
return { return {
x: r2(rect.x + box.minX / SCALE), x: r2(rect.x + box.minX / SCALE),
@@ -172,6 +194,7 @@ async function main() {
} }
const rounded = (v) => Math.round(v * 1000) / 1000; const rounded = (v) => Math.round(v * 1000) / 1000;
const textOnly = spec.kind === "text";
slots[spec.id] = { slots[spec.id] = {
page: { w: PAGE.w, h: PAGE.h }, page: { w: PAGE.w, h: PAGE.h },
mark: markBox, mark: markBox,
@@ -179,8 +202,10 @@ async function main() {
markDensity: rounded(markDensity), markDensity: rounded(markDensity),
lockupDensity: rounded(lockupDensity), lockupDensity: rounded(lockupDensity),
anchor: spec.anchor, anchor: spec.anchor,
vAlign: spec.vAlign, vAlign: textOnly ? ALLOWANCES[spec.id].vAlign : spec.vAlign,
darkSurface: spec.dark, darkSurface: spec.dark,
textOnly,
allowance: textOnly ? allowanceBox(spec.id, lockupBox, spec.anchor) : lockupBox,
}; };
rows.push({ n, id: spec.id, mark: markBox, lockup: lockupBox, markDensity, lockupDensity, anchor: spec.anchor, dark: spec.dark, bg }); rows.push({ n, id: spec.id, mark: markBox, lockup: lockupBox, markDensity, lockupDensity, anchor: spec.anchor, dark: spec.dark, bg });
+88
View File
@@ -0,0 +1,88 @@
// 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.
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 SCALE = 1.6;
const PDF_NO = { marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6 };
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 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";
async function main() {
mkdirSync(OUT, { recursive: true });
const { createCanvas } = await loadCanvas();
const r = await loadRenderer();
r.ensureSetup();
const side = async (left, right, file) => {
const gap = 12;
const c = createCanvas(left.width + right.width + gap, Math.max(left.height, right.height));
const ctx = c.getContext("2d");
ctx.fillStyle = "#888888";
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(left, 0, 0);
ctx.drawImage(right, left.width + gap, 0);
writeFileSync(path.join(OUT, file), c.toBuffer("image/png"));
};
for (const id of targets) {
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 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" } : {}) }));
const sample = (over = {}) => ({ name: `s-${id}`, items: items(over.registered !== false), signature: true, invoice: { poNumber: "", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, ...over });
const render = async (spec, size = "A4") => {
const prefs = r.prefsFor(size, { templateId: id });
return r.renderFixture(spec, prefs);
};
const withLogo = await render(sample({ logo: "real" }));
await side(canva, await rasterize(withLogo.result.bytes, 1, SCALE), `compare-${id}.png`);
const noLogo = await render(sample({ registered: false, logo: "none" }));
await side(canva, await rasterize(noLogo.result.bytes, 1, SCALE), `compare-${id}-nologo.png`);
if (numbers) {
const a = await textItems(canvaBytes);
const b = await textItems(withLogo.result.bytes);
console.log(`--- ${id}: Canva (${a.length}) vs ours (${b.length}); baseline y, x`);
for (const it of a) console.log(` canva ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`);
for (const it of b) console.log(` ours ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`);
}
if (id === "linea") {
// The opt-in logo band (off by default so Linea keeps matching format.pdf).
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"]) {
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"));
}
const sixty = await render({ name: "s60", items: 60, signature: true });
for (let p = 1; p <= Math.min(2, sixty.result.pages); p++) writeFileSync(path.join(OUT, `${id}-A4-60rows-p${p}.png`), (await rasterize(sixty.result.bytes, p, SCALE)).toBuffer("image/png"));
const letter = await render(sample({ logo: "real" }), "LETTER");
writeFileSync(path.join(OUT, `${id}-Letter-tax.png`), (await rasterize(letter.result.bytes, 1, SCALE)).toBuffer("image/png"));
const long = await render({
name: "long",
items: 4,
signature: true,
logo: "real",
vendor: { vendorAddress: LONG, vendorEmail: `${TOKEN}@example-studios-india.co.in`, vendorName: TOKEN },
invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` },
});
writeFileSync(path.join(OUT, `${id}-A4-longtext.png`), (await rasterize(long.result.bytes, 1, SCALE)).toBuffer("image/png"));
console.log(`${id}: pages ${withLogo.result.pages}/${sixty.result.pages}/${letter.result.pages}/${long.result.pages}; audit issues`, [withLogo, noLogo, sixty, letter, long].map((x) => x.result.issues.length).join("/"));
for (const x of [withLogo, noLogo, sixty, letter, long]) for (const i of x.result.issues) console.log(" ", i.severity, i.code, `p${i.page}`, i.message.slice(0, 160));
}
}
await main();
+4
View File
@@ -0,0 +1,4 @@
// Bundled by scripts/templates/lib.mjs so plain node can render templates (the PDF code is TypeScript + JSX).
export { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture, makeItems } from "../../src/pdf/templates/harness";
export type { FixtureSpec } from "../../src/pdf/templates/harness";
export { TEMPLATES } from "../../src/pdf/templates/registry";
+82
View File
@@ -0,0 +1,82 @@
// Shared by the template scripts (thumbnails, compare): bundles the TypeScript renderer for node and
// rasterises PDFs with pdf.js + @napi-rs/canvas.
import { build } from "esbuild";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { ROOT } from "../logo/lib.mjs";
export { ROOT };
export const CANVA_DIR = path.join(ROOT, "templates", "Modern Neutral Invoice Template");
/**
* Bundles scripts/templates/entry.ts into node_modules/.cache (so bare imports such as react resolve from
* node_modules) and imports it. Node packages stay external; only the app's own sources are bundled.
*/
export async function loadRenderer() {
const dir = path.join(ROOT, "node_modules", ".cache", "voiced-templates");
mkdirSync(dir, { recursive: true });
const outfile = path.join(dir, `bundle-${Date.now()}.mjs`);
await build({
entryPoints: [path.join(ROOT, "scripts/templates/entry.ts")],
outfile,
bundle: true,
packages: "external",
platform: "node",
format: "esm",
jsx: "automatic",
logLevel: "error",
});
return import(pathToFileURL(outfile).href);
}
export async function loadCanvas() {
try {
return await import("@napi-rs/canvas");
} catch {
console.error("@napi-rs/canvas is not installed (it is an optional dev dependency); run npm install");
process.exit(1);
}
}
/** Rasterises page `pageNo` of PDF bytes at `scale` on a white background; returns the canvas. */
export async function rasterize(bytes, pageNo = 1, scale = 1.6) {
const { createCanvas } = await loadCanvas();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const task = pdfjs.getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true, isEvalSupported: false, verbosity: 0 });
const doc = await task.promise;
try {
const page = await doc.getPage(pageNo);
const vp = page.getViewport({ scale });
const canvas = createCanvas(Math.ceil(vp.width), Math.ceil(vp.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, canvas, viewport: vp }).promise;
return canvas;
} finally {
await task.destroy();
}
}
/** Text items of page 1 with baselines measured from the page top, for numeric comparison. */
export async function textItems(bytes, pageNo = 1) {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const task = pdfjs.getDocument({ data: new Uint8Array(bytes), useSystemFonts: false, disableFontFace: true, isEvalSupported: false, verbosity: 0 });
const doc = await task.promise;
try {
const page = await doc.getPage(pageNo);
const vp = page.getViewport({ scale: 1 });
const content = await page.getTextContent();
return content.items
.filter((it) => "str" in it && it.str.trim() !== "")
.map((it) => {
const m = vp.transform;
const [px, py] = [it.transform[4], it.transform[5]];
const [x, y] = [m[0] * px + m[2] * py + m[4], m[1] * px + m[3] * py + m[5]];
return { str: it.str, x: Math.round(x * 10) / 10, baseline: Math.round(y * 10) / 10, w: Math.round(it.width * 10) / 10, size: Math.round(Math.hypot(it.transform[0], it.transform[1]) * 10) / 10 };
});
} finally {
await task.destroy();
}
}
+46
View File
@@ -0,0 +1,46 @@
// Renders every registered template with one fixed sample invoice into public/templates/<id>.png, the
// static thumbnails of the template picker. Run: npm run thumbnails (the PNGs are committed).
// The sample is the harness's tax invoice with The sample vendor's logo; the picker says that thumbnails use sample data.
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { ROOT, loadRenderer, rasterize } from "./templates/lib.mjs";
const OUT = path.join(ROOT, "public", "templates");
const WIDTH = 420;
const A4_WIDTH_PT = 595.28;
const ITEMS = [
["Voice-over narration", 4, 1500],
["Audio mastering", 2, 800],
].map(([description, quantity, rate], i) => ({
id: i + 1,
description,
mode: "rate",
rate,
unit: "hour",
quantity,
amount: 0,
sortOrder: i,
hsnSac: "998397",
}));
const SAMPLE = {
name: "thumbnail",
items: ITEMS,
logo: "real",
signature: false,
// A leaner supplier block keeps the whole design (closing block included) on the thumbnail's one page.
vendor: { vendorEmail: "", vendorPhone: "", vendorPan: "" },
invoice: { poNumber: "", notes: "" },
};
const r = await loadRenderer();
r.ensureSetup();
mkdirSync(OUT, { recursive: true });
for (const id of Object.keys(r.TEMPLATES)) {
const rendered = await r.renderFixture(SAMPLE, r.prefsFor("A4", { templateId: id }));
const canvas = await rasterize(rendered.result.bytes, 1, WIDTH / A4_WIDTH_PT);
const file = path.join(OUT, `${id}.png`);
writeFileSync(file, canvas.toBuffer("image/png"));
console.log(`${id}: ${canvas.width} x ${canvas.height} px, ${rendered.result.pages} page(s), ${rendered.result.issues.length} audit issue(s)`);
}
+39 -6
View File
@@ -1,9 +1,12 @@
import { useState, type ReactNode } from "react"; import { useState } from "react";
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react"; import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch, Toggle } from "@carbon/react";
import { Catalog } from "@carbon/icons-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { Settings } from "../lib/types"; import type { Settings } from "../lib/types";
import type { MarginPreset, PageId } from "../pdf/engine/geometry"; import type { MarginPreset, PageId } from "../pdf/engine/geometry";
import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs"; import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs";
import { catalogEntry } from "../pdf/templates/catalog";
import TemplatePicker from "./TemplatePicker";
const PAGES: { id: PageId; label: string }[] = [ const PAGES: { id: PageId; label: string }[] = [
{ id: "A4", label: "A4" }, { id: "A4", label: "A4" },
@@ -24,17 +27,17 @@ interface Props {
/** Called after the default was saved so the app reloads its settings. */ /** Called after the default was saved so the app reloads its settings. */
onSaved: () => void; onSaved: () => void;
disabled?: boolean; disabled?: boolean;
/** Where a TemplatePicker goes once there is more than one template. */
templateSlot?: ReactNode;
} }
/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */ /** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) { export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled }: Props) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const saved = prefsToJson(parsePrefs(settings.renderPrefs)); const saved = prefsToJson(parsePrefs(settings.renderPrefs));
const isDefault = prefsToJson(prefs) === saved; const isDefault = prefsToJson(prefs) === saved;
const template = catalogEntry(prefs.templateId);
const saveDefault = async () => { const saveDefault = async () => {
setSaving(true); setSaving(true);
@@ -52,7 +55,12 @@ export default function PageSetupControls({ prefs, onChange, settings, onSaved,
return ( return (
<div className="voiced-page-setup"> <div className="voiced-page-setup">
<div className="voiced-page-setup__row"> <div className="voiced-page-setup__row">
{templateSlot} <div className="voiced-page-setup__field">
<span className="cds--label">Template</span>
<Button kind="tertiary" size="md" renderIcon={Catalog} disabled={disabled} onClick={() => setPickerOpen(true)}>
{template.name}
</Button>
</div>
<div className="voiced-page-setup__field"> <div className="voiced-page-setup__field">
<span className="cds--label">Page size</span> <span className="cds--label">Page size</span>
<ContentSwitcher <ContentSwitcher
@@ -80,6 +88,31 @@ export default function PageSetupControls({ prefs, onChange, settings, onSaved,
</Select> </Select>
</div> </div>
</div> </div>
{template.logoOptIn ? (
<div className="voiced-page-setup__row voiced-page-setup__extra">
<Toggle
id="template-logo-show"
size="sm"
labelText="Logo"
labelA="Hidden"
labelB="Shown above the title"
toggled={prefs.logo?.show === true}
disabled={disabled}
onToggle={(on) => onChange({ ...prefs, logo: { ...prefs.logo, show: on } })}
/>
</div>
) : null}
<TemplatePicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
selectedId={template.id}
onSelect={(id) => onChange({ ...prefs, templateId: id })}
isDefault={isDefault}
saving={saving}
error={error}
onSaveDefault={() => void saveDefault()}
disabled={disabled}
/>
<div className="voiced-page-setup__footer"> <div className="voiced-page-setup__footer">
{saving ? ( {saving ? (
<InlineLoading status="active" description="Saving…" /> <InlineLoading status="active" description="Saving…" />
+77
View File
@@ -0,0 +1,77 @@
import { Button, ClickableTile, ComposedModal, InlineLoading, ModalBody, ModalFooter, ModalHeader, Tag } from "@carbon/react";
import { CheckmarkFilled } from "@carbon/icons-react";
import { FAMILY_LABELS, TEMPLATE_CATALOG, catalogEntry } from "../pdf/templates/catalog";
interface Props {
open: boolean;
onClose: () => void;
/** The template of the invoice being edited; an unknown id shows as Classic, which is what renders. */
selectedId: string;
/** Applies at once to the live preview. */
onSelect: (id: string) => void;
/** The selection is already the saved default. */
isDefault: boolean;
saving: boolean;
error: string | null;
onSaveDefault: () => void;
/** Issued invoices keep the template they were issued with. */
disabled?: boolean;
}
/** Pick a template from static thumbnails drawn with sample data; the choice previews immediately. */
export default function TemplatePicker({ open, onClose, selectedId, onSelect, isDefault, saving, error, onSaveDefault, disabled }: Props) {
const current = catalogEntry(selectedId).id;
return (
<ComposedModal open={open} onClose={onClose} size="lg" aria-label="Choose a template">
<ModalHeader title="Choose a template" label="Invoice layout" />
<ModalBody hasScrollingContent>
<p className="voiced-muted voiced-template-picker__note">
Thumbnails use sample data. Your choice shows in the preview straight away and is frozen into each invoice when it is issued.
</p>
<div className="voiced-template-grid" role="list">
{TEMPLATE_CATALOG.map((t) => {
const selected = t.id === current;
return (
<div key={t.id} role="listitem">
<ClickableTile
className={`voiced-template-tile${selected ? " voiced-template-tile--selected" : ""}`}
aria-label={`${t.name}${selected ? ", selected" : ""}`}
onClick={() => !disabled && onSelect(t.id)}
>
<img className="voiced-template-tile__thumb" src={t.thumbnail} alt={`${t.name} template with sample data`} loading="lazy" />
<div className="voiced-template-tile__head">
<span className="voiced-template-tile__name">{t.name}</span>
{selected ? <CheckmarkFilled size={20} className="voiced-template-tile__check" aria-label="Selected" /> : null}
</div>
<div className="voiced-template-tile__tags">
<Tag size="sm" type="gray">
{FAMILY_LABELS[t.family]}
</Tag>
{t.badge ? (
<Tag size="sm" type="blue">
{t.badge}
</Tag>
) : null}
</div>
</ClickableTile>
</div>
);
})}
</div>
</ModalBody>
<ModalFooter>
<span className="voiced-muted voiced-template-picker__status">{error ?? (disabled ? "This invoice is issued; its template is fixed." : "")}</span>
{saving ? (
<InlineLoading status="active" description="Saving…" />
) : (
<Button kind="secondary" disabled={isDefault || disabled} onClick={onSaveDefault}>
{isDefault ? "Saved as default" : "Save as default"}
</Button>
)}
<Button kind="primary" onClick={onClose}>
Done
</Button>
</ModalFooter>
</ComposedModal>
);
}
+30 -11
View File
@@ -3,7 +3,9 @@ import type { ReactNode } from "react";
import type { PageFrame } from "./engine/geometry"; import type { PageFrame } from "./engine/geometry";
import { chromeHeight } from "./engine/metrics"; import { chromeHeight } from "./engine/metrics";
import type { RenderModel } from "./model/build"; import type { RenderModel } from "./model/build";
import type { TemplateDefinition } from "./templates/contract";
import { AvailableWidthProvider } from "./blocks/AvailableWidth"; import { AvailableWidthProvider } from "./blocks/AvailableWidth";
import type { DecorItem } from "./blocks/FirstPageHeader";
import { PageChrome } from "./blocks/PageChrome"; import { PageChrome } from "./blocks/PageChrome";
/** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */ /** Height the footer strip takes inside the bottom padding: the text line, a 6 pt gap and the rule. */
@@ -11,13 +13,10 @@ export const FOOTER_RESERVE = 24;
/** Room above the content on pages 2+ for the continuation band, inside the top margin. */ /** Room above the content on pages 2+ for the continuation band, inside the top margin. */
export const CONTINUATION_RESERVE = 18; export const CONTINUATION_RESERVE = 18;
const CHROME_INK = "#6f6f6f";
const RULE = "#e0e0e0";
const FOOTER_LEFT_WIDTH = 170;
export interface InvoicePageProps { export interface InvoicePageProps {
frame: PageFrame; frame: PageFrame;
model: RenderModel; model: RenderModel;
template: TemplateDefinition;
children?: ReactNode; children?: ReactNode;
} }
@@ -26,25 +25,45 @@ export interface InvoicePageProps {
* anywhere else), the flow is the template's, and the chrome (footer, continuation band) is added after * anywhere else), the flow is the template's, and the chrome (footer, continuation band) is added after
* it as fixed page-level boxes. No lineHeight here: it would reach the render-prop page-number text. * it as fixed page-level boxes. No lineHeight here: it would reach the render-prop page-number text.
*/ */
export function InvoicePage({ frame, model, children }: InvoicePageProps) { export function InvoicePage({ frame, model, template, children }: InvoicePageProps) {
const { pad } = frame; const { pad } = frame;
const { chrome, decor } = template;
const marginBottom = pad.bottom - frame.footerReserve; const marginBottom = pad.bottom - frame.footerReserve;
const ruleY = frame.page.height - marginBottom - chromeHeight("footer") - 6.6; const ruleY = frame.page.height - marginBottom - chromeHeight(chrome.role ?? "footer") - 6.6;
const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`; const bandLeft = `${model.docTitle} ${model.meta.number} · ${model.meta.date}`;
const items: DecorItem[] = [...(decor.everyPage?.(frame) ?? [])];
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 }} /> });
}
// The footer sits inside the template's text column, which may be narrower than the page content width.
const inset = chrome.inset ?? { left: 0, right: 0 };
const chromeFrame: PageFrame = {
...frame,
pad: { ...pad, left: pad.left + inset.left, right: pad.right + inset.right },
contentWidth: frame.contentWidth - inset.left - inset.right,
};
return ( return (
<Page <Page
size={frame.page.size} size={frame.page.size}
style={{ paddingTop: pad.top, paddingRight: pad.right, paddingBottom: pad.bottom, paddingLeft: pad.left, color: "#161616" }} style={{
paddingTop: pad.top,
paddingRight: pad.right,
paddingBottom: pad.bottom,
paddingLeft: pad.left,
color: decor.ink,
...(decor.background ? { backgroundColor: decor.background } : {}),
}}
> >
<AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider> <AvailableWidthProvider width={frame.contentWidth}>{children}</AvailableWidthProvider>
<PageChrome <PageChrome
frame={frame} frame={chromeFrame}
footerLeft={model.supplier.name} footerLeft={model.supplier.name}
footerLeftWidth={FOOTER_LEFT_WIDTH} footerLeftWidth={chrome.leftWidth}
footerCenter={model.footerText} footerCenter={model.footerText}
color={CHROME_INK} role={chrome.role}
color={chrome.color}
band={{ left: bandLeft, right: "continued" }} band={{ left: bandLeft, right: "continued" }}
decor={[{ x: pad.left, y: ruleY, width: frame.contentWidth, height: 0.6, children: <View style={{ height: 0.6, backgroundColor: RULE }} /> }]} decor={items}
/> />
</Page> </Page>
); );
+1 -1
View File
@@ -39,7 +39,7 @@ export function InvoicePdf({ model, frame, template, logo, prefs, onRender }: In
modificationDate={date} modificationDate={date}
onRender={onRender as never} onRender={onRender as never}
> >
<InvoicePage frame={frame} model={model}> <InvoicePage frame={frame} model={model} template={template}>
<Layout model={model} frame={frame} logo={logo} prefs={prefs} /> <Layout model={model} frame={frame} logo={logo} prefs={prefs} />
</InvoicePage> </InvoicePage>
</Document> </Document>
+18 -3
View File
@@ -17,6 +17,8 @@ export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLi
/** Width to fit; defaults to the available width of the enclosing cell. */ /** Width to fit; defaults to the available width of the enclosing cell. */
width?: number; width?: number;
minSize?: number; minSize?: number;
/** Pin the single-line box to the target width, so `align` (right, centre) works. Chunked mode aligns the chunk row as a whole. */
fill?: boolean;
/** For tests and audits only. */ /** For tests and audits only. */
onFit?: (info: IdTextInfo) => void; onFit?: (info: IdTextInfo) => void;
} }
@@ -27,7 +29,7 @@ export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLi
* it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction * it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction
* joins them back into the original string). * joins them back into the original string).
*/ */
export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTextProps) { export function IdText({ role, children, width, minSize, fill, onFit, ...rest }: IdTextProps) {
const avail = useAvailableWidth(); const avail = useAvailableWidth();
const target = width ?? avail; const target = width ?? avail;
const token = roleToken(role); const token = roleToken(role);
@@ -35,7 +37,11 @@ export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTex
if (fit.fits) { if (fit.fits) {
onFit?.({ ...fit, mode: "single", chunks: [children] }); onFit?.({ ...fit, mode: "single", chunks: [children] });
return <Txt {...rest} role={{ ...token, size: fit.size }}>{children}</Txt>; return (
<Txt {...rest} role={{ ...token, size: fit.size }} {...(fill ? { width: target } : {})}>
{children}
</Txt>
);
} }
const sized: TypeToken = { ...token, size: fit.size }; const sized: TypeToken = { ...token, size: fit.size };
@@ -53,7 +59,16 @@ export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTex
} }
onFit?.({ ...fit, mode: "chunked", chunks }); onFit?.({ ...fit, mode: "chunked", chunks });
return ( return (
<View style={{ flexDirection: "row", flexWrap: "wrap", width: target, minWidth: target, maxWidth: target } as never}> <View
style={{
flexDirection: "row",
flexWrap: "wrap",
width: target,
minWidth: target,
maxWidth: target,
...(rest.align === "right" ? { justifyContent: "flex-end" } : rest.align === "center" ? { justifyContent: "center" } : {}),
} as never}
>
{chunks.map((c, i) => ( {chunks.map((c, i) => (
<Txt key={i} {...rest} align={undefined} role={sized}> <Txt key={i} {...rest} align={undefined} role={sized}>
{c} {c}
+37 -12
View File
@@ -10,10 +10,14 @@ import { pdfFlags } from "./pdfProps";
import { Cell, Row } from "./Row"; import { Cell, Row } from "./Row";
import { Txt } from "./Txt"; import { Txt } from "./Txt";
/** A cell: one string, or several (one Txt each, stacked), for multi-line values. */ /** One line of a cell: text in the table's role, or in a role of its own (a small sub-line under a description). */
export type TableCellValue = string | string[]; export type CellPart = string | { text: string; role: RoleName | TypeToken };
/** A cell: one string, or several parts (one Txt each, stacked), for multi-line values. */
export type TableCellValue = string | CellPart[];
const cellParts = (v: TableCellValue): string[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]); const cellParts = (v: TableCellValue): CellPart[] => (Array.isArray(v) ? (v.length ? v : [""]) : [v]);
const partText = (p: CellPart) => (typeof p === "string" ? p : p.text);
const partRole = (p: CellPart, role: RoleName | TypeToken) => (typeof p === "string" ? role : p.role);
export interface TableRoles { export interface TableRoles {
head: RoleName | TypeToken; head: RoleName | TypeToken;
@@ -30,6 +34,8 @@ export interface TableLook {
/** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */ /** Extra right padding per column, pt (e.g. keeps description text off the next column). Counted in widths. */
padRight?: number[]; padRight?: number[];
faceOverrides?: Readonly<Record<string, string>>; faceOverrides?: Readonly<Record<string, string>>;
/** Height of the `headerTail` TableRows draws under the header row (a rule and its spacing); counted in the header height. */
headerTailH?: number;
} }
export interface ItemsTablePlanInput extends TableLook { export interface ItemsTablePlanInput extends TableLook {
@@ -57,12 +63,14 @@ export interface ItemsTablePlan {
} }
function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } { function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellValue, inner: number, look: TableLook): { lines: number; height: number } {
const t = roleToken(role);
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
let lines = 0; let lines = 0;
for (const text of cellParts(value)) { let height = 0;
for (const part of cellParts(value)) {
const t = roleToken(partRole(part, role));
if (t.leading === undefined) throw new PlanError("table roles need a leading", "invalid-plan");
const text = partText(part);
if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan"); if (/[\n\t]/.test(text)) throw new PlanError("table cells must not contain \\n or \\t", "invalid-plan");
lines += Math.max( const n = Math.max(
1, 1,
m.wrapLines(drawnText(t, text), inner, { m.wrapLines(drawnText(t, text), inner, {
face: resolveFace(t.face, look.faceOverrides), face: resolveFace(t.face, look.faceOverrides),
@@ -72,8 +80,10 @@ function cellHeight(m: Measurer, role: RoleName | TypeToken, value: TableCellVal
letterSpacing: (t.tracking ?? 0) * t.size, letterSpacing: (t.tracking ?? 0) * t.size,
}).length, }).length,
); );
lines += n;
height += n * t.leading;
} }
return { lines, height: lines * t.leading }; return { lines, height };
} }
/** /**
@@ -100,7 +110,7 @@ export function planItemsTable(input: ItemsTablePlanInput): ItemsTablePlan {
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 * padY + border };
}; };
const headH = heightOf(roles.head, header).height; const headH = heightOf(roles.head, header).height + (input.headerTailH ?? 0);
const measured = rows.map((r) => heightOf(roles.body, r)); const measured = rows.map((r) => heightOf(roles.body, r));
const rowHeights = measured.map((r) => r.height); const rowHeights = measured.map((r) => r.height);
@@ -134,6 +144,12 @@ export interface TableRowsProps extends TableLook {
borderColor?: string; borderColor?: string;
/** Text colour of the header cells. */ /** Text colour of the header cells. */
headColor?: string; headColor?: string;
/**
* Drawn under the header row inside the same fixed group, so it repeats with the header on every page
* (a rule and the gap below it: nothing under a repeated header may depend on a first-page-only margin).
* Pass the same height as `headerTailH` to planItemsTable.
*/
headerTail?: ReactNode;
} }
/** /**
@@ -141,7 +157,7 @@ export interface TableRowsProps extends TableLook {
* `wrap={false}` row per item. The container has no padding or border; those go on the rows, because * `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. * 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, ...look }: TableRowsProps) { export function TableRows({ columns, gap, width, header, rows, roles, align = [], tailH, tail, headerStyle, rowStyle, borderColor, headColor, headerTail, ...look }: TableRowsProps) {
const padX = look.padX ?? 0; const padX = look.padX ?? 0;
const padY = look.padY ?? 4; const padY = look.padY ?? 4;
const border = look.border ?? 0; const border = look.border ?? 0;
@@ -154,8 +170,8 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
values.map((v, i) => ( values.map((v, i) => (
<Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}> <Cell key={i} padding={{ top: padY, bottom: padY, left: padX, right: padX + (look.padRight?.[i] ?? 0) }}>
{cellParts(v).map((part, li) => ( {cellParts(v).map((part, li) => (
<Txt key={li} role={role} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}> <Txt key={li} role={partRole(part, role)} color={color} width={Math.max(0, widths[i] - 2 * padX - (look.padRight?.[i] ?? 0))} align={align[i]} faceOverrides={look.faceOverrides}>
{part} {partText(part)}
</Txt> </Txt>
))} ))}
</Cell> </Cell>
@@ -164,9 +180,18 @@ export function TableRows({ columns, gap, width, header, rows, roles, align = []
return ( return (
<AvailableWidthProvider width={width}> <AvailableWidthProvider width={width}>
<View> <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)}
</Row>
{headerTail}
</View>
) : (
<Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}> <Row id={TABLE_HEAD_ID} widths={columns} gap={gap} width={width} fixed style={cellStyle(headerStyle)}>
{cells(roles.head, header, headColor)} {cells(roles.head, header, headColor)}
</Row> </Row>
)}
{rows.map((r, i) => ( {rows.map((r, i) => (
<Row <Row
key={i} key={i}
+1 -1
View File
@@ -28,7 +28,7 @@ export function SafeTxt({ children, width, align, ...rest }: SafeTxtProps) {
); );
if (widest > w + 1e-6) { if (widest > w + 1e-6) {
return ( return (
<IdText {...rest} width={w}> <IdText {...rest} width={w} {...(align ? { align, fill: true } : {})}>
{children} {children}
</IdText> </IdText>
); );
+86 -25
View File
@@ -32,20 +32,20 @@ const INK_SUB = "#393939";
const FINE = "#6f6f6f"; const FINE = "#6f6f6f";
const SIGNATURE_RULE = "#8d8d8d"; const SIGNATURE_RULE = "#8d8d8d";
function Gstin({ model, party = "supplier", role = "body", color }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string }) { function Gstin({ model, party = "supplier", role = "body", color, align }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string; align?: "left" | "right" }) {
const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin; const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin;
if (!value) return null; if (!value) return null;
return ( return (
<View id={`${SLOT_ID.gstin}-${party}`}> <View id={`${SLOT_ID.gstin}-${party}`}>
<IdText role={role} color={color}>{`GSTIN: ${value}`}</IdText> <IdText role={role} color={color} {...(align ? { align, fill: true } : {})}>{`GSTIN: ${value}`}</IdText>
</View> </View>
); );
} }
function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) { function PlaceOfSupply({ model, role = "body", color, align }: { model: RenderModel; role?: Role; color?: string; align?: "left" | "right" }) {
if (!model.client.posLabel) return null; if (!model.client.posLabel) return null;
return ( return (
<SafeTxt id={SLOT_ID.pos} role={role} color={color}> <SafeTxt id={SLOT_ID.pos} role={role} color={color} align={align}>
{`Place of Supply: ${model.client.posLabel}`} {`Place of Supply: ${model.client.posLabel}`}
</SafeTxt> </SafeTxt>
); );
@@ -62,26 +62,50 @@ export interface TaxRowsProps {
role?: Role; role?: Role;
amountWidth: number; amountWidth: number;
rowStyle?: Record<string, unknown>; rowStyle?: Record<string, unknown>;
/** Label text; default "CGST (9%)". */
format?: (label: string, rateText: string | undefined) => string;
labelAlign?: "left" | "right";
/** Space between the label and the amount column. */
labelGap?: number;
color?: string;
} }
/** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */ /** 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 }: TaxRowsProps) { function TaxRows({ model, role = "body", amountWidth, rowStyle, format, labelAlign, labelGap = 0, color }: TaxRowsProps) {
const w = useAvailableWidth(); const w = useAvailableWidth();
if (model.totals.taxRows.length === 0) return null; if (model.totals.taxRows.length === 0) return null;
const text = format ?? ((label: string, rate: string | undefined) => (rate ? `${label} (${rate})` : label));
return ( return (
<View id={SLOT_ID.taxRows}> <View id={SLOT_ID.taxRows}>
{model.totals.taxRows.map((r, i) => ( {model.totals.taxRows.map((r, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} width={w} style={rowStyle}> <Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
<Txt role={role} width={w - amountWidth}>{r.rateText ? `${r.label} (${r.rateText})` : r.label}</Txt> <Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
<Txt role={role} width={amountWidth} align="right">{r.amountText}</Txt> <Txt role={role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
</Row> </Row>
))} ))}
</View> </View>
); );
} }
function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record<string, unknown> }) { export interface AmountInWordsProps {
model: RenderModel;
role?: Role;
style?: Record<string, unknown>;
/** Without it the label and the words are one italic paragraph (Classic); with it the label is its own line in this role. */
labelRole?: Role;
color?: string;
}
function AmountInWords({ model, role = "body", style, labelRole, color }: AmountInWordsProps) {
if (!model.amountInWords) return null; if (!model.amountInWords) return null;
if (labelRole) {
return (
<View id={SLOT_ID.amountInWords} style={style as never}>
<SafeTxt role={labelRole} color={color} style={{ marginBottom: 2 }}>Amount in words:</SafeTxt>
<SafeTxt role={role} color={color}>{model.amountInWords}</SafeTxt>
</View>
);
}
return ( return (
<SafeTxt id={SLOT_ID.amountInWords} role={role} italic style={style}> <SafeTxt id={SLOT_ID.amountInWords} role={role} italic style={style}>
{`Amount in words: ${model.amountInWords}`} {`Amount in words: ${model.amountInWords}`}
@@ -100,25 +124,56 @@ function ReverseCharge({ model, role = "body", color, style }: { model: RenderMo
/** Predicted heights of the slots that sit in the totals block, for its presence value. */ /** Predicted heights of the slots that sit in the totals block, for its presence value. */
export const slotHeights = { export const slotHeights = {
/** Lines the GSTIN slot prints for a party (0 or 1; an identifier that cannot fit may wrap further, which only makes the page longer). */
gstinLines: (model: RenderModel, party: "supplier" | "client"): number =>
(party === "supplier" ? (model.slots.gstin && model.supplier.gstin) : model.client.gstin) ? 1 : 0,
/** Lines the place-of-supply slot prints at `width`. */
placeOfSupplyLines: (model: RenderModel, role: Role, width: number): number =>
model.client.posLabel ? textLines(role, `Place of Supply: ${model.client.posLabel}`, width) : 0,
/** Whether the item has an HSN/SAC value to print (a code, not the "—" placeholder). */
hasHsn: (value: string): boolean => value !== "—",
/** Number of tax rows (each is one body line plus the template's row padding). */ /** Number of tax rows (each is one body line plus the template's row padding). */
taxRowCount: (model: RenderModel): number => model.totals.taxRows.length, taxRowCount: (model: RenderModel): number => model.totals.taxRows.length,
amountInWords: (model: RenderModel, width: number): number => /** Height of AmountInWords at `width`; pass the same roles the layout prints it in. */
model.amountInWords ? textLines("body", `Amount in words: ${model.amountInWords}`, width, { italic: true }) * (roleToken("body").leading as number) : 0, amountInWords: (model: RenderModel, width: number, roles: { role?: Role; labelRole?: Role } = {}): number => {
reverseCharge: (model: RenderModel, width: number): number => if (!model.amountInWords) return 0;
model.reverseCharge ? textLines("small", model.reverseCharge, width) * (roleToken("small").leading as number) : 0, const role = roles.role ?? "body";
const leading = (r: Role) => roleToken(r).leading as number;
if (roles.labelRole) {
return textLines(roles.labelRole, "Amount in words:", width) * leading(roles.labelRole) + 2 + textLines(role, model.amountInWords, width) * leading(role);
}
return textLines(role, `Amount in words: ${model.amountInWords}`, width, { italic: true }) * leading(role);
},
reverseCharge: (model: RenderModel, width: number, role: Role = "small"): number =>
model.reverseCharge ? textLines(role, model.reverseCharge, width) * (roleToken(role).leading as number) : 0,
}; };
export const SIGNATURE_IMAGE_H = 58; export const SIGNATURE_IMAGE_H = 58;
const FINE_LINE = "Computer-generated invoice; no signature is required."; const FINE_LINE = "Computer-generated invoice; no signature is required.";
export interface SignatoryOptions {
roles?: { name: Role; designation: Role; fine: Role };
/** Text and picture alignment; the default is right. */
align?: "left" | "right";
/** Rule between the picture and the name. Default: a 0.8 pt grey one (Classic); a template passes its ink. */
rule?: { width: number; color: string } | null;
color?: string;
imageHeight?: number;
}
const SIGN_ROLES = { name: "bodyStrong", designation: "body", fine: "fine" } as const satisfies NonNullable<SignatoryOptions["roles"]>;
const DEFAULT_RULE = { width: 0.8, color: SIGNATURE_RULE };
/** Height of the signatory block at `width`, for the closing block's presence value. */ /** Height of the signatory block at `width`, for the closing block's presence value. */
export function signatoryHeight(model: RenderModel, width: number): number { export function signatoryHeight(model: RenderModel, width: number, opts: SignatoryOptions = {}): number {
const leading = (r: Role) => roleToken(r).leading as number; const leading = (r: Role) => roleToken(r).leading as number;
const roles = opts.roles ?? SIGN_ROLES;
const rule = opts.rule === undefined ? DEFAULT_RULE : opts.rule;
const { name, designation, dataUri } = model.signature; const { name, designation, dataUri } = model.signature;
let h = (dataUri ? SIGNATURE_IMAGE_H : 0) + 4 + 0.8 + 3; let h = (dataUri ? (opts.imageHeight ?? SIGNATURE_IMAGE_H) : 0) + 4 + (rule?.width ?? 0) + 3;
h += textLines("bodyStrong", name, width) * leading("bodyStrong"); h += textLines(roles.name, name, width) * leading(roles.name);
if (designation) h += textLines("body", designation, width) * leading("body"); if (designation) h += textLines(roles.designation, designation, width) * leading(roles.designation);
if (!dataUri) h += 6 + textLines("fine", FINE_LINE, width) * leading("fine"); if (!dataUri) h += 6 + textLines(roles.fine, FINE_LINE, width) * leading(roles.fine);
return h; return h;
} }
@@ -126,27 +181,33 @@ export function signatoryHeight(model: RenderModel, width: number): number {
* Signature image (if any), a rule, the signatory's name and designation. Without an image the * Signature image (if any), a rule, the signatory's name and designation. Without an image the
* "computer-generated" line stands in for the signature. * "computer-generated" line stands in for the signature.
*/ */
function Signatory({ model, width }: { model: RenderModel; width?: number }) { function Signatory({ model, width, ...opts }: { model: RenderModel; width?: number } & SignatoryOptions) {
const avail = useAvailableWidth(); const avail = useAvailableWidth();
const w = width ?? avail; const w = width ?? avail;
const { name, designation, dataUri } = model.signature; const { name, designation, dataUri } = model.signature;
const roles = opts.roles ?? SIGN_ROLES;
const align = opts.align ?? "right";
const rule = opts.rule === undefined ? DEFAULT_RULE : opts.rule;
const custom = opts.color !== undefined;
return ( return (
<View id={SLOT_ID.signatory} style={pinned(w) as never}> <View id={SLOT_ID.signatory} style={pinned(w) as never}>
{dataUri ? ( {dataUri ? (
// Pinned box (an Image may be pinned); the picture sits at its right and bottom edge, just above the rule. // Pinned box (an Image may be pinned); the picture sits at its edge and bottom, just above the rule.
<Image <Image
src={dataUri} src={dataUri}
style={{ ...pinned(w), height: SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: "100%", objectPositionY: "100%" } as never} style={{ ...pinned(w), height: opts.imageHeight ?? SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: align === "right" ? "100%" : "0%", objectPositionY: "100%" } as never}
/> />
) : null} ) : null}
<View style={{ marginTop: 4, paddingTop: 3, borderTopWidth: 0.8, borderTopColor: SIGNATURE_RULE, ...pinned(w) } as never}> <View
<SafeTxt role="bodyStrong" width={w} align="right">{name}</SafeTxt> style={{ marginTop: 4, paddingTop: 3, ...(rule ? { borderTopWidth: rule.width, borderTopColor: rule.color } : {}), ...pinned(w) } as never}
>
<SafeTxt role={roles.name} width={w} align={align} color={opts.color}>{name}</SafeTxt>
{designation ? ( {designation ? (
<SafeTxt role="body" width={w} align="right" color={INK_SUB}>{designation}</SafeTxt> <SafeTxt role={roles.designation} width={w} align={align} color={custom ? opts.color : INK_SUB}>{designation}</SafeTxt>
) : null} ) : null}
</View> </View>
{!dataUri ? ( {!dataUri ? (
<Txt role="fine" color={FINE} width={w} align="right" style={{ marginTop: 6 }}> <Txt role={roles.fine} color={custom ? opts.color : FINE} width={w} align={align} style={{ marginTop: 6 }}>
{FINE_LINE} {FINE_LINE}
</Txt> </Txt>
) : null} ) : null}
+55
View File
@@ -0,0 +1,55 @@
import { Path, Svg } from "@react-pdf/renderer";
import type { DecorItem } from "../blocks/FirstPageHeader";
import { SCRIPT_SWASH, SCRIPT_WORD, type ScriptArt } from "./scriptWord.generated";
/*
* Where Canva's vertical word sits on the A4 page, measured on the rasterised page: the word's ink spans
* x 38.5-107.5 and y 410-780.5, the swash x 56.5-81.5 and y 143-410 (ours joins the word's end). (The PDF text baseline at x 150 is
* far from the ink, because that font's glyphs sit well to the left of it.)
*/
const WORD_X = 38.5;
const WORD_BOTTOM_Y = 780.5;
const SWASH_BOTTOM_Y = 410.5;
function art(a: ScriptArt, fill: string) {
return (
<Svg width={a.width} height={a.height} viewBox={`0 0 ${a.width} ${a.height}`}>
<Path d={a.d} fill={fill} />
</Svg>
);
}
/**
* Serenity's decorative "invoice" and swash as every-page decor items in page space. The outlines are
* generated at build time (scripts/decor/script-word.mjs); this is art, not text. `shiftUp` moves the
* band up on Letter, where the page is 49.9 pt shorter and the band would end 20 pt from the edge.
*/
export function scriptWordDecor(ink: string, shiftUp: number): DecorItem[] {
const dy = -shiftUp;
const wordTop = WORD_BOTTOM_Y - SCRIPT_WORD.height + dy;
return [
{
x: WORD_X,
y: wordTop,
width: SCRIPT_WORD.width,
height: SCRIPT_WORD.height,
children: art(SCRIPT_WORD, ink),
},
{
x: WORD_X + SCRIPT_WORD.anchorX - SCRIPT_SWASH.anchorX,
y: SWASH_BOTTOM_Y - SCRIPT_SWASH.height + dy,
width: SCRIPT_SWASH.width,
height: SCRIPT_SWASH.height,
children: art(SCRIPT_SWASH, ink),
},
];
}
/** The band's bounding box, page space, for the keep-out audit. */
export function scriptWordBounds(shiftUp: number): { x: number; y: number; w: number; h: number } {
const swashX = WORD_X + SCRIPT_WORD.anchorX - SCRIPT_SWASH.anchorX;
const x = Math.min(WORD_X, swashX);
const right = Math.max(WORD_X + SCRIPT_WORD.width, swashX + SCRIPT_SWASH.width);
const top = SWASH_BOTTOM_Y - SCRIPT_SWASH.height - shiftUp;
const bottom = WORD_BOTTOM_Y - shiftUp;
return { x, y: top, w: right - x, h: bottom - top };
}
File diff suppressed because one or more lines are too long
+65 -6
View File
@@ -17,6 +17,10 @@ const logo = (over: Partial<SlotLogoInput>): SlotLogoInput => ({
}); });
const Sample_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true }); const Sample_P = logo({ aspect: 4.62, density: 0.437, kind: "wordmark", includesName: true });
/** The box a logo is fitted to: the allowance for a text-only slot, else the lockup (or mark) box. */
const lockupTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : s.lockup!);
const markTarget = (s: TemplateSlot): Box => (s.textOnly ? s.allowance : (s.mark ?? s.lockup)!);
const inside = (inner: Box, outer: Box, eps = 1e-6) => const inside = (inner: Box, outer: Box, eps = 1e-6) =>
inner.x >= outer.x - eps && inner.y >= outer.y - eps && inner.x + inner.w <= outer.x + outer.w + eps && inner.y + inner.h <= outer.y + outer.h + eps; inner.x >= outer.x - eps && inner.y >= outer.y - eps && inner.x + inner.w <= outer.x + outer.w + eps && inner.y + inner.h <= outer.y + outer.h + eps;
const closeBox = (a: Box, b: Box, tol = 0.5) => { const closeBox = (a: Box, b: Box, tol = 0.5) => {
@@ -39,6 +43,17 @@ describe("slots.generated", () => {
} }
}); });
it("text-only placeholders (Monolith, Tangerine) carry a bigger allowance anchored at the text's edge", () => {
expect(measured.filter(([, s]) => s.textOnly).map(([id]) => id).sort()).toEqual(["monolith", "tangerine-ledger"]);
const mono = SLOTS.monolith!;
expect(mono.allowance).toEqual({ x: 400, y: 54.75, w: 160, h: 28 });
expect(mono.allowance.x + mono.allowance.w).toBeCloseTo(mono.lockup!.x + mono.lockup!.w, 6);
const tang = SLOTS["tangerine-ledger"]!;
expect(tang.allowance).toEqual({ x: 60.5, y: 62, w: 220, h: 36 });
expect(tang.allowance.x).toBeCloseTo(tang.lockup!.x, 6);
for (const [id, s] of measured) if (!s.textOnly) expect(s.allowance, id).toEqual(s.lockup);
});
it("only Slate Band is a dark surface", () => { it("only Slate Band is a dark surface", () => {
expect(measured.filter(([, s]) => s.darkSurface).map(([id]) => id)).toEqual(["slate-band"]); expect(measured.filter(([, s]) => s.darkSurface).map(([id]) => id)).toEqual(["slate-band"]);
}); });
@@ -63,7 +78,7 @@ describe("slots.optin", () => {
describe("computeLogoPlacement identity: the placeholder itself comes back as its own box", () => { describe("computeLogoPlacement identity: the placeholder itself comes back as its own box", () => {
for (const [id, s] of measured) { for (const [id, s] of measured) {
it(`${id} lockup`, () => { it(`${id} lockup`, () => {
const l = s.lockup!; const l = lockupTarget(s);
const p = computeLogoPlacement({ const p = computeLogoPlacement({
logo: logo({ aspect: l.w / l.h, density: s.lockupDensity, kind: "wordmark", includesName: true }), logo: logo({ aspect: l.w / l.h, density: s.lockupDensity, kind: "wordmark", includesName: true }),
slot: s, slot: s,
@@ -71,7 +86,7 @@ describe("computeLogoPlacement identity: the placeholder itself comes back as it
expect(p.slotKind).toBe("lockup"); expect(p.slotKind).toBe("lockup");
closeBox(p.box, l); closeBox(p.box, l);
}); });
if (s.mark) { if (s.mark && !s.textOnly) {
it(`${id} mark`, () => { it(`${id} mark`, () => {
const m = s.mark!; const m = s.mark!;
const p = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: s.markDensity }), slot: s }); const p = computeLogoPlacement({ logo: logo({ aspect: m.w / m.h, density: s.markDensity }), slot: s });
@@ -82,6 +97,48 @@ describe("computeLogoPlacement identity: the placeholder itself comes back as it
} }
}); });
describe("computeLogoPlacement text-only allowances", () => {
it("Test Vendor in Monolith is 28 pt tall, about 129 wide, and keeps the right edge", () => {
const s = SLOTS.monolith!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
expect(p.h).toBeCloseTo(28, 6);
expect(p.w).toBeGreaterThanOrEqual(128);
expect(p.w).toBeLessThanOrEqual(130);
expect(p.x + p.w).toBeCloseTo(s.lockup!.x + s.lockup!.w, 6);
expect(p.y).toBeCloseTo(s.allowance.y, 6);
expect(p.slotKind).toBe("lockup");
});
it("a very wide logo in Monolith is capped by the 160 pt width", () => {
const s = SLOTS.monolith!;
const p = computeLogoPlacement({ logo: logo({ aspect: 10, density: 0.3, kind: "wordmark", includesName: true }), slot: s });
expect(p.w).toBeCloseTo(160, 6);
expect(p.h).toBeCloseTo(16, 6);
expect(p.x + p.w).toBeCloseTo(560, 6);
});
it("Test Vendor in Tangerine fills 36 pt of height (about 166 wide) at the left edge", () => {
const s = SLOTS["tangerine-ledger"]!;
const p = computeLogoPlacement({ logo: Sample_P, slot: s });
expect(p.h).toBeCloseTo(36, 6);
expect(p.w).toBeCloseTo(36 * 4.62, 6);
expect(p.x).toBeCloseTo(60.5, 6);
});
it("a square mark in Tangerine is 36 x 36 and vertically centred in the allowance", () => {
const s = SLOTS["tangerine-ledger"]!;
const p = computeLogoPlacement({ logo: logo({ aspect: 1, density: 0.3 }), slot: s });
expect(p.w).toBeCloseTo(36, 6);
expect(p.y + p.h / 2).toBeCloseTo(62 + 18, 6);
});
it("the weight factor still uses the text's density: a dense logo shrinks to 70 % at most", () => {
const s = SLOTS["tangerine-ledger"]!;
const p = computeLogoPlacement({ logo: logo({ aspect: 4, density: 5, kind: "wordmark", includesName: true }), slot: s });
expect(p.h).toBeCloseTo(36 * 0.7, 6);
});
});
describe("computeLogoPlacement on every slot", () => { describe("computeLogoPlacement on every slot", () => {
const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn]; const all: [TemplateId, TemplateSlot][] = [...measured, ...optIn];
@@ -89,8 +146,8 @@ describe("computeLogoPlacement on every slot", () => {
for (const [id, s] of all) { for (const [id, s] of all) {
const p = computeLogoPlacement({ logo: Sample_P, slot: s }); const p = computeLogoPlacement({ logo: Sample_P, slot: s });
expect(p.slotKind).toBe("lockup"); expect(p.slotKind).toBe("lockup");
expect(inside(p.box, s.lockup!), id).toBe(true); expect(inside(p.box, lockupTarget(s)), id).toBe(true);
if (!s.optIn) { if (!s.optIn && !s.textOnly) {
const ink = p.w * p.h * Sample_P.density; const ink = p.w * p.h * Sample_P.density;
const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity; const placeholderInk = s.lockup!.w * s.lockup!.h * s.lockupDensity;
expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05); expect(ink, `${id}: ${ink.toFixed(1)} vs ${placeholderInk.toFixed(1)}`).toBeLessThanOrEqual(placeholderInk * 1.05);
@@ -103,7 +160,7 @@ describe("computeLogoPlacement on every slot", () => {
for (const [id, s] of all) { for (const [id, s] of all) {
for (const l of shapes) { for (const l of shapes) {
const p = computeLogoPlacement({ logo: l, slot: s }); const p = computeLogoPlacement({ logo: l, slot: s });
expect(inside(p.box, (s.mark ?? s.lockup)!), `${id} aspect ${l.aspect}`).toBe(true); expect(inside(p.box, markTarget(s)), `${id} aspect ${l.aspect}`).toBe(true);
expect(p.w / p.h).toBeCloseTo(l.aspect, 6); expect(p.w / p.h).toBeCloseTo(l.aspect, 6);
} }
} }
@@ -114,7 +171,7 @@ describe("computeLogoPlacement on every slot", () => {
expect(s.mark).toBeNull(); expect(s.mark).toBeNull();
const p = computeLogoPlacement({ logo: logo({ aspect: 1 }), slot: s }); const p = computeLogoPlacement({ logo: logo({ aspect: 1 }), slot: s });
expect(p.slotKind).toBe("lockup"); expect(p.slotKind).toBe("lockup");
expect(inside(p.box, s.lockup!)).toBe(true); expect(inside(p.box, s.allowance)).toBe(true);
}); });
it("a denser logo is drawn smaller, at most down to 70 %, and a lighter one is not enlarged", () => { it("a denser logo is drawn smaller, at most down to 70 %, and a lighter one is not enlarged", () => {
@@ -169,6 +226,8 @@ describe("computeLogoPlacement legibility floor", () => {
anchor: "left", anchor: "left",
vAlign: "middle", vAlign: "middle",
darkSurface: false, darkSurface: false,
textOnly: false,
allowance: { x: 50, y: 50, w: 100, h },
}); });
it("raises a logo that the weight factor made too short to 14 pt", () => { it("raises a logo that the weight factor made too short to 14 pt", () => {
+5 -3
View File
@@ -91,12 +91,14 @@ function align(target: Box, w: number, h: number, slot: TemplateSlot): Box {
/** /**
* Places the user's logo where a template's logo placeholder is. Contain-fit in the measured box, then * Places the user's logo where a template's logo placeholder is. Contain-fit in the measured box, then
* a shrink-only weight factor so a denser logo draws smaller and matches the placeholder's visual * a shrink-only weight factor so a denser logo draws smaller and matches the placeholder's visual
* weight. A wordmark (includesName) replaces the whole lockup; a mark replaces the icon only. The * weight. A wordmark (includesName) replaces the whole lockup; a mark replaces the icon only. A
* legibility floor never pushes past the box: a box under MIN_LOGO_HEIGHT stays the limit. * text-only placeholder (Monolith's "CAVEIN", Tangerine's "Rimberio") fits the template's allowance
* instead, with the weight factor still computed against the text's density. The legibility floor never pushes past the box: a box under MIN_LOGO_HEIGHT stays the limit.
*/ */
export function computeLogoPlacement({ logo, slot, scale = 1, available }: SlotLogoPlacementInput): SlotLogoPlacement { export function computeLogoPlacement({ logo, slot, scale = 1, available }: SlotLogoPlacementInput): SlotLogoPlacement {
const wantLockup = logo.includesName || !slot.mark; const wantLockup = logo.includesName || !slot.mark;
const target = wantLockup ? (slot.lockup ?? slot.mark) : slot.mark; // A text-only placeholder is too small for a logo; its allowance (from the template docs) is the target.
const target = slot.textOnly ? slot.allowance : wantLockup ? (slot.lockup ?? slot.mark) : slot.mark;
if (!target) throw new Error("slot has neither a mark nor a lockup box"); if (!target) throw new Error("slot has neither a mark nor a lockup box");
const slotKind = wantLockup ? "lockup" : "mark"; const slotKind = wantLockup ? "lockup" : "mark";
const placeholderDensity = wantLockup ? slot.lockupDensity : slot.markDensity; const placeholderDensity = wantLockup ? slot.lockupDensity : slot.markDensity;
+4 -1
View File
@@ -12,12 +12,15 @@ const goodFont = () => {
}; };
describe("font manifest", () => { describe("font manifest", () => {
it("lists the four bundled families", () => { it("lists the bundled families", () => {
expect(FONT_FAMILIES).toEqual([ expect(FONT_FAMILIES).toEqual([
"IBM Plex Mono", "IBM Plex Mono",
"IBM Plex Sans", "IBM Plex Sans",
"Montserrat",
"Noto Sans Devanagari", "Noto Sans Devanagari",
"Noto Sans Kannada", "Noto Sans Kannada",
"Open Sans",
"Poppins",
]); ]);
for (const fam of FONT_MANIFEST.families) expect(fam.license).toBe("OFL-1.1"); for (const fam of FONT_MANIFEST.families) expect(fam.license).toBe("OFL-1.1");
}); });
+11 -2
View File
@@ -3,9 +3,10 @@
* on the same node as fontSize. Roles used by render-prop text (page numbers) have no leading, because * on the same node as fontSize. Roles used by render-prop text (page numbers) have no leading, because
* react-pdf multiplies a render-prop lineHeight again on every relayout. * react-pdf multiplies a render-prop lineHeight again on every relayout.
* *
* These values are the Classic template's; other templates will pass their own token objects. * 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"; export type FaceKey = "IBM Plex Sans" | "IBM Plex Mono" | "Montserrat" | "Open Sans" | "Poppins";
export interface TypeToken { export interface TypeToken {
face: FaceKey; face: FaceKey;
@@ -44,6 +45,14 @@ export const TYPE_ROLES = {
export type RoleName = keyof typeof TYPE_ROLES; export type RoleName = keyof typeof TYPE_ROLES;
/** A template's own role table: names to tokens. `satisfies`-friendly, so each template keeps its literal keys. */
export type RoleTable<K extends string = string> = Record<K, TypeToken>;
/** Checks a role table against TypeToken and keeps its literal keys: `export const ROLES = defineRoles({ ... })`. */
export function defineRoles<const T extends Record<string, TypeToken>>(table: T): T {
return table;
}
/** /**
* Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is * Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is
* actually bundled. Empty until those families are registered. * actually bundled. Empty until those families are registered.
+8 -2
View File
@@ -9,6 +9,8 @@ export interface RenderPrefsV1 {
logo?: { logo?: {
/** The logo already contains the business name, so a template drops its own name lockup. */ /** The logo already contains the business name, so a template drops its own name lockup. */
useWordmark?: boolean; useWordmark?: boolean;
/** Templates without a Canva logo placeholder (Linea, ...) show the logo only when this is true. */
show?: boolean;
}; };
} }
@@ -40,8 +42,12 @@ export function parsePrefs(json: string | null | undefined): RenderPrefsV1 {
margins: MARGIN_IDS.includes(o.margins as MarginPreset) ? (o.margins as MarginPreset) : DEFAULT_PREFS.margins, margins: MARGIN_IDS.includes(o.margins as MarginPreset) ? (o.margins as MarginPreset) : DEFAULT_PREFS.margins,
}; };
const logo = o.logo; const logo = o.logo;
if (typeof logo === "object" && logo !== null && typeof (logo as { useWordmark?: unknown }).useWordmark === "boolean") { if (typeof logo === "object" && logo !== null) {
prefs.logo = { useWordmark: (logo as { useWordmark: boolean }).useWordmark }; const { useWordmark, show } = logo as { useWordmark?: unknown; show?: unknown };
const out: NonNullable<RenderPrefsV1["logo"]> = {};
if (typeof useWordmark === "boolean") out.useWordmark = useWordmark;
if (typeof show === "boolean") out.show = show;
if (Object.keys(out).length > 0) prefs.logo = out;
} }
return prefs; return prefs;
} }
+30 -5
View File
@@ -1,5 +1,5 @@
import { createElement } from "react"; import { createElement } from "react";
import { computeClassicLogoPlacement } from "../engine/logo"; import { computeClassicLogoPlacement, computeLogoPlacement } from "../engine/logo";
import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry"; import { computeFrame, PAGE_SIZES, type PageFrame } from "../engine/geometry";
import type { AuditIssue } from "../engine/audit"; import type { AuditIssue } from "../engine/audit";
import type { RenderModel } from "../model/build"; import type { RenderModel } from "../model/build";
@@ -31,7 +31,7 @@ export function frameForPrefs(prefs: RenderPrefsV1): PageFrame {
return computeFrame({ return computeFrame({
page: PAGE_SIZES[prefs.pageSize], page: PAGE_SIZES[prefs.pageSize],
margins: marginsFor(template, prefs.margins), margins: marginsFor(template, prefs.margins),
footerReserve: FOOTER_RESERVE, footerReserve: template.chrome.reserve ?? FOOTER_RESERVE,
continuationReserve: CONTINUATION_RESERVE, continuationReserve: CONTINUATION_RESERVE,
letterMode: true, letterMode: true,
}); });
@@ -46,7 +46,32 @@ export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, byt
const frame = frameForPrefs(prefs); const frame = frameForPrefs(prefs);
let logo: PlacedLogo | null = null; let logo: PlacedLogo | null = null;
if (model.logo && template.logo.defaultOn) { const wanted = template.logo.optIn ? prefs.logo?.show === true : template.logo.defaultOn;
if (model.logo && wanted && template.logo.slot) {
// A Canva-based template: fit the logo to the measured placeholder. prefs can override whether the
// logo counts as a wordmark (it then replaces the template's whole name lockup).
const placement = computeLogoPlacement({
logo: {
aspect: model.logo.aspect,
density: model.logo.density,
kind: model.logo.kind,
includesName: prefs.logo?.useWordmark ?? model.logo.includesName,
hasKnockout: model.logo.hasKnockout ?? false,
},
slot: template.logo.slot,
available: template.logo.available?.(frame),
});
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,
w: placement.w,
h: placement.h,
stacked: false,
placement,
slotOffset: { x: placement.x - target.x, y: placement.y - target.y },
};
} else if (model.logo && wanted) {
const placed = computeClassicLogoPlacement({ const placed = computeClassicLogoPlacement({
aspect: model.logo.width / model.logo.height, aspect: model.logo.width / model.logo.height,
box: template.logo.box, box: template.logo.box,
@@ -56,8 +81,8 @@ export async function renderInvoicePdf({ model, prefs }: RenderInvoiceInput, byt
logo = { dataUri: model.logo.dataUri, ...placed }; logo = { dataUri: model.logo.dataUri, ...placed };
} }
// No decor yet, so no keep-outs; templates with corner art will return theirs here.
const element = createElement(InvoicePdf, { model, frame, template, logo, prefs }); const element = createElement(InvoicePdf, { model, frame, template, logo, prefs });
const core = await renderCore(element, { frame, decorBoxes: [], audit: { closingId: CLOSING_ID } }, bytesOf); const decorBoxes = template.decor.keepOuts?.(frame) ?? [];
const core = await renderCore(element, { frame, decorBoxes, audit: { closingId: CLOSING_ID } }, bytesOf);
return { bytes: core.bytes, pages: core.pages, issues: core.issues, fingerprint: core.fingerprint, core }; return { bytes: core.bytes, pages: core.pages, issues: core.issues, fingerprint: core.fingerprint, core };
} }
+32
View File
@@ -0,0 +1,32 @@
/**
* What the template picker shows about each template. Kept free of layout code so the UI can import it
* without pulling react-pdf into the main bundle; every TemplateDefinition takes its identity from here.
*/
export interface TemplateCatalogEntry {
id: string;
name: string;
family: "classic" | "neutral";
/** Static PNG from /public, written by scripts/thumbnails.mjs (sample data, not the user's invoice). */
thumbnail: string;
badge?: "Matches your original format";
/** No Canva placeholder: the logo shows only when the user turns it on. */
logoOptIn?: boolean;
}
export const FAMILY_LABELS: Record<TemplateCatalogEntry["family"], string> = {
classic: "Classic",
neutral: "Neutral",
};
/** Picker order: Classic first, Linea (the vendor's original format) second. */
export const TEMPLATE_CATALOG: readonly TemplateCatalogEntry[] = [
{ id: "classic", name: "Voiced Standard", family: "classic", thumbnail: "/templates/classic.png" },
{ 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" },
];
/** The entry for an id; an unknown id falls back to Classic, like getTemplate. */
export function catalogEntry(id: string | undefined): TemplateCatalogEntry {
return TEMPLATE_CATALOG.find((e) => e.id === id) ?? TEMPLATE_CATALOG[0];
}
+1 -1
View File
@@ -90,7 +90,7 @@ describe("classic plan matches the laid-out blocks", () => {
describe("registry", () => { describe("registry", () => {
it("has Classic and falls back to it for an unknown or missing id", () => { it("has Classic and falls back to it for an unknown or missing id", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic"]); expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
expect(getTemplate("classic")).toBe(classic); expect(getTemplate("classic")).toBe(classic);
expect(getTemplate("no-such-template")).toBe(classic); expect(getTemplate("no-such-template")).toBe(classic);
expect(getTemplate(undefined)).toBe(classic); expect(getTemplate(undefined)).toBe(classic);
+7 -3
View File
@@ -1,3 +1,5 @@
import { TYPE_ROLES } from "../../fonts/roles";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract"; import type { TemplateDefinition } from "../contract";
import { ClassicLayout } from "./Layout"; import { ClassicLayout } from "./Layout";
import { headerCols } from "./plan"; import { headerCols } from "./plan";
@@ -10,9 +12,10 @@ import { headerCols } from "./plan";
* the footer text sits 24 pt above it. The narrow (36) and normal (54) presets only ever widen a side. * the footer text sits 24 pt above it. The narrow (36) and normal (54) presets only ever widen a side.
*/ */
export const classic: TemplateDefinition = { export const classic: TemplateDefinition = {
id: "classic", ...catalogEntry("classic"),
name: "Voiced Standard", roles: TYPE_ROLES,
family: "classic", decor: { ink: "#161616" },
chrome: { color: "#6f6f6f", rule: "#e0e0e0", leftWidth: 170 },
version: 1, version: 1,
margins: { top: 40, right: 40, bottom: 24, left: 40 }, margins: { top: 40, right: 40, bottom: 24, left: 40 },
marginFloor: { top: 36, right: 36, bottom: 24, left: 36 }, marginFloor: { top: 36, right: 36, bottom: 24, left: 36 },
@@ -21,6 +24,7 @@ export const classic: TemplateDefinition = {
stackBelowTextWidth: 160, stackBelowTextWidth: 160,
defaultOn: true, defaultOn: true,
slotWidth: (frame) => headerCols(frame.contentWidth)[0], slotWidth: (frame) => headerCols(frame.contentWidth)[0],
slot: null,
}, },
Layout: ClassicLayout, Layout: ClassicLayout,
}; };
+53
View File
@@ -1,7 +1,12 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { effectiveMargins, type MarginPreset, type PageFrame, type Sides } from "../engine/geometry"; import { effectiveMargins, type MarginPreset, type PageFrame, type Sides } from "../engine/geometry";
import type { Rect } from "../engine/layoutTree";
import type { SlotLogoPlacement } from "../engine/logo";
import type { RoleName, RoleTable, TypeToken } from "../fonts/roles";
import type { DecorItem } from "../blocks/FirstPageHeader";
import type { RenderModel } from "../model/build"; import type { RenderModel } from "../model/build";
import type { RenderPrefsV1 } from "../model/prefs"; import type { RenderPrefsV1 } from "../model/prefs";
import type { TemplateSlot } from "./slots.types";
/** A logo after fitting and the stack-or-side-by-side decision (engine/logo.ts). */ /** A logo after fitting and the stack-or-side-by-side decision (engine/logo.ts). */
export interface PlacedLogo { export interface PlacedLogo {
@@ -9,6 +14,37 @@ export interface PlacedLogo {
w: number; w: number;
h: number; h: number;
stacked: boolean; stacked: boolean;
/** Set for slot-based templates: where the Canva placeholder was and how the logo was fitted to it. */
placement?: SlotLogoPlacement;
/** Where the fitted logo sits inside the slot's target box (pt from its top-left), so a flow layout can reproduce the alignment. */
slotOffset?: { x: number; y: number };
}
/** What the page itself carries besides the flow: the paper, the ink and every-page art. */
export interface TemplateDecor {
/** Page fill (the Page backgroundColor), e.g. Serenity's paper. Omitted: white. */
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[];
}
/** Footer and page-label chrome of a template. */
export interface TemplateChrome {
color: string;
/** Hairline above the footer (Classic); null for none. */
rule: string | null;
/** Footer type (a render-prop role: no leading). Default: the "footer" role. */
role?: RoleName | TypeToken;
/** The footer text starts and ends this far inside the content column. */
inset?: { left: number; right: number };
/** Width the left footer text (supplier name) is fitted to. */
leftWidth: number;
/** Height reserved at the foot of every page for the footer line; default FOOTER_RESERVE (it includes Classic's rule). */
reserve?: number;
} }
export interface TemplateProps { export interface TemplateProps {
@@ -22,6 +58,14 @@ export interface TemplateDefinition {
id: string; id: string;
name: string; name: string;
family: string; family: string;
/** Static PNG for the template picker, served from /public (written by scripts/thumbnails.mjs). */
thumbnail: string;
/** Shown on the picker tile. */
badge?: "Matches your original format";
/** The template's own type roles (Classic's are TYPE_ROLES). */
roles: RoleTable;
decor: TemplateDecor;
chrome: TemplateChrome;
/** Bump when the layout changes in a way that alters pages; it goes into the PDF Creator string. */ /** Bump when the layout changes in a way that alters pages; it goes into the PDF Creator string. */
version: number; version: number;
/** The template's own physical margins (the "template" margin preset). */ /** The template's own physical margins (the "template" margin preset). */
@@ -36,6 +80,15 @@ export interface TemplateDefinition {
defaultOn: boolean; defaultOn: boolean;
/** Width of the column the logo shares with the supplier text, for this frame. */ /** Width of the column the logo shares with the supplier text, for this frame. */
slotWidth: (frame: PageFrame) => number; slotWidth: (frame: PageFrame) => number;
/**
* The measured Canva placeholder (or opt-in area) the user's logo replaces, from slotFor(id).
* Null for Classic, which fits its logo with computeClassicLogoPlacement.
*/
slot: TemplateSlot | null;
/** The template has no placeholder: the logo is shown only when the user turns it on (prefs.logo.show). */
optIn?: boolean;
/** Room the layout can really give the logo; the slot's target box shrinks to it. */
available?: (frame: PageFrame) => { w: number; h: number };
}; };
Layout: ComponentType<TemplateProps>; Layout: ComponentType<TemplateProps>;
} }
+21 -4
View File
@@ -73,14 +73,24 @@ function checkRendered(r: Rendered, label: string) {
const titleInk = textLinesUnder(title); 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); 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);
// Header columns (the old fixed 339.5 / 362.5 bounds, derived from the frame so Letter is checked too): // 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. // 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); const frame = frameForPrefs(r.prefs);
if (r.prefs.templateId === "classic") {
const [supplierW] = headerCols(frame.contentWidth); const [supplierW] = headerCols(frame.contentWidth);
const supplierRight = frame.pad.left + supplierW; const supplierRight = frame.pad.left + supplierW;
const metaLeft = supplierRight + HEADER_GAP; 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(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); 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. // Header repeats on every continuation page that has rows; no page shows a header without rows.
layout.forEach((page, i) => { layout.forEach((page, i) => {
@@ -110,8 +120,15 @@ function checkRendered(r: Rendered, label: string) {
expect(r.pdf.raw, ctx("no Helvetica")).not.toContain("/BaseFont /Helvetica"); 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, ctx("rupee extracts as U+20B9, never a superscript one")).not.toContain("¹");
expect(text).toContain("₹"); expect(text).toContain("₹");
for (const t of r.pdf.pages.flatMap((p) => p.items)) { // A face without the rupee sign (Open Sans) draws it from the fallback font, which extraction reports as
if (/\d\.\d\d$/.test(t.str.trim())) expect(t.str, ctx(`amount "${t.str}" carries the rupee sign`)).toContain("₹"); // 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); const flat = squash(text);
expect(flat, ctx("invoice number")).toContain(squash(model.meta.number)); expect(flat, ctx("invoice number")).toContain(squash(model.meta.number));
+504
View File
@@ -0,0 +1,504 @@
import { Image, View } from "@react-pdf/renderer";
import type { ComponentProps, 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, useAvailableWidth } from "../../blocks/AvailableWidth";
import { BottomSpacer } from "../../blocks/BottomSpacer";
import { FirstPageHeader } from "../../blocks/FirstPageHeader";
import { FitText } from "../../blocks/FitText";
import { IdText } from "../../blocks/IdText";
import { TableRows } from "../../blocks/ItemsTable";
import { Row } from "../../blocks/Row";
import { SafeTxt } from "../../blocks/SafeTxt";
import { Slot } from "../../blocks/slots";
import { TableGuard } from "../../blocks/TableGuard";
import { Txt, type TxtChild } from "../../blocks/Txt";
import type { RenderModel } from "../../model/build";
import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids";
import type { PlacedLogo, TemplateProps } from "../contract";
import {
bankLines,
CANCELLED_INK,
CANCELLED_SENTENCE,
CLIENT_LABEL_GAP,
CLOSING_GAP,
INFO_GAP,
initialsOf,
LOGO_TITLE_GAP,
META_ROW_GAP,
META_VALUE_GAP,
metaRows,
planNeutral,
SERENITY_LABEL_GAP,
signatureOptions,
TITLE_RULE_GAP,
WORDS_GAP,
WORDS_TOP,
type NeutralPlan,
} from "./plan";
import type { NeutralVariant } from "./variant";
interface Ctx {
model: RenderModel;
plan: NeutralPlan;
v: NeutralVariant;
logo: PlacedLogo | null;
}
/** Text as wide as its cell, so alignment and wrapping use the real box. */
function FullTxt({ children, ...rest }: Omit<ComponentProps<typeof Txt>, "width" | "children" | "grow" | "maxLines"> & { children?: TxtChild }) {
const w = useAvailableWidth();
return (
<Txt {...rest} width={w}>
{children}
</Txt>
);
}
/** The text column: inset from the content edge on both sides, with its own available width. */
function TextColumn({ ctx, children, style }: { ctx: Ctx; children: ReactNode; style?: Record<string, unknown> }) {
const { v, plan } = ctx;
return (
<View style={{ marginLeft: v.textInset.left, ...pinned(plan.geo.T), ...style }}>
<AvailableWidthProvider width={plan.geo.T}>{children}</AvailableWidthProvider>
</View>
);
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
function LogoImage({ logo, style }: { logo: PlacedLogo; style?: Record<string, unknown> }) {
return <Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, objectFit: "contain", ...style } as never} />;
}
// --- header --------------------------------------------------------------------------------------------
function LineaTitleRow({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const step = (plan.titleToken.tracking ?? 0) * plan.titleToken.size;
return (
<View style={{ flexDirection: "row", alignItems: "center", ...pinned(plan.geo.W) }}>
<Txt id={TITLE_ID} role={plan.titleToken} color={v.ink} width={plan.titleW}>
{model.docTitle}
</Txt>
{/* The tracking after the last glyph is part of the box, so the visible gap is TITLE_RULE_GAP. */}
<View style={{ ...FILL, borderTopWidth: v.rule, borderTopColor: v.ink, marginLeft: Math.max(0, TITLE_RULE_GAP - step - 1) }} />
</View>
);
}
function LineaHeader({ ctx }: { ctx: Ctx }) {
const { plan, logo } = ctx;
const logoBand = logo ? logo.h + LOGO_TITLE_GAP : 0;
return (
<>
<Spacer h={plan.gaps.top - logoBand} />
{logo ? <LogoImage logo={logo} style={{ marginBottom: LOGO_TITLE_GAP }} /> : null}
<LineaTitleRow ctx={ctx} />
</>
);
}
function MonogramHeader({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const r = v.roles;
const T = plan.geo.T;
// The mark cell: the slot width on the right of the text column, or room for the initials.
const slotW = logo ? Math.max(logo.w, Math.min(142.75, Math.floor(T * 0.4))) : Math.min(120, Math.floor(T * 0.4));
const initials = initialsOf(model.supplier.name);
return (
<>
<Spacer h={plan.gaps.top} />
<View style={{ flexDirection: "row", ...pinned(plan.geo.W) }}>
<View style={{ ...FILL, paddingTop: plan.titleTop }}>
<AvailableWidthProvider width={plan.geo.W - slotW - v.textInset.right}>
<FitText id={TITLE_ID} role={r.title} color={v.ink} width={plan.geo.W - slotW - v.textInset.right}>
{model.docTitle}
</FitText>
</AvailableWidthProvider>
</View>
<View style={{ ...pinned(slotW), marginRight: v.textInset.right, alignItems: "flex-start" }}>
<View style={{ height: plan.markTop }} />
{logo ? (
<LogoImage logo={logo} style={{ marginLeft: slotW - logo.w }} />
) : initials ? (
<Txt role={r.initials} color={v.ink} width={slotW} align="right">
{initials}
</Txt>
) : null}
</View>
</View>
</>
);
}
function SerenityHeader({ ctx }: { ctx: Ctx }) {
const { plan, v, logo } = ctx;
const T = plan.geo.T;
return (
<>
<Spacer h={plan.gaps.top} />
{logo ? (
<>
<Spacer h={plan.markTop} />
<LogoImage logo={logo} style={{ marginLeft: v.textInset.left + T - logo.w }} />
</>
) : null}
</>
);
}
function FromBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const s = model.supplier;
const w = Math.min(v.fromW, plan.geo.T);
return (
<View id={SUPPLIER_ID} style={{ marginLeft: plan.geo.T - w, ...pinned(w) }}>
<AvailableWidthProvider width={w}>
<SafeTxt role={r.bodyStrong} color={v.ink} align="right">
{s.name}
</SafeTxt>
{s.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.address} color={v.ink} align="right">
{line}
</SafeTxt>
))}
{s.email ? (
<IdText role={r.address} color={v.ink} align="right" fill>
{s.email}
</IdText>
) : null}
{s.phone ? (
<IdText role={r.address} color={v.ink} align="right" fill>
{s.phone}
</IdText>
) : null}
{s.pan ? (
<IdText role={r.address} color={v.ink} align="right" fill>{`PAN: ${s.pan}`}</IdText>
) : null}
<Slot.Gstin model={model} party="supplier" role={r.address} color={v.ink} align="right" />
</AvailableWidthProvider>
</View>
);
}
// --- info row ------------------------------------------------------------------------------------------
function PayBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
if (!model.bank) return <View />;
return (
<View style={pinned(width)}>
<AvailableWidthProvider width={width}>
<Txt role={r.label} color={v.ink} width={width} style={{ marginBottom: CLIENT_LABEL_GAP }}>
{v.payTo.label}
</Txt>
{bankLines(model).map((line, i) => (
<SafeTxt key={i} role={r.address} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const c = model.client;
const w = useAvailableWidth();
return (
<View style={pinned(w)}>
<Txt role={r.label} color={v.ink} width={w} style={{ marginBottom: CLIENT_LABEL_GAP }}>
Issued to:
</Txt>
<SafeTxt role={r.address} color={v.ink}>
{c.name}
</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.address} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.address} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.address} color={v.ink} />
{v.payTo.where === "under-client" && model.bank ? (
<View style={{ marginTop: v.payGap }}>
<PayBlock ctx={ctx} width={w} />
</View>
) : null}
</View>
);
}
function CancelledTag({ ctx }: { ctx: Ctx }) {
const { v, plan } = ctx;
const textW = Math.ceil(measureToken(getMeasurer(), 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, marginLeft: plan.metaW - boxW, ...pinned(boxW) }}
>
<Txt role={v.roles.label} color={CANCELLED_INK} width={textW}>
Cancelled
</Txt>
</View>
);
}
function MetaBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const w = useAvailableWidth();
return (
<View id={META_ID} style={pinned(w)}>
{model.cancelled ? <CancelledTag ctx={ctx} /> : null}
{metaRows(model).map((row, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: plan.metaValueW }]} gap={META_VALUE_GAP} style={{ marginBottom: META_ROW_GAP }}>
<FullTxt role={row.strong ? r.label : { ...r.label, weight: r.body.weight }} color={v.ink} align="right">
{row.label}
</FullTxt>
<FitText role={row.strong ? r.bodyStrong : r.body} color={v.ink} width={plan.metaValueW} align="right">
{row.value}
</FitText>
</Row>
))}
</View>
);
}
function InfoRow({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
return (
<Row widths={[{ fill: true, min: 150 }, { fixed: plan.metaW }]} gap={INFO_GAP}>
<ClientBlock ctx={ctx} />
<MetaBlock ctx={ctx} />
</Row>
);
}
// --- table and totals ------------------------------------------------------------------------------------
function TotalsLine({ ctx, label, amount, role, color }: { ctx: Ctx; label: string; amount: string; role?: keyof NeutralVariant["roles"]; color?: string }) {
const { v } = ctx;
const token = v.roles[role ?? "body"];
return (
<Row widths={[{ fill: true }, { fixed: v.amountW }]} gap={v.labelGap} style={{ marginTop: v.totalsRowGap }}>
<FullTxt role={token} color={color ?? v.ink} align="right">
{label}
</FullTxt>
<FullTxt role={token} color={color ?? v.ink} align="right">
{amount}
</FullTxt>
</Row>
);
}
function TotalsBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const t = model.totals;
const innerW = plan.geo.innerW;
const taxFormat = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
const rightCol = (
<View style={pinned(v.totalsW)}>
<AvailableWidthProvider width={v.totalsW}>
{t.discountText ? <TotalsLine ctx={ctx} label="Discount" amount={`- ${t.discountText}`} /> : null}
{t.taxableText ? <TotalsLine ctx={ctx} label="Taxable value" amount={t.taxableText} /> : null}
<Slot.TaxRows
model={model}
role={r.body}
color={v.ink}
amountWidth={v.amountW}
labelGap={v.labelGap}
labelAlign="right"
format={taxFormat}
rowStyle={{ marginTop: v.totalsRowGap }}
/>
<TotalsLine ctx={ctx} label="Total" amount={t.totalText} role="total" />
</AvailableWidthProvider>
</View>
);
const words = (
<>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.wordsLabel} color={v.ink} />
<Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: 4 }} />
</>
);
return (
<View id={TOTALS_ID} wrap={false}>
<View
style={{
marginTop: v.closeGap,
borderTopWidth: v.rule,
borderTopColor: v.ink,
paddingTop: v.subtotalPad,
paddingHorizontal: v.padX,
}}
>
<AvailableWidthProvider width={innerW}>
<Row widths={[{ fill: true }, { fixed: v.amountW }]} gap={v.labelGap}>
<FullTxt role={r.subtotal} color={v.ink}>
Subtotal
</FullTxt>
<FullTxt role={r.subtotal} color={v.ink} align="right">
{t.subtotalText}
</FullTxt>
</Row>
{plan.wordsBeside ? (
<Row widths={[{ fill: true }, { fixed: v.totalsW }]} gap={16}>
<View style={{ marginTop: WORDS_TOP }}>{words}</View>
{rightCol}
</Row>
) : (
<>
<Row widths={[{ fill: true }, { fixed: v.totalsW }]}>
<View />
{rightCol}
</Row>
<View style={{ marginTop: WORDS_GAP }}>{words}</View>
</>
)}
</AvailableWidthProvider>
</View>
</View>
);
}
function Notes({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
if (model.notesLines.length === 0) return null;
return (
<TextColumn ctx={ctx} style={{ marginTop: plan.gaps.notes }}>
{/* 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} style={{ marginBottom: CLIENT_LABEL_GAP }}>
Notes:
</Txt>
<SafeTxt role={r.small} color={v.ink}>
{model.notesLines[0]}
</SafeTxt>
</View>
{model.notesLines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</TextColumn>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const signW = v.signatureW;
const T = plan.geo.T;
const opts = signatureOptions(v);
const sign = (
<View style={pinned(signW)}>
{v.id === "serenity" ? (
<Txt role={r.label} color={v.ink} width={signW} align="right" style={{ marginBottom: 4 }}>
Thank you
</Txt>
) : null}
<Slot.Signatory model={model} width={signW} {...opts} />
</View>
);
const payInClosing = v.payTo.where === "closing";
// Top-aligned columns; the shorter one gets a margin so the two end on the same line.
const pad = (own: number, other: number) => Math.max(0, other - own);
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={{ paddingTop: CLOSING_GAP }}>
<TextColumn ctx={ctx}>
<Row widths={[{ fill: true }, { fixed: signW }]} gap={16}>
{payInClosing ? (
<View style={{ marginTop: pad(plan.payH, plan.signH), paddingLeft: v.payTo.inset }}>
<AvailableWidthProvider width={T - signW - 16 - v.payTo.inset}>
<PayBlock ctx={ctx} width={T - signW - 16 - v.payTo.inset} />
</AvailableWidthProvider>
</View>
) : (
<View />
)}
<View style={{ marginTop: payInClosing ? pad(plan.signH, plan.payH) : 0 }}>{sign}</View>
</Row>
{model.cancelled ? (
<Txt role={r.small} color={CANCELLED_INK} width={T} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</TextColumn>
</View>
</>
);
}
// --- the layout --------------------------------------------------------------------------------------------
export function NeutralLayout({ model, frame, logo, variant }: TemplateProps & { variant: NeutralVariant }) {
const plan = planNeutral(model, frame, variant, logo);
const ctx: Ctx = { model, plan, v: variant, logo };
const { v } = ctx;
const r = v.roles;
return (
<>
<FirstPageHeader pad={frame.pad}>
{v.id === "linea" ? <LineaHeader ctx={ctx} /> : v.id === "monogram" ? <MonogramHeader ctx={ctx} /> : <SerenityHeader ctx={ctx} />}
<Spacer h={v.fromGap} />
<TextColumn ctx={ctx}>
<FromBlock ctx={ctx} />
</TextColumn>
<Spacer h={plan.gaps.info} />
{v.id === "serenity" ? (
<TextColumn ctx={ctx} style={{ marginBottom: SERENITY_LABEL_GAP }}>
<Txt id={TITLE_ID} role={r.label} color={v.ink} width={plan.geo.T} align="right">
{model.docTitle}
</Txt>
</TextColumn>
) : null}
<TextColumn ctx={ctx}>
<InfoRow ctx={ctx} />
</TextColumn>
</FirstPageHeader>
<Spacer h={plan.gaps.table} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={{ marginLeft: v.ruleInset, ...pinned(plan.geo.ruleW) }}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.geo.ruleW}
header={plan.header}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={v.padX}
padY={v.padY}
border={0}
headColor={v.ink}
headerTail={
<View style={{ marginTop: v.headTail.before, borderTopWidth: v.rule, borderTopColor: v.ink, marginBottom: v.headTail.after }} />
}
tailH={plan.tailH}
tail={<TotalsBlock ctx={ctx} />}
/>
</View>
<Notes ctx={ctx} />
<Closing ctx={ctx} />
</>
);
}
+2
View File
@@ -0,0 +1,2 @@
/** Height reserved for the footer line of a Neutral page (10 pt line plus 6 pt of air; there is no footer rule). */
export const FOOTER_RESERVE = 16;
+11
View File
@@ -0,0 +1,11 @@
import type { ComponentType } from "react";
import type { TemplateProps } from "../contract";
import { NeutralLayout } from "./Layout";
import type { NeutralVariant } from "./variant";
/** The family component bound to one variant's tokens, as the TemplateDefinition's Layout. */
export function NeutralLayoutFor(variant: NeutralVariant): ComponentType<TemplateProps> {
const Layout = (props: TemplateProps) => <NeutralLayout {...props} variant={variant} />;
Layout.displayName = `NeutralLayout(${variant.id})`;
return Layout;
}
+98
View File
@@ -0,0 +1,98 @@
import { defineRoles } from "../../fonts/roles";
import { FOOTER_RESERVE } from "./constants";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NeutralLayoutFor } from "./layoutFor";
import type { NeutralRoles, NeutralVariant } from "./variant";
const INK = "#3D3B3A";
const M = "Montserrat" as const;
/**
* Linea's type: Montserrat 400/700 at 10 pt with 0.05 em tracking, a 38 pt title with 0.20 em
* (docs/templates/04-linea.md). Montserrat ascent 0.968, descent 0.251, cap height 0.700.
*/
export const LINEA_ROLES = defineRoles({
title: { face: M, weight: 400, size: 38, leading: 47, tracking: 0.2, caps: true },
label: { face: M, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
body: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
bodyStrong: { face: M, weight: 700, size: 10, leading: 13.5, tracking: 0.05 },
address: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
tableHead: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05, caps: true },
tableBody: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
subtotal: { face: M, weight: 400, size: 10, leading: 13.5, tracking: 0.05, caps: true },
total: { face: M, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
small: { face: M, weight: 400, size: 9, leading: 12.5, tracking: 0.05 },
wordsLabel: { face: M, weight: 700, size: 8, leading: 11, tracking: 0.1, caps: true },
fine: { face: M, weight: 400, size: 8, leading: 11, tracking: 0.05 },
initials: { face: M, weight: 400, size: 40, leading: 46 },
footer: { face: M, weight: 400, size: 7 },
} satisfies NeutralRoles);
/**
* Canva A4 numbers, converted: the rules start 10.09 pt inside a 59.55 pt margin (x 69.64, the title
* starts on the margin), text 7.8 pt further in (x 77.44). Vertical numbers are the empty bands of the
* design (title top 124.85, info row 259.5, table header 434.3, ...) turned into elastic gaps.
*/
export const LINEA_VARIANT: NeutralVariant = {
id: "linea",
roles: LINEA_ROLES,
ink: INK,
margins: { top: 56, right: 59.55, bottom: 60, left: 59.55 },
marginFloor: { top: 36, right: 36, bottom: 36, left: 36 },
ruleInset: 10.09,
padX: 7.8,
textInset: { left: 17.89, right: 17.89 },
rule: 0.75,
topGap: { nominal: 68.85, min: 33.85 },
fromGap: 6,
bandToInfo: 134.65,
infoMinGap: 14,
tableGap: { nominal: 49.5, min: 24 },
headTail: { before: 5.05, after: 9.06 },
padY: 3.98,
closeGap: 9.94,
subtotalPad: 8.32,
totalsRowGap: 1.5,
labelGap: 8,
hsnMode: "column",
cols: { hsn: 64, rate: 96, qty: 44, amount: 88, descMin: 80 },
totalsW: 236,
amountW: 92,
wordsPlacement: "beside",
notesGap: { nominal: 14, min: 8 },
fromW: 245,
payTo: { where: "under-client", label: "Pay to:", inset: 0 },
signatureW: 200,
payGap: 12,
chromeInset: 17.89,
footerLeftW: 150,
signatureImageH: 56,
};
export const linea: TemplateDefinition = {
...catalogEntry("linea"),
version: 1,
roles: LINEA_ROLES,
margins: LINEA_VARIANT.margins,
marginFloor: LINEA_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: INK,
rule: null,
role: LINEA_ROLES.footer,
inset: { left: LINEA_VARIANT.chromeInset, right: LINEA_VARIANT.chromeInset },
reserve: FOOTER_RESERVE,
leftWidth: LINEA_VARIANT.footerLeftW,
},
logo: {
box: { w: 150, h: 48 },
stackBelowTextWidth: 0,
defaultOn: false,
optIn: catalogEntry("linea").logoOptIn === true,
slotWidth: (frame) => frame.contentWidth,
slot: slotFor("linea"),
},
Layout: NeutralLayoutFor(LINEA_VARIANT),
};
+99
View File
@@ -0,0 +1,99 @@
import { defineRoles } from "../../fonts/roles";
import { FOOTER_RESERVE } from "./constants";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NeutralLayoutFor } from "./layoutFor";
import type { NeutralRoles, NeutralVariant } from "./variant";
const INK = "#3D3B3A";
const F = "Open Sans" as const;
/**
* Monogram's type: Open Sans, bold tracked caps for titles and labels (0.10 em), Medium for address
* lines. Data text is tracked 0.05 em, not the design's 0.10 em: wider tracking makes PDF text extraction
* split words (docs/templates/05-monogram.md; layout rule 7).
* Open Sans ascent 1.069, descent 0.293, cap height 0.714.
*/
export const MONOGRAM_ROLES = defineRoles({
title: { face: F, weight: 700, size: 38, leading: 52, tracking: 0.1, caps: true },
label: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.1, caps: true },
body: { face: F, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
bodyStrong: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.05 },
address: { face: F, weight: 500, size: 10, leading: 13.5, tracking: 0.05 },
tableHead: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.1, caps: true },
tableBody: { face: F, weight: 400, size: 10, leading: 13.5, tracking: 0.05 },
subtotal: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
total: { face: F, weight: 700, size: 10, leading: 13.5, tracking: 0.05, caps: true },
small: { face: F, weight: 400, size: 9, leading: 12.5, tracking: 0.05 },
wordsLabel: { face: F, weight: 700, size: 8, leading: 11, tracking: 0.1, caps: true },
fine: { face: F, weight: 400, size: 8, leading: 11, tracking: 0.05 },
initials: { face: F, weight: 700, size: 46, leading: 52, tracking: 0.02, caps: true },
footer: { face: F, weight: 400, size: 7 },
} satisfies NeutralRoles);
/**
* Canva A4 numbers: the rules run from the margin (x 69.64) and the title sits flush with them; text
* is 7.8 pt inside. The logo box starts 23.25 pt below the 58 pt top margin (y 81.25).
*/
export const MONOGRAM_VARIANT: NeutralVariant = {
id: "monogram",
roles: MONOGRAM_ROLES,
ink: INK,
margins: { top: 58, right: 69.64, bottom: 60, left: 69.64 },
marginFloor: { top: 36, right: 36, bottom: 36, left: 36 },
ruleInset: 0,
padX: 7.8,
textInset: { left: 7.8, right: 7.8 },
rule: 0.75,
topGap: { nominal: 23.25, min: 8 },
fromGap: 7,
bandToInfo: 233.48,
infoMinGap: 14,
tableGap: { nominal: 47.5, min: 24 },
headTail: { before: 3.57, after: 7.06 },
padY: 5.72,
closeGap: 7.98,
subtotalPad: 8.06,
totalsRowGap: 5.1,
labelGap: 8,
hsnMode: "column",
cols: { hsn: 72, rate: 96, qty: 44, amount: 88, descMin: 80 },
totalsW: 236,
amountW: 92,
wordsPlacement: "beside",
notesGap: { nominal: 14, min: 8 },
fromW: 240,
payTo: { where: "closing", label: "Payment info:", inset: 0 },
signatureW: 170,
payGap: 0,
chromeInset: 7.8,
footerLeftW: 150,
signatureImageH: 60,
};
export const monogram: TemplateDefinition = {
...catalogEntry("monogram"),
version: 1,
roles: MONOGRAM_ROLES,
margins: MONOGRAM_VARIANT.margins,
marginFloor: MONOGRAM_VARIANT.marginFloor,
decor: { ink: INK },
chrome: {
color: INK,
rule: null,
role: MONOGRAM_ROLES.footer,
inset: { left: MONOGRAM_VARIANT.chromeInset, right: MONOGRAM_VARIANT.chromeInset },
reserve: FOOTER_RESERVE,
leftWidth: MONOGRAM_VARIANT.footerLeftW,
},
logo: {
box: { w: 142.75, h: 116.5 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: (frame) => Math.min(142.75, Math.floor(frame.contentWidth * 0.4)),
slot: slotFor("monogram"),
available: (frame) => ({ w: Math.min(142.75, Math.floor(frame.contentWidth * 0.4)), h: 116.5 }),
},
Layout: NeutralLayoutFor(MONOGRAM_VARIANT),
};
+236
View File
@@ -0,0 +1,236 @@
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 { marginsFor } from "../contract";
import { TEMPLATE_CATALOG, catalogEntry } from "../catalog";
import { buildFixture, ensureSetup, FIXTURES, prefsFor, renderFixture } from "../harness";
import { DEFAULT_TEMPLATE_ID, TEMPLATES, getTemplate } from "../registry";
import { classic } from "../classic";
import { elastic, geometry, initialsOf, planNeutral, rateCell } from "./plan";
import { linea, LINEA_VARIANT } from "./linea";
import { monogram, MONOGRAM_VARIANT } from "./monogram";
import { serenity, SERENITY_VARIANT } from "./serenity";
import { getMeasurer } from "../../engine/measure";
ensureSetup();
const NEUTRAL = [linea, monogram, serenity];
const VARIANTS = { linea: LINEA_VARIANT, monogram: MONOGRAM_VARIANT, serenity: SERENITY_VARIANT } as const;
const nodes = (layout: LayoutNode[]) => {
const out: LayoutNode[] = [];
walk(layout, (n) => out.push(n));
return out;
};
describe("registry and catalog", () => {
it("lists Classic first and Linea second, then Monogram and Serenity", () => {
expect(Object.keys(TEMPLATES)).toEqual(["classic", "linea", "monogram", "serenity"]);
expect(TEMPLATE_CATALOG.map((e) => e.id)).toEqual(Object.keys(TEMPLATES));
});
it("falls back to Classic for an unknown, missing or prototype-key id", () => {
for (const id of ["no-such-template", undefined, "", "constructor", "__proto__", "toString"]) expect(getTemplate(id), String(id)).toBe(classic);
expect(getTemplate("serenity")).toBe(serenity);
expect(DEFAULT_TEMPLATE_ID).toBe("classic");
expect(catalogEntry("nope").id).toBe("classic");
});
it("the catalog and the definitions agree, and every thumbnail is on disk", () => {
for (const entry of TEMPLATE_CATALOG) {
const def = TEMPLATES[entry.id];
expect(def.name).toBe(entry.name);
expect(def.family).toBe(entry.family);
expect(def.thumbnail).toBe(entry.thumbnail);
expect(def.badge).toBe(entry.badge);
expect(existsSync(path.join(process.cwd(), "public", entry.thumbnail)), entry.thumbnail).toBe(true);
}
});
it("only Linea carries the badge and the opt-in logo", () => {
expect(TEMPLATE_CATALOG.filter((e) => e.badge).map((e) => e.id)).toEqual(["linea"]);
expect(linea.badge).toBe("Matches your original format");
expect(linea.logo.optIn).toBe(true);
expect(linea.logo.defaultOn).toBe(false);
expect(monogram.logo.defaultOn && serenity.logo.defaultOn).toBe(true);
expect(monogram.logo.slot?.mark).toBeTruthy();
expect(serenity.logo.slot?.mark).toBeTruthy();
});
it("every Neutral template declares its own role table in a bundled face", () => {
expect(linea.roles.body.face).toBe("Montserrat");
expect(monogram.roles.body.face).toBe("Open Sans");
expect(serenity.roles.body.face).toBe("Open Sans");
expect(serenity.roles).not.toBe(monogram.roles);
for (const t of NEUTRAL) for (const [name, token] of Object.entries(t.roles)) expect(token.size, `${t.id}.${name}`).toBeGreaterThan(0);
});
});
describe("page geometry", () => {
it("template margins and floors", () => {
expect(marginsFor(linea, "template")).toEqual({ top: 56, right: 59.55, bottom: 60, left: 59.55 });
expect(marginsFor(monogram, "template")).toEqual({ top: 58, right: 69.64, bottom: 60, left: 69.64 });
expect(marginsFor(serenity, "template")).toEqual({ top: 56, right: 64.1, bottom: 60, left: 162.91 });
// Serenity's left margin never goes under the decorative band, whatever the preset.
expect(marginsFor(serenity, "narrow").left).toBe(162.91);
expect(marginsFor(serenity, "normal").left).toBe(162.91);
// Narrow and Normal are absolute presets, only lifted to the floors (36 pt here).
expect(marginsFor(linea, "narrow")).toEqual({ top: 36, right: 36, bottom: 36, left: 36 });
expect(marginsFor(monogram, "normal")).toEqual({ top: 54, right: 54, bottom: 54, left: 54 });
});
it("the rules sit where the Canva ones do on A4 (x 69.64-525.86 for Linea and Monogram, 162.91-531.39 for Serenity)", () => {
for (const [t, left, right] of [
[linea, 69.64, 525.86],
[monogram, 69.64, 525.86],
[serenity, 162.91, 531.39],
] as const) {
const frame = frameForPrefs(prefsFor("A4", { templateId: t.id }));
const v = VARIANTS[t.id as keyof typeof VARIANTS];
const g = geometry(v, frame);
const x0 = frame.pad.left + v.ruleInset;
// Canva's page is 0.22 pt wider than A4, so the right end is allowed that much.
expect(Math.abs(x0 - left), t.id).toBeLessThanOrEqual(0.3);
expect(Math.abs(x0 + g.ruleW - right), t.id).toBeLessThanOrEqual(0.3);
expect(frame.pad.bottom - frame.footerReserve).toBe(60);
}
});
it("Letter keeps the physical margins and gives the extra width to the content", () => {
for (const t of NEUTRAL) {
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);
expect(letter.heightDeficit).toBeCloseTo(49.89, 2);
}
});
it("elastic gaps give back exactly the deficit and never pass their minimum", () => {
const ranges = { a: { nominal: 70, min: 34 }, b: { nominal: 40, min: 20 }, c: { nominal: 50, min: 24 } };
expect(elastic(ranges, 0)).toEqual({ a: 70, b: 40, c: 50 });
const e = elastic(ranges, 49.89);
const given = 160 - (e.a + e.b + e.c);
expect(given).toBeCloseTo(49.89, 1);
expect(e.a).toBeGreaterThanOrEqual(34);
expect(elastic(ranges, 1000)).toEqual({ a: 34, b: 20, c: 24 });
});
});
describe("helpers", () => {
it("initialsOf takes the first and last word", () => {
expect(initialsOf("Test Vendor")).toBe("AP");
expect(initialsOf("Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt")).toBe("AP");
expect(initialsOf("Bongbetic")).toBe("B");
expect(initialsOf(" ")).toBe("");
expect(initialsOf("& Co")).toBe("C");
});
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
const body = linea.roles.tableBody;
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr");
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]);
expect(rateCell(null, 90, body)).toBe("—");
expect(getMeasurer()).toBeTruthy();
});
});
describe("what the layout does with a logo", () => {
const withLogo = { name: "logo", items: 2, logo: "real" as const };
const imageCount = (r: Awaited<ReturnType<typeof renderFixture>>) => nodes(r.result.core.layout[0].nodes).filter((n) => n.type === "image" && !n.decor).length;
it("Monogram and Serenity draw the logo inside the slot; with no logo nothing, or the initials", async () => {
for (const t of [monogram, serenity]) {
const r = await renderFixture(withLogo, prefsFor("A4", { templateId: t.id }));
expect(imageCount(r), t.id).toBe(1);
const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.x + img.box.w, t.id).toBeLessThanOrEqual(frameForPrefs(r.prefs).page.width - frameForPrefs(r.prefs).pad.right + 0.5);
const none = await renderFixture({ ...withLogo, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(imageCount(none), t.id).toBe(0);
const text = none.pdf.pages[0].items.map((i) => i.str).join(" ");
if (t.id === "monogram") expect(text).toContain("AP");
// No Canva ring or stamp is ever drawn.
expect(nodes(none.result.core.layout[0].nodes).filter((n) => n.type === "svg" && !n.decor), t.id).toHaveLength(0);
}
});
it("Linea is logo-free until the user turns the logo on", async () => {
const off = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea" }));
expect(imageCount(off)).toBe(0);
const on = await renderFixture(withLogo, prefsFor("A4", { templateId: "linea", logo: { show: true } }));
expect(imageCount(on)).toBe(1);
const img = nodes(on.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.h).toBeLessThanOrEqual(48 + 0.01);
expect(img.box.w).toBeLessThanOrEqual(150 + 0.01);
expect(img.box.x).toBeCloseTo(59.55, 1);
});
it("a logo wide as a wordmark stays inside its box in Monogram, a tall one too", async () => {
for (const logo of ["wide", "square"] as const) {
const r = await renderFixture({ ...withLogo, logo }, prefsFor("A4", { templateId: "monogram" }));
const img = nodes(r.result.core.layout[0].nodes).find((n) => n.type === "image")!;
expect(img.box.w).toBeLessThanOrEqual(142.75 + 0.01);
expect(img.box.h).toBeLessThanOrEqual(116.5 + 0.01);
}
});
});
describe("the fonts the templates ask for are the ones embedded", () => {
it("Linea embeds Montserrat, Monogram and Serenity embed Open Sans, and none falls back to Helvetica", async () => {
for (const [t, family] of [
[linea, "Montserrat"],
[monogram, "OpenSans"],
[serenity, "OpenSans"],
] as const) {
const r = await renderFixture({ name: "fonts", items: 2, logo: "none" }, prefsFor("A4", { templateId: t.id }));
expect(r.pdf.raw, t.id).toContain(family);
expect(r.pdf.raw, t.id).not.toContain("/BaseFont /Helvetica");
}
});
});
describe("plan matches the laid-out blocks", () => {
// The totals tail and the closing block carry minPresenceAhead values predicted from the measurer;
// too low and a block can start a page without its spacer.
for (const t of NEUTRAL) {
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 { model } = buildFixture(spec);
const frame = frameForPrefs(prefs);
const r = await renderFixture(spec, prefs);
const logo = null;
void logo;
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];
};
// The same plan the layout used: logo-less here, so it only differs in the header (not asserted).
const plan = planNeutral(model, frame, VARIANTS[t.id as keyof typeof VARIANTS], null);
const tailGap = plan.tailH - find(TOTALS_ID);
expect(tailGap, "tail prediction vs layout").toBeGreaterThanOrEqual(-0.01);
// Wrap is predicted greedily and can only err by whole lines, on the safe side.
expect(tailGap).toBeLessThanOrEqual(15);
const closingGap = plan.closingH - find(CLOSING_ID);
expect(closingGap, "closing prediction vs layout").toBeGreaterThanOrEqual(-0.01);
expect(closingGap).toBeLessThanOrEqual(15);
});
}
}
}
});
describe("design docs", () => {
it("each Neutral doc records its implementation notes", () => {
for (const f of ["04-linea.md", "05-monogram.md", "06-serenity.md"]) {
const text = readFileSync(path.join(process.cwd(), "docs/templates", f), "utf8");
expect(text, f).toContain("## Implementation notes");
}
});
});
+360
View File
@@ -0,0 +1,360 @@
import { cols, type ColSpec } from "../../engine/columns";
import type { PageFrame } from "../../engine/geometry";
import { getMeasurer, measureToken } from "../../engine/measure";
import { textHeight, textLines } from "../../engine/textplan";
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import { guardPresence } from "../../blocks/TableGuard";
import type { RenderModel } from "../../model/build";
import type { PlacedLogo } from "../contract";
import type { GapRange, NeutralVariant } from "./variant";
export const CANCELLED_INK = "#a2191f";
export const CANCELLED_SENTENCE = "This invoice has been cancelled.";
export const INFO_GAP = 24;
export const CLIENT_LABEL_GAP = 2.47;
/** Space between the title's last glyph and the hairline that runs to the page edge (Linea). */
export const TITLE_RULE_GAP = 20;
export const TITLE_RULE_MIN = 40;
/** Space between the opt-in logo band and Linea's title box. */
export const LOGO_TITLE_GAP = 4.85;
export const META_VALUE_GAP = 18;
export const META_ROW_GAP = 1.5;
export const WORDS_GAP = 12;
export const CLOSING_GAP = 16;
/** Space above the amount in words when it sits beside the totals column, under the SUBTOTAL row. */
export const WORDS_TOP = 8;
export const SERENITY_LABEL_GAP = 7.5;
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Each range shrinks by the same share of its own slack until `deficit` is given back (Letter's missing height). */
export function elastic<K extends string>(ranges: Record<K, GapRange>, deficit: number): Record<K, number> {
const keys = Object.keys(ranges) as K[];
const slack = keys.reduce((sum, k) => sum + ranges[k].nominal - ranges[k].min, 0);
const share = slack > 0 ? Math.min(1, Math.max(0, deficit) / slack) : 0;
const out = {} as Record<K, number>;
for (const k of keys) out[k] = round2(ranges[k].nominal - share * (ranges[k].nominal - ranges[k].min));
return out;
}
export interface NeutralGeometry {
/** Content width of the page. */
W: number;
/** Table and rule width. */
ruleW: number;
/** Text column (client, meta, supplier). */
T: number;
/** Inner width of the totals: the rule width less the cell padding. */
innerW: number;
}
export function geometry(v: NeutralVariant, frame: Pick<PageFrame, "contentWidth">): NeutralGeometry {
const W = frame.contentWidth;
const ruleW = W - 2 * v.ruleInset;
return { W, ruleW, T: W - v.textInset.left - v.textInset.right, innerW: ruleW - 2 * v.padX };
}
export function initialsOf(name: string): string {
const words = name.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w));
const letter = (w: string) => Array.from(w.replace(/^[^\p{L}\p{N}]+/u, ""))[0] ?? "";
const picked = words.length >= 2 ? [words[0], words[words.length - 1]] : words.slice(0, 1);
return picked.map(letter).join("").toUpperCase();
}
/** Lines of the bank block as printed: bank name, account name, then the rest as "Label: value". */
export function bankLines(model: RenderModel): string[] {
const bank = model.bank;
if (!bank) return [];
const out: string[] = [];
for (const l of bank.lines) {
if (l.label === "Bank") {
out.push(l.value, `Account Name: ${bank.accountName}`);
} else out.push(`${l.label}: ${l.value}`);
}
if (!bank.lines.some((l) => l.label === "Bank")) out.unshift(`Account Name: ${bank.accountName}`);
return out;
}
export function signatureOptions(v: NeutralVariant): SignatoryOptions {
const r = v.roles;
return {
roles: { name: r.bodyStrong, designation: r.body, fine: r.fine },
align: "right",
rule: { width: v.rule, color: v.ink },
color: v.ink,
imageHeight: v.signatureImageH,
};
}
export interface MetaRowSpec {
label: string;
value: string;
strong: boolean;
}
export function metaRows(model: RenderModel): MetaRowSpec[] {
const rows: MetaRowSpec[] = [
{ label: model.meta.numberLabel === "Invoice No." ? "Invoice No:" : model.meta.numberLabel, value: model.meta.number, strong: true },
{ label: "Date:", value: model.meta.date, strong: false },
];
if (model.meta.dueDate) rows.push({ label: "Due Date:", value: model.meta.dueDate, strong: false });
if (model.client.poNumber) rows.push({ label: "PO No:", value: model.client.poNumber, strong: false });
return rows;
}
const SHORT_UNIT: Record<string, string> = { second: "/sec", minute: "/min", hour: "/hr", session: "/session", unit: "/unit" };
/**
* The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit
* ("per hour") on a second line, which keeps one-line rows one line tall.
*/
export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue {
if (!rateText) return "—";
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText);
if (!m) return rateText;
const short = `${m[1]}${SHORT_UNIT[m[2]]}`;
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`];
}
export interface NeutralPlan {
v: NeutralVariant;
geo: NeutralGeometry;
gaps: { top: number; table: number; notes: number; info: number };
/** Title tracking override when "TAX INVOICE" would not leave the hairline its minimum. */
titleToken: NeutralVariant["roles"]["title"];
titleW: number;
/** Header region height (title box, header row, logo box) that bandToInfo is measured from. */
headerH: number;
fromH: number;
/** Monogram: vertical offsets inside the header row. */
markTop: number;
titleTop: number;
metaW: number;
metaValueW: number;
columns: ColSpec[];
header: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
table: ItemsTablePlan;
guardH: number;
headerTailH: number;
tailH: number;
/** Words go beside the totals column (false: below it, full width). */
wordsBeside: boolean;
wordsW: number;
closingH: number;
payH: number;
signH: number;
}
/** Monogram's design offsets inside its header row (Canva A4): title box top and logo box top below the row top. */
const MONOGRAM_TITLE_TOP = 22.1;
/** Where the ring centre sat below the row top (139.5 - 81.25); the no-logo initials are centred there. */
const MONOGRAM_RING_CENTRE = 58.25;
const leading = (t: { leading?: number }) => t.leading as number;
/**
* Everything the layout must know before rendering: elastic gaps, header geometry and the predicted
* heights of the totals tail and the closing block (their minPresenceAhead values). Each prediction is
* deliberately a hair high: too low and a block can start a page without its spacer.
*/
export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVariant, logo: PlacedLogo | null): NeutralPlan {
const m = getMeasurer();
const r = v.roles;
const geo = geometry(v, frame);
const { W, T, ruleW, innerW } = geo;
const hsn = Slot.HsnSac(model);
// Narrow templates print the code as a small second line under the description instead of a column.
const hsnCol = hsn && v.hsnMode === "column" ? hsn : null;
// --- header ----------------------------------------------------------------------------------------
let titleToken = r.title;
let titleW = Math.ceil(measureToken(m, titleToken, model.docTitle)) + 1;
if (v.id === "linea" && W - titleW - TITLE_RULE_GAP < TITLE_RULE_MIN) {
titleToken = { ...r.title, tracking: 0.12 };
titleW = Math.ceil(measureToken(m, titleToken, model.docTitle)) + 1;
}
const logoH = logo?.h ?? 0;
let headerH: number;
let markTop = 0;
let titleTop = 0;
let topRange = v.topGap;
if (v.id === "linea") {
headerH = leading(r.title);
if (logo) topRange = { nominal: Math.max(v.topGap.nominal, logoH + LOGO_TITLE_GAP), min: Math.max(v.topGap.min, logoH + LOGO_TITLE_GAP) };
} else if (v.id === "monogram") {
titleTop = MONOGRAM_TITLE_TOP;
// The logo's offset below the row top is what computeLogoPlacement made of the slot's vertical alignment.
const mark = logo ? logoH : leading(r.initials);
markTop = logo ? (logo.slotOffset?.y ?? 0) : MONOGRAM_RING_CENTRE - leading(r.initials) / 2;
headerH = Math.max(titleTop + leading(r.title), markTop + mark);
} else {
markTop = logo?.slotOffset?.y ?? 0;
headerH = logo ? markTop + logoH : 0;
}
// --- supplier block ----------------------------------------------------------------------------------
const s = model.supplier;
const fromW = Math.min(v.fromW, T);
let fromH = textHeight(r.bodyStrong, s.name, fromW);
for (const l of s.addressLines) fromH += textHeight(r.address, l, fromW);
const idLines = [s.email, s.phone, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier");
fromH += idLines * leading(r.address);
const infoNominal = Math.max(v.infoMinGap, v.bandToInfo - headerH - v.fromGap - fromH);
const ranges = {
top: topRange,
info: { nominal: infoNominal, min: Math.max(v.infoMinGap, infoNominal - 20) },
table: v.tableGap,
notes: v.notesGap,
};
// --- info row: meta width ---------------------------------------------------------------------------
const rows = metaRows(model);
const labelMax = Math.max(...rows.map((x) => measureToken(m, r.label, x.label, { weight: x.strong ? r.label.weight : r.body.weight })));
const valMax = Math.max(54, ...rows.map((x) => measureToken(m, x.strong ? r.bodyStrong : r.body, x.value)));
const metaValueW = Math.min(Math.ceil(valMax) + 1, Math.floor(T * 0.45));
const metaW = Math.min(Math.max(143, Math.ceil(labelMax) + META_VALUE_GAP + metaValueW), Math.floor(T * 0.6));
// --- table -------------------------------------------------------------------------------------------
// Fixed columns grow to fit their widest single token (a money amount or HSN code cannot wrap), so
// the variant's widths are minimums. Descriptions take the rest.
const widest = (role: NeutralVariant["roles"]["tableBody"], 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.split(" ")))) + 2 * v.padX + 1);
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
const hsnHead = hsnCol?.header ?? "";
const c = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"),
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"),
amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
};
const columns: ColSpec[] = [
// No `min`: a table row must never stack its cells; the fixed columns' widths are what keeps this wide enough.
{ fill: true },
...(hsnCol ? [{ fixed: c.hsn } as ColSpec] : []),
{ fixed: c.rate },
{ fixed: c.qty },
{ fixed: c.amount },
];
const header = ["Description", ...(hsnCol ? [hsnCol.header] : []), v.id === "monogram" ? "Rate" : "Unit price", "Qty", "Total"];
const align: NeutralPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "center", "center", "right"];
const rateW = c.rate - 2 * v.padX;
const tableRows: TableCellValue[][] = model.items.map((item, i) => [
hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
...(hsnCol ? [hsnCol.values[i]] : []),
rateCell(item.rateText, rateW, r.tableBody),
item.qtyText,
item.amountText,
]);
// --- totals ------------------------------------------------------------------------------------------
const t = model.totals;
const rightRows = 1 + (t.discountText ? 1 : 0) + (t.taxableText ? 1 : 0) + slotHeights.taxRowCount(model);
const rowH = leading(r.body) + v.totalsRowGap;
const rightH = rightRows * rowH;
const wordsGap = 16;
const wordsBeside = v.wordsPlacement === "beside" && innerW - v.totalsW - wordsGap >= 150;
const wordsW = wordsBeside ? innerW - v.totalsW - wordsGap : innerW;
const wordsH = (wordsBeside ? WORDS_TOP : 0) + slotHeights.amountInWords(model, wordsW, { role: r.small, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
const leftH = wordsH + (reverseH ? 4 + reverseH : 0);
let tailH = v.closeGap + v.rule + v.subtotalPad + leading(r.subtotal);
tailH += wordsBeside ? Math.max(rightH, leftH) : rightH + (leftH ? WORDS_GAP + leftH : 0);
tailH += 1;
const headerTailH = v.headTail.before + v.rule + v.headTail.after;
const table = planItemsTable({
columns,
gap: 0,
width: ruleW,
header,
rows: tableRows,
measurer: m,
roles: { head: r.tableHead, body: r.tableBody },
padX: v.padX,
padY: v.padY,
border: 0,
headerTailH,
tailH,
contentH: frame.contentHeight,
});
const guardH = guardPresence(table.headH + 1, table.firstRowH, leading(r.tableBody));
// --- closing -----------------------------------------------------------------------------------------
const signOpts = signatureOptions(v);
const signW = v.signatureW;
let signH = signatoryHeight(model, signW, signOpts);
let payH = 0;
const lines = bankLines(model);
const payW = v.payTo.where === "closing" ? W - v.textInset.left - v.textInset.right - signW - 16 - v.payTo.inset : T;
if (model.bank) {
payH = leading(r.label) + CLIENT_LABEL_GAP;
for (const l of lines) payH += textHeight(r.address, l, payW);
}
if (v.id === "serenity") signH += leading(r.label) + 4;
let closingH = 0;
if (v.payTo.where === "closing") closingH = Math.max(payH, signH);
else closingH = signH;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
closingH += CLOSING_GAP + 1.5;
// --- elastic gaps --------------------------------------------------------------------------------------
// Letter gives its missing height back from the gaps (docs: top band, header-to-info gap). On any page a
// one-page invoice that overflows by less than the gaps' slack is squeezed the same way, so a typical
// invoice keeps its totals and signature on page 1 instead of spilling over for a few points.
const clientW = Math.max(60, T - metaW - INFO_GAP);
const c0 = model.client;
let clientH = leading(r.label) + CLIENT_LABEL_GAP + textHeight(r.address, c0.name, clientW);
for (const l of c0.addressLines) clientH += textHeight(r.address, l, clientW);
clientH += slotHeights.gstinLines(model, "client") * leading(r.address) + slotHeights.placeOfSupplyLines(model, r.address, clientW) * leading(r.address);
if (v.payTo.where === "under-client" && model.bank) {
clientH += v.payGap + leading(r.label) + CLIENT_LABEL_GAP;
for (const l of lines) clientH += textHeight(r.address, l, clientW);
}
const metaH = rows.length * (leading(r.body) + META_ROW_GAP);
const infoRowH = Math.max(clientH, metaH);
const labelH = v.id === "serenity" ? leading(r.label) + SERENITY_LABEL_GAP : 0;
const notesH = model.notesLines.length
? leading(r.label) + CLIENT_LABEL_GAP + model.notesLines.reduce((h, l) => h + textHeight(r.small, l, T), 0)
: 0;
const tableH = table.headH + table.rowHeights.reduce((a, b) => a + b, 0);
const nominalTotal =
ranges.top.nominal + headerH + v.fromGap + fromH + ranges.info.nominal + labelH + infoRowH + ranges.table.nominal + tableH + tailH + (notesH ? ranges.notes.nominal + notesH : 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);
return {
v,
geo,
gaps: { top: g.top, table: g.table, notes: g.notes, info: g.info },
titleToken,
titleW,
headerH,
fromH,
markTop,
titleTop,
metaW,
metaValueW,
columns,
header,
rows: tableRows,
align,
table,
guardH,
headerTailH,
tailH,
wordsBeside,
wordsW,
closingH,
payH,
signH,
};
}
export { cols, textLines };
+93
View File
@@ -0,0 +1,93 @@
import { defineRoles } from "../../fonts/roles";
import { scriptWordBounds, scriptWordDecor } from "../../decor/ScriptWord";
import { FOOTER_RESERVE } from "./constants";
import { catalogEntry } from "../catalog";
import type { TemplateDefinition } from "../contract";
import { slotFor } from "../slots.optin";
import { NeutralLayoutFor } from "./layoutFor";
import { MONOGRAM_ROLES } from "./monogram";
import type { NeutralRoles, NeutralVariant } from "./variant";
const INK = "#3D3B3A";
/** The paper colour measured from Canva's texture (its average); the texture itself is stock and is not bundled. */
const PAPER = "#ECE8E1";
/** Serenity shares Monogram's Open Sans type system (docs/templates/06-serenity.md); its own object so it can drift. */
export const SERENITY_ROLES = defineRoles({
...MONOGRAM_ROLES,
} satisfies NeutralRoles);
/** Letter keeps the band's top and takes this share of the page-height deficit off its position (docs: shift up about 32 pt). */
const BAND_SHIFT_SHARE = 0.64;
/**
* Canva A4 numbers: the rules start on the 162.91 pt left margin (the band occupies x 55-155), the
* stamp slot is 82 x 82 at y 131.75 on the right.
*/
export const SERENITY_VARIANT: NeutralVariant = {
id: "serenity",
roles: SERENITY_ROLES,
ink: INK,
margins: { top: 56, right: 64.1, bottom: 60, left: 162.91 },
// The left margin never goes under the band's right edge plus 8 pt, whatever the preset.
marginFloor: { top: 36, right: 36, bottom: 36, left: 162.91 },
ruleInset: 0,
padX: 8.9,
textInset: { left: 8.9, right: 6.7 },
rule: 0.75,
topGap: { nominal: 75.75, min: 40 },
fromGap: 12,
bandToInfo: 156.85,
infoMinGap: 14,
tableGap: { nominal: 61, min: 28 },
headTail: { before: 3.47, after: 6.98 },
padY: 5.8,
closeGap: 7.9,
subtotalPad: 8.06,
totalsRowGap: 5.1,
labelGap: 8,
hsnMode: "subline",
cols: { hsn: 0, rate: 90, qty: 36, amount: 80, descMin: 60 },
totalsW: 210,
amountW: 90,
wordsPlacement: "below",
notesGap: { nominal: 14, min: 8 },
fromW: 250,
payTo: { where: "closing", label: "Bank details", inset: 10.9 },
signatureW: 150,
payGap: 0,
chromeInset: 8.9,
footerLeftW: 130,
signatureImageH: 44,
};
export const serenity: TemplateDefinition = {
...catalogEntry("serenity"),
version: 1,
roles: SERENITY_ROLES,
margins: SERENITY_VARIANT.margins,
marginFloor: SERENITY_VARIANT.marginFloor,
decor: {
ink: INK,
background: PAPER,
everyPage: (frame) => scriptWordDecor(INK, frame.heightDeficit * BAND_SHIFT_SHARE),
keepOuts: (frame) => [scriptWordBounds(frame.heightDeficit * BAND_SHIFT_SHARE)],
},
chrome: {
color: INK,
rule: null,
role: SERENITY_ROLES.footer,
inset: { left: SERENITY_VARIANT.chromeInset, right: SERENITY_VARIANT.chromeInset },
reserve: FOOTER_RESERVE,
leftWidth: SERENITY_VARIANT.footerLeftW,
},
logo: {
box: { w: 82, h: 81.75 },
stackBelowTextWidth: 0,
defaultOn: true,
slotWidth: () => 82,
slot: slotFor("serenity"),
available: () => ({ w: 82, h: 81.75 }),
},
Layout: NeutralLayoutFor(SERENITY_VARIANT),
};
+98
View File
@@ -0,0 +1,98 @@
import type { Sides } from "../../engine/geometry";
import type { TypeToken } from "../../fonts/roles";
/** Type roles every Neutral template declares (Linea in Montserrat, Monogram and Serenity in Open Sans). */
export interface NeutralRoles {
title: TypeToken;
/** Bold, tracked caps: "ISSUED TO:", "PAY TO:", meta labels. */
label: TypeToken;
body: TypeToken;
bodyStrong: TypeToken;
/** Client, supplier and bank lines (Medium in the Open Sans templates). */
address: TypeToken;
tableHead: TypeToken;
tableBody: TypeToken;
/** The SUBTOTAL label and value (bold in Monogram and Serenity). */
subtotal: TypeToken;
/** The TOTAL label and value. */
total: TypeToken;
small: TypeToken;
wordsLabel: TypeToken;
fine: TypeToken;
/** Monogram's no-logo initials. */
initials: TypeToken;
/** Footer and continuation band: a render-prop role (no leading). */
footer: TypeToken;
}
export interface GapRange {
nominal: number;
min: number;
}
export type NeutralId = "linea" | "monogram" | "serenity";
/**
* Everything that differs between Linea, Monogram and Serenity. All y values are distances in pt
* measured on the Canva A4 page and converted into flow gaps; x values are relative to the content
* edge (the margin), so the same tokens serve every margin preset and page size.
*/
export interface NeutralVariant {
id: NeutralId;
roles: NeutralRoles;
ink: string;
margins: Sides;
marginFloor: Sides;
/** Content edge to where the table rules start (Linea's title and rule overhang the text column). */
ruleInset: number;
/** Horizontal padding of table cells: rule edge to text. */
padX: number;
/** Content edge to the text column (client, meta, supplier, totals). */
textInset: { left: number; right: number };
rule: number;
/** Space above the first header element (margin to title top, logo top ...); Letter takes some of it. */
topGap: GapRange;
/** Header anchor bottom to the supplier block. */
fromGap: number;
/** Design distance from the header anchor bottom to the first info line (supplier block included). */
bandToInfo: number;
/** Floor of the gap between the supplier block and the info row. */
infoMinGap: number;
/** Info row bottom to the table header. */
tableGap: GapRange;
/** The table rule's gap above and below (the header tail): header text bottom to rule, rule to first row. */
headTail: { before: number; after: number };
padY: number;
/** Last row bottom to the closing rule. */
closeGap: number;
/** Closing rule to the SUBTOTAL text top. */
subtotalPad: number;
/** Space between the stacked totals rows. */
totalsRowGap: number;
/** Distance of the tax/total column labels from the amounts column. */
labelGap: number;
/** HSN/SAC as a table column, or (narrow templates) a small line under each description. */
hsnMode: "column" | "subline";
cols: { hsn: number; rate: number; qty: number; amount: number; descMin: number };
/** Width of the right-anchored tax/total column (labels + amounts). */
totalsW: number;
amountW: number;
wordsPlacement: "beside" | "below";
notesGap: GapRange;
/** Width of the supplier (FROM) block, right-anchored. */
fromW: number;
/** Where PAY TO sits: under ISSUED TO (Linea) or in the closing row with the signature. */
payTo: { where: "under-client" | "closing"; label: string; inset: number };
/** Width of the signature column of the closing row. */
signatureW: number;
/** Gap between PAY TO and the info block above it (Linea). */
payGap: number;
/** Footer and continuation band inset inside the content width, each side. */
chromeInset: number;
/** Footer fitted-name width. */
footerLeftW: number;
/** Letter takes the page-height deficit from the elastic gaps only. */
signatureImageH: number;
}
+8 -1
View File
@@ -1,13 +1,20 @@
import type { TemplateDefinition } from "./contract"; import type { TemplateDefinition } from "./contract";
import { classic } from "./classic"; import { classic } from "./classic";
import { linea } from "./neutral/linea";
import { monogram } from "./neutral/monogram";
import { serenity } from "./neutral/serenity";
/** Picker order: Classic first, Linea (the vendor's original format) second. */
export const TEMPLATES: Record<string, TemplateDefinition> = { export const TEMPLATES: Record<string, TemplateDefinition> = {
classic, classic,
linea,
monogram,
serenity,
}; };
export const DEFAULT_TEMPLATE_ID = "classic"; export const DEFAULT_TEMPLATE_ID = "classic";
/** The template for an id; an unknown id (a template removed later, a corrupt pref) falls back to Classic. */ /** The template for an id; an unknown id (a template removed later, a corrupt pref) falls back to Classic. */
export function getTemplate(id: string | undefined): TemplateDefinition { export function getTemplate(id: string | undefined): TemplateDefinition {
return (id !== undefined ? TEMPLATES[id] : undefined) ?? TEMPLATES[DEFAULT_TEMPLATE_ID]; return (id !== undefined && Object.hasOwn(TEMPLATES, id) ? TEMPLATES[id] : undefined) ?? TEMPLATES[DEFAULT_TEMPLATE_ID];
} }
+9 -9
View File
@@ -8,15 +8,15 @@ export const SLOTS: Record<TemplateId, TemplateSlot | null> = {
"classic": null, "classic": null,
"marble": null, "marble": null,
"highlighter": null, "highlighter": null,
"monolith": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":513.25,"y":54.75,"w":46.75,"h":9},"markDensity":0,"lockupDensity":0.579,"anchor":"right","vAlign":"middle","darkSurface":false}, "monolith": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":513.25,"y":54.75,"w":46.75,"h":9},"markDensity":0,"lockupDensity":0.579,"anchor":"right","vAlign":"top","darkSurface":false,"textOnly":true,"allowance":{"x":400,"y":54.75,"w":160,"h":28}},
"linea": null, "linea": null,
"monogram": {"page":{"w":595.5,"h":842.25},"mark":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"lockup":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"markDensity":0.142,"lockupDensity":0.142,"anchor":"right","vAlign":"middle","darkSurface":false}, "monogram": {"page":{"w":595.5,"h":842.25},"mark":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"lockup":{"x":387.75,"y":81.25,"w":142.75,"h":116.5},"markDensity":0.142,"lockupDensity":0.142,"anchor":"right","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":387.75,"y":81.25,"w":142.75,"h":116.5}},
"serenity": {"page":{"w":595.5,"h":842.25},"mark":{"x":444.5,"y":131.75,"w":82,"h":81.75},"lockup":{"x":444.5,"y":131.75,"w":82,"h":81.75},"markDensity":0.069,"lockupDensity":0.069,"anchor":"right","vAlign":"middle","darkSurface":false}, "serenity": {"page":{"w":595.5,"h":842.25},"mark":{"x":444.5,"y":131.75,"w":82,"h":81.75},"lockup":{"x":444.5,"y":131.75,"w":82,"h":81.75},"markDensity":0.069,"lockupDensity":0.069,"anchor":"right","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":444.5,"y":131.75,"w":82,"h":81.75}},
"crimson-grid": {"page":{"w":595.5,"h":842.25},"mark":{"x":420.5,"y":53,"w":31.25,"h":45.75},"lockup":{"x":420.5,"y":53,"w":113.25,"h":45.75},"markDensity":0.466,"lockupDensity":0.261,"anchor":"right","vAlign":"bottom","darkSurface":false}, "crimson-grid": {"page":{"w":595.5,"h":842.25},"mark":{"x":420.5,"y":53,"w":31.25,"h":45.75},"lockup":{"x":420.5,"y":53,"w":113.25,"h":45.75},"markDensity":0.466,"lockupDensity":0.261,"anchor":"right","vAlign":"bottom","darkSurface":false,"textOnly":false,"allowance":{"x":420.5,"y":53,"w":113.25,"h":45.75}},
"cobalt-stripe": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":34.25,"h":34.25},"lockup":{"x":59.5,"y":59.5,"w":181.5,"h":34.25},"markDensity":0.479,"lockupDensity":0.287,"anchor":"left","vAlign":"middle","darkSurface":false}, "cobalt-stripe": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":34.25,"h":34.25},"lockup":{"x":59.5,"y":59.5,"w":181.5,"h":34.25},"markDensity":0.479,"lockupDensity":0.287,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":59.5,"y":59.5,"w":181.5,"h":34.25}},
"slate-band": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":55.75,"w":25,"h":31.25},"lockup":{"x":59.5,"y":55.75,"w":99.25,"h":31.25},"markDensity":0.492,"lockupDensity":0.181,"anchor":"left","vAlign":"middle","darkSurface":true}, "slate-band": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":55.75,"w":25,"h":31.25},"lockup":{"x":59.5,"y":55.75,"w":99.25,"h":31.25},"markDensity":0.492,"lockupDensity":0.181,"anchor":"left","vAlign":"middle","darkSurface":true,"textOnly":false,"allowance":{"x":59.5,"y":55.75,"w":99.25,"h":31.25}},
"teal-swoosh": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":41,"h":27},"lockup":{"x":59.5,"y":59.5,"w":125.75,"h":29.25},"markDensity":0.445,"lockupDensity":0.233,"anchor":"left","vAlign":"middle","darkSurface":false}, "teal-swoosh": {"page":{"w":595.5,"h":842.25},"mark":{"x":59.5,"y":59.5,"w":41,"h":27},"lockup":{"x":59.5,"y":59.5,"w":125.75,"h":29.25},"markDensity":0.445,"lockupDensity":0.233,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":59.5,"y":59.5,"w":125.75,"h":29.25}},
"tangerine-ledger": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":60.5,"y":69.75,"w":82.5,"h":14.75},"markDensity":0,"lockupDensity":0.486,"anchor":"left","vAlign":"middle","darkSurface":false}, "tangerine-ledger": {"page":{"w":595.5,"h":842.25},"mark":null,"lockup":{"x":60.5,"y":69.75,"w":82.5,"h":14.75},"markDensity":0,"lockupDensity":0.486,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":true,"allowance":{"x":60.5,"y":62,"w":220,"h":36}},
"purple-pop": null, "purple-pop": null,
"citrus-split": {"page":{"w":595.5,"h":842.25},"mark":{"x":337.5,"y":74.5,"w":34,"h":32.75},"lockup":{"x":337.5,"y":73.25,"w":144.5,"h":34},"markDensity":0.543,"lockupDensity":0.259,"anchor":"left","vAlign":"middle","darkSurface":false}, "citrus-split": {"page":{"w":595.5,"h":842.25},"mark":{"x":337.5,"y":74.5,"w":34,"h":32.75},"lockup":{"x":337.5,"y":73.25,"w":144.5,"h":34},"markDensity":0.543,"lockupDensity":0.259,"anchor":"left","vAlign":"middle","darkSurface":false,"textOnly":false,"allowance":{"x":337.5,"y":73.25,"w":144.5,"h":34}},
}; };
+2
View File
@@ -21,6 +21,8 @@ function slot(lockup: Box, anchor: TemplateSlot["anchor"], vAlign: TemplateSlot[
anchor, anchor,
vAlign, vAlign,
darkSurface: false, darkSurface: false,
textOnly: false,
allowance: lockup,
optIn: true, optIn: true,
defaultOn: false, defaultOn: false,
}; };
+7
View File
@@ -40,6 +40,13 @@ export interface TemplateSlot {
lockupDensity: number; lockupDensity: number;
anchor: SlotAnchor; anchor: SlotAnchor;
vAlign: SlotVAlign; vAlign: SlotVAlign;
/** The Canva placeholder is plain text (a brand name), so its own box is far too small for a real logo. */
textOnly: boolean;
/**
* The box a logo may fill. For a text-only placeholder it is an allowance from the template docs
* (the text's anchor edge kept, room for a legible logo); otherwise it equals the lockup box.
*/
allowance: Box;
/** The slot sits on a dark fill: use the knockout logo, or a white chip behind a logo without alpha. */ /** The slot sits on a dark fill: use the knockout logo, or a white chip behind a logo without alpha. */
darkSurface: boolean; darkSurface: boolean;
/** The template has no placeholder; the position is offered as an option. */ /** The template has no placeholder; the position is offered as an option. */
+71
View File
@@ -198,6 +198,10 @@ body {
gap: 1rem; gap: 1rem;
} }
.voiced-page-setup__extra {
margin-top: 0.75rem;
}
.voiced-page-setup__field { .voiced-page-setup__field {
min-width: 11rem; min-width: 11rem;
flex: 1 1 11rem; flex: 1 1 11rem;
@@ -221,6 +225,73 @@ body {
flex: 1 1 12rem; flex: 1 1 12rem;
} }
.voiced-template-picker__note {
margin-bottom: 1rem;
font-size: 0.875rem;
}
.voiced-template-picker__status {
flex: 1 1 auto;
align-self: center;
padding-left: 1rem;
font-size: 0.75rem;
}
.voiced-template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 1rem;
}
.voiced-template-tile {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
.voiced-template-tile--selected {
outline: 2px solid var(--cds-focus);
outline-offset: -2px;
}
.voiced-template-tile__thumb {
display: block;
width: 100%;
aspect-ratio: 420 / 594;
object-fit: cover;
border: 1px solid var(--cds-border-subtle);
background: var(--cds-layer);
}
.voiced-template-tile__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.voiced-template-tile__name {
font-weight: 600;
}
.voiced-template-tile__check {
flex: none;
fill: var(--cds-support-success);
}
.voiced-template-tile__tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.voiced-template-tile__tags .cds--tag {
margin: 0;
}
.voiced-pdf-preview { .voiced-pdf-preview {
display: flex; display: flex;
flex-direction: column; flex-direction: column;