Author SHA1 Message Date
xavierk 8dc4d85e14 Verify the ERPNext integration live and fix the defects it found (Phase F4)
Ran the push flow against ERPNext 15.121.6 on a plain site and on a site with India Compliance 15.32.0 (podman, scripts/erpnext-e2e). 15 ignored live tests pass on both.

Fixes, each covered by a mock-based test:
- load_options no longer fails on a plain site: the India Compliance-only gstin field is dropped on a 417 and retried.
- Code-less rows with fractional hours or minutes send stock_uom, since ERPNext defaults it to Nos.
- A created document whose total differs from Voiced's (ERPNext's default Banker's Rounding on half-paise ties) is recorded as a conflict, kept as a draft, not submitted and not given a PDF. The message names Commercial Rounding as the fix.
- An existing customer address is reused instead of creating a duplicate on every first push. A bare creation order-by is not used with a Dynamic Link filter.
- A re-adopted document no longer gets a second copy of the PDF.
- Reverse charge: India Compliance rejects is_reverse_charge unless tax rows are negative RCM amounts, so the flag is sent as 0 with a warning that the invoice lands as a normal taxed invoice.
- The 16-character name rule is checked locally in mirror mode under India Compliance, and a TDS-only payment is refused locally with the reason.

Payment Entry mapping verified: bank_account is the Account name (paid_to), paid and received amounts equal the cash received, allocated_amount is cash plus TDS, and TDS is one positive deductions row. The integration user needs the Accounts User and Sales User roles.

Not verified: ERPNext v14 and v16, other India Compliance versions, the Tauri commands and UI against a live site, SEZ and overseas customers, UTGST supplier states, TLS sites, e-invoicing.
2026-10-04 22:09:03 +05:30
xavierk 2ffbf0f66c Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)
- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save.
- Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send.
- History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured.
- Optional auto-push after issue runs in the background and never blocks or fails the issue.
- Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string.

The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
2026-10-04 20:57:31 +05:30
xavierk d3b5fbb1b8 Add ERPNext push logic for invoices and payments (Phase F2)
- ErpnextSink implements InvoiceSink. Pushing an issued invoice ensures the Customer and Address (read-back of the returned name, local PIN/state/GSTIN checks), creates the Sales Invoice, optionally submits it in a second call, and attaches the archived PDF as a private file once.
- Idempotent: a mirrored-name 409 is compared by grand total (same total records as synced, different total is a conflict and never overwrites); series mode looks up an existing document by its remarks before re-posting; an identical re-push sends nothing.
- Create and submit are separate calls so a draft survives a failed submit. A failed push is recorded in erpnext_sync with a readable message. Bulk push never aborts on one failing row.
- Payment push requires a submitted parent, maps TDS to Payment Entry deductions and is idempotent through the stored entry name.
- Migration M11 rebuilds erpnext_sync to allow a conflict status and adds attachment_sha256.
- Commands for single and bulk push, payment push, sync status and the open-in-ERPNext URL, with typed wrappers. No UI yet.

Tested against a mock server only. The Payment Entry deductions fields, the series-mode submit route and the payment amount semantics need a live ERPNext check.
2026-10-04 20:33:45 +05:30
xavierk 86b4d436fa Add the ERPNext integration foundation (Phase F1)
- New integrations module with an InvoiceSink seam and an ERPNext client built on reqwest with rustls (ring provider only; aws-lc is not in the dependency graph). Token auth, timeouts, retry with backoff on 429, 5xx and timeouts (POSTs only when idempotent), optional extra CA, and a rule that plain http is allowed only for localhost-style hosts.
- Error extraction for Frappe v1 and v2 bodies, version and India Compliance discovery, live option loaders, and a connection test that returns warnings instead of failing.
- Pure, snapshot-tested mapping from a stored invoice to a Sales Invoice (CGST+SGST, IGST, unregistered, discount, code-less rows, mirrored and series naming) that refuses to produce a payload whose totals differ from Voiced's.
- Migration M10 adds erpnext_config (the API secret is never returned to the webview), erpnext_sync and the nullable ERPNext link columns.
- Commands to get and save the config, test the connection and load options, with typed wrappers. No UI and no push yet.

The API secret is stored in the SQLite file, so backups contain it. Nothing has run against a live ERPNext yet.
2026-10-04 19:50:24 +05:30
xavierk 92f952b136 Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 18:13:57 +05:30
xavierk 1623b879e2 Add payments with TDS, invoice detail view and filterable History (Phase E2)
- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary.
- New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted.
- History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page.
- Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 17:49:25 +05:30
xavierk 3510cc4260 Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- Global Carbon toast stack replaces scattered inline notices; errors persist until closed.
- Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings.
- New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot.
- Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular.
- New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control.
- Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 17:29:46 +05:30
xavierk 9e17ba3479 Fix Monolith and Cobalt Stripe closing-block squeeze; guard Monolith flow notes
When only the closing block moved to page 2, the page-1 deficit was shared across every gap, including the closing block's own gaps, which cannot help page 1. Page 1 then came out a few points short and the totals spilled over with the closing block. Monolith and Cobalt Stripe now squeeze only the page-1 gaps in that case, as Marble and Highlighter already do.

Monolith's flow notes block could start in the last few points of a page and be cut into zero-height lines. It now holds 70 pt of room under its label first, as Marble does.

Adds tests for both cases (they failed before the fix) and notes in the template specs. Fingerprint goldens and thumbnails are unchanged.
2026-10-04 16:46:22 +05:30
xavierk afbc0aa56f Add font import for Now, Gotham and Open Sauce One overrides; package fonts for Void
Font import:
- Migration M6 adds user_fonts; files are stored content-addressed under
  the local data dir. New Rust commands inspect, import, list, read and
  remove fonts, refusing WOFF2, collections, variable fonts and
  restricted fsType, and unpacking WOFF1 to plain sfnt.
- Imported fonts register in the render worker as user:<Face> families
  with an italic alias, and are pinned in the frozen render prefs so
  issued invoices can still re-render. Fonts referenced by issued
  invoices are hidden, never deleted.
- Size and tracking compensations tuned for the substitutes apply only
  while the face is not overridden; default output and fingerprint
  goldens are unchanged.
- Settings gets a Fonts section with a licence acknowledgement.

Void packaging:
- Install fonts to /usr/share/fonts/voiced with per-family OFL licences,
  set font_dirs, and add INSTALL/REMOVE scripts that run fc-cache.
  xbps-create carries both scripts (verified in a void-glibc container).
- Fix xbps-create dependency specs in build-xbps.sh.
2026-10-04 13:58:06 +05:30
xavierk 4d18a50753 Stop tracking .commandcode; keep it local via .gitignore 2026-10-04 13:16:31 +05:30
xavierk ad10e2cacd Stop tracking CLAUDE.md and handoff.md; keep them local via .gitignore 2026-10-04 13:13:30 +05:30
xavierk 63854dd960 Fix glyph cache poisoning across renders; add CLAUDE.md and handoff.md
fontkit caches glyphs per font for the whole process and pdfkit embeds composite
components with no code points, so setting ':' before '.' in Poppins corrupted every
later render in the same process (text layer showed '1,200;00', line breaks shifted).
primeFontGlyphs() now creates the component glyphs with their cmap code point at font
registration, awaited by renderCore and browser init. Regression test and Quirk 13 added;
the warm-up workarounds in the template tests are removed.
2026-10-04 13:11:40 +05:30
xavierk 76533ad2c7 Add the Marble and Highlighter templates, completing the 13 reference templates
Marble uses a flat-to-gradient band in place of the marble photo and bundles Lustria (OFL).
Both logo slots are opt-in. Quirk 13 documents a fontkit glyph-cache issue that the
tests work around with warm-up renders; the engine fix follows separately.
2026-10-04 12:47:05 +05:30
xavierk dd85c3b3c5 Add the Monolith and Cobalt Stripe templates
Standalone layouts for reference templates 3 and 8, with the user's logo sized to the
placeholder box, a CapRule decor primitive, optional TableRows rule/heading options,
and a separate 1..45-row sweep test file.
2026-10-04 11:25:19 +05:30
xavierk 4cea3fdd8e Add the Northline template family: Purple Pop and Citrus Split
Purple Pop (cream page, purple pills, asterisk and dome decor, opt-in
logo) and Citrus Split (grey split panel, halftone chevrons, icon
contacts, logo in the measured slot) are rebuilt from the reference PDFs on
the engine with A4/Letter support, GST slots and zero audit errors. Adds
static DM Sans, Jost and Poppins 800 as look-alikes for the commercial
fonts. The picker now lists ten templates.
2026-10-04 09:56:56 +05:30
xavierk 9de9a6ffee Add Ledger and Band template families with decor primitives
Crimson Grid, Tangerine Ledger, Slate Band and Teal Swoosh are rebuilt from
the reference PDFs with grid and band tables that survive page breaks,
full-bleed fixed bands, pennant tabs, gradient corner swooshes with
keep-outs, Carbon icon circles, and the user's logo in the measured slot
(knockout or white chip on the dark Slate band). Adds static Poppins
500-700 and Inter fonts. Thumbnails regenerated for all eight templates;
the item-count sweeps run in their own test file.
2026-10-04 08:34:18 +05:30
xavierk aa7a407944 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 reference 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.
2026-10-04 07:27:12 +05:30
xavierk eddf27629f Measure reference logo placeholders and add slot-based logo placement
scripts/logo/measure-placeholders rasterises the reference PDFs and measures
each logo placeholder's ink box and density into slots.generated.ts. The
new placement contains the logo in the measured box, shrinks denser logos
by up to 30 percent to match visual weight, picks knockout or a white chip
on dark surfaces, and passes an identity test per template. A contact
sheet compares each placeholder with the Test Vendor logo and three test logos.
2026-10-04 06:40:10 +05:30
xavierk e1edf02448 Add Rust logo pipeline: trim, metrics, knockout and Branding settings
Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled
to 1200 px and re-encoded as a content-addressed print PNG plus a white
knockout variant when the logo has alpha; aspect, ink density, mean colour
and kind (wordmark, mark, tall) are stored with the settings and frozen in
the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo
PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with
white and dark previews and a business-name toggle (migration M5).
2026-10-04 06:31:30 +05:30
xavierk 3c5fd617cd Add per-template specifications measured from the reference PDFs 2026-10-04 06:17:43 +05:30
xavierk 92904f169c Add unattended end-to-end self-test mode for the production webview
VOICED_SELFTEST_OUT runs the Diagnostics checks plus an issue, archive,
export (searchable, flattened, re-render), cancel and archive-immutability
flow through the real IPC, writes a JSON report and exits. Inert without
the variable; doubles as the per-release gate on installed builds.
2026-10-04 06:17:04 +05:30
xavierk f1b7502e9d Add searchable and flattened export, history re-export and diagnostics self-test
issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
2026-10-04 06:11:23 +05:30
xavierk 7162dac6e7 Add PDF archive store, raw-body IPC and export file helpers
The searchable PDF can be archived content-addressed under the local data
dir with its sha256 and layout fingerprint; a different PDF for an archived
invoice is refused and reads verify the hash. Export writes go through a
raw-body command that remembers the last folder; reveal-in-folder and open
fall back to xdg-open / explorer. Adds a Windows-safe export file name
builder. Migration M4 adds the archive columns and last export dir.
2026-10-04 05:54:26 +05:30
xavierk cedf4839bd Replace the HTML preview with a worker-rendered PDF preview
Rendering moves into a module Web Worker behind a latest-wins RenderClient
with a watchdog and a handshake-based main-thread fallback. The preview
shows the real PDF through pdf.js at 2x with zoom, page indicator, audit
issues popover and a focus modal. A4/Letter and margin controls are frozen
into each invoice at issue and can be saved as the default (migration M3).
react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB
to 0.5 MB. The HTML twin and its styles are removed.
2026-10-04 05:47:19 +05:30
xavierk a014a907e4 Rebuild the Classic template on the engine with A4/Letter and a template harness
Classic now renders through buildRenderModel, computeFrame, the template
and renderCore, with S.No and HSN/SAC columns, GST slots only through
checked Slot components, taxable value, reverse-charge line, cancelled tag,
continuation band and Page n of N. Letter absorbs its height deficit in
elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item
sweep and asserts zero audit errors, repeated headers, no split blocks,
rupee extraction and deterministic output. The legacy InvoiceDocument is
removed.
2026-10-04 05:31:14 +05:30
xavierk 8d4f8e666b Add pagination blocks, renderCore, layout audit and react-pdf quirk tests
TableGuard, BottomSpacer, FirstPageHeader and PageChrome implement the
verified pagination rules; renderCore returns bytes, a normalised layout
tree, audit issues and a layout fingerprint. auditLayout detects overlaps,
ink overflow, truncation, header-only pages and split blocks. Quirk tests
pin the react-pdf behaviours the design depends on, and a child-process
test pins the page-level absolute Svg hang.
2026-10-04 05:05:51 +05:30
xavierk 40aa3e05d9 Add typography roles, text primitives, fontkit measurer and layout lint
Txt always sets an absolute line height; RenderTxt never does. Row pins
every cell and stacks when the fill column would be too narrow. IdText and
FitText shrink then chunk or truncate. The fontkit measurer matches react-pdf
text widths exactly. A TypeScript-AST lint test enforces the layout rules
(no lineHeight outside Txt, no non-literal fixed/wrap/minPresenceAhead,
flexGrow needs flexBasis, no stray absolute positioning).
2026-10-04 04:48:19 +05:30
xavierk 92ba3d9590 Add bundled TTF fonts, page geometry, column allocator and render model
Static TTF fonts (IBM Plex Sans/Mono, Noto Devanagari/Kannada) with a
fontkit-generated manifest, a verifier and OFL licences. Every family
registers a normal and italic source so fallback stacks cannot throw.
Adds A4/Letter geometry with margin presets, an exact column allocator,
deterministic Indian-grouping money and date formatting, text sanitising
and a pure buildRenderModel.
2026-10-04 04:38:03 +05:30
xavierk fbed18a0bd Add invoice drafts, read-only issued state and keyboard shortcuts
Views stay mounted so switching tabs no longer loses a half-typed invoice.
The form autosaves a draft after 1 s, drafts can be resumed or deleted,
and after issuing the form is read-only with Export again, Duplicate and
New invoice actions. A failed export keeps the issued invoice and offers a
retry instead of issuing again. Adds Ctrl+N/S/Enter/L and a shortcut sheet;
F5 and Ctrl+R are blocked in production builds.
2026-10-04 04:21:47 +05:30
xavierk 5597fb791d Add issue/cancel lifecycle, GST derivation and integer-paise totals
issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
2026-10-04 04:15:58 +05:30
xavierk d6ec6b2a17 Add versioned DB migrations, startup error dialog and native basics
rusqlite_migration with a VACUUM INTO backup before upgrading; M2 adds GST
registration, snapshot, cancellation and draft columns and backfills
snapshots for existing invoices. Startup failures now show a native dialog
instead of exiting silently. Adds single-instance and window-state plugins,
minimum window 960x600, embedBootstrapper, DMABUF workaround on Linux and
xbps dependency updates.
2026-10-04 04:04:34 +05:30
390 changed files with 57936 additions and 1914 deletions
View File
+5
View File
@@ -26,3 +26,8 @@ xwin/
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
# Local agent notes (kept on disk, not in the repo)
CLAUDE.md
handoff.md
.commandcode/
+9 -1
View File
@@ -108,12 +108,20 @@ Launch **Voiced** from the application menu, or run `voiced`. Remove it with
The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on The package installs `/usr/bin/voiced`, a desktop entry and hicolor icons, and depends on
`libwebkit2gtk41`. `libwebkit2gtk41`.
**Fonts on Void.** The package also installs the bundled TTF fonts under
`/usr/share/fonts/voiced/<family>/` and each family's OFL licence under
`/usr/share/licenses/voiced/`. The `voiced/` subdirectory avoids file conflicts with Void's own
`font-ibm-plex-ttf` and `noto-fonts-ttf`. The `INSTALL` and `REMOVE` scripts in `packaging/void/`
run `fc-cache -f /usr/share/fonts/voiced` after install and removal. `xbps-create` packs them from
the destdir root (checked with xbps 0.60.7).
### Building through xbps-src ### Building through xbps-src
Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way: Copy `packaging/void/template` into a `void-packages` checkout and build it the standard way:
```bash ```bash
cp -r packaging/void/template void-packages/srcpkgs/voiced/template mkdir -p void-packages/srcpkgs/voiced
cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE void-packages/srcpkgs/voiced/
cd void-packages && ./xbps-src pkg voiced cd void-packages && ./xbps-src pkg voiced
``` ```
+180
View File
@@ -0,0 +1,180 @@
# Template 1: Marble (`marble`)
Source: `templates/reference/1.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Airy editorial invoice. Letter-spaced Lustria serif caps (right-aligned 'INVOICE #1234', column heads, 'AMOUNT DUE'), a light geometric sans body (Now), pale-grey zebra rows, two mauve-grey hairlines around the parties band, and photographic white-marble bands at the top (56pt) and bottom (130pt). The bottom band carries the vendor contact footer. All text is pure black with no accent colour.
## Fonts
Measured from the embedded subsets. Tracking is built into per-glyph Td moves (Tc=0), so the em values below are measured gaps.
1) Lustria Regular. OFL 1.1 (name table: 'Copyright (c) 2011, Matthew Desmond ... with Reserved Font Name Lustria'; on Google Fonts), so it is safe to bundle. upm 1000, hhea ascent 955 / descent -327 / lineGap 0, capHeight 704. Used in ALL CAPS only:
- Title 'INVOICE #1234': 25.18pt, letterSpacing 4.83pt (0.192em)
- Table heads DESCRIPTION / HOURS / PRICE / TOTAL: 11.68pt, 2.24pt (0.192em)
- 'BILL TO:' / 'PAYABLE TO:': 12.65pt, 1.39pt (0.110em)
- 'AMOUNT DUE: $3500': 14.60pt, 2.80pt (0.192em)
2) Now Regular. PROPRIETARY. Name table reads '(c)2015 Now Typeface is a trademark of Alfredo Marco Pradil. All rights reserved.', fsType 8. It is not OFL, so it cannot go into the xbps without a licence. upm 800, ascent 736 / descent -240 (0.92 / -0.30em), capHeight 0.75em. Used for:
- Issued/Due lines and the BILL TO / PAYABLE TO blocks: 11.55pt, 0.48pt (0.042em). Pitch is 17.5 for the meta lines and 18.2 for the blocks, so lineHeight 1.52 / 1.58.
- Table cells: 10.57pt, 0.46pt (0.044em)
- Footer: 9.60pt, 0.61pt (0.064em), pitch 15.3-15.4, so lineHeight 1.6
Substitute for Now: Poppins Regular (OFL, already needed by templates 2/3/7/8/11), about 0.5pt smaller with about 0.02em tracking. Advance widths per 1000 units, Now vs Poppins: n 560 vs 640, m 840 vs 1030, s 414 vs 534, u 524 vs 640, O 894 (round geometric). Poppins therefore sets typical strings 3-5% wider ('reallygreatsite.com' 9414 vs 9823 in Poppins Medium) and lowercase-heavy runs up to about 15% wider, so columns must flex. If one more family is acceptable, Jost (OFL, Futura-like, narrow s, circular O) is a closer look-alike.
Rupee sign: Lustria (2011, Latin only) almost certainly lacks U+20B9. Any amount set in Lustria needs fontFamily ['Lustria','IBM Plex Sans']; the sign is verified present in the IBM Plex Sans and Montserrat TTF cmaps.
## Colours
#FFFFFF page background.
#000000 for all text (no greys anywhere).
#F7F7F8 zebra row bands (rows 1, 3, 5, 7).
#E7E1E6 hairline rules (mauve-grey, 0.75pt).
Marble raster: very light warm grey with lilac-grey veins. Visual estimate only: base about #EEEDEF, veins about #CFCAD0. For a flat replacement band use #F2F1F3 or the zebra #F7F7F8.
No accent colour. A brand accent could optionally replace #E7E1E6 on the rules.
## Layout
Coordinates are pt from the top-left of the 595.5 x 842.25 visible page. y is the text baseline unless noted.
1. Top marble band: y 7.6 to 63.8 (h 56.3), x 0 to 595.5. The 7.6pt white sliver above it comes from the reference design's clip rectangle (starts at 7.56). Render it full-bleed from 0 to 64.
2. Header, y 64 to 200. The left half (x 31 to 300) is empty and becomes the logo slot. The right column is right-aligned at x 549.0:
- 'INVOICE #1234', baseline 124.9. Reference box x 312.7-554.4, top 101.5; glyph run x 313.4-549.0, w 235.6.
- 'Issued: 06/09/2021' baseline 159.6 and 'Due: On Receipt' baseline 177.1 (pitch 17.5). Right edge 549.0, box x 426-550.
3. Rule 1: y 219.4, x 31.5 to 549.8, 0.75pt #E7E1E6.
4. Parties band, 3 columns with 2 used:
- Col A at x 38.5: 'BILL TO:' baseline 247.9, then lines at 269.0 / 287.2 / 305.4.
- Col B at x 211.4: 'PAYABLE TO:' baseline 247.9, same 3 line baselines.
- Col C (x about 380 to 549) is empty.
Spacing: rule to label baseline 28.5, label to first line 21.1, last line to rule 17.8.
5. Rule 2: y 323.2, same style. The band is 103.8pt between rules.
6. Gap from 323 to 375 (about 52pt).
7. Table header baseline 384.9, with no fill or rule. The first row band starts at 398.6, 13.7pt below the header baseline.
8. 7 item rows, table x 31.2 to 549.6 (w 518.4). Zebra bands (h 32.0) start at 398.6 / 462.6 / 529.5 / 597.2. Row baselines are 417.2 / 448.7 / 481.2 / 515.7 / 548.1 / 583.3 / 615.8, an irregular 31.5-35.2pt pitch; normalise to 32.
9. 'AMOUNT DUE: $3500' baseline 668.7. Glyph run x 337.2-531.8, box right edge 535.4, so it is right-aligned about 14pt inside the table edge, 29pt below the last band.
10. Gap from 669 to 712.
11. Bottom marble band: y 712.0 to 842.25 (h 130.3), full-bleed. Footer text starts about 35pt below the band top:
- col 1 at x 40.7: name / address line 1 / address line 2, baselines 746.9 / 762.2 / 777.6
- col 2 at x 197.8: phone / email / web, same baselines
- col 3 (x about 360 to 549) is empty
Effective margins: bands and rules start at x 31.2 with text inset to 38.5-40.7. The right edge is 549.0-549.8, giving a 45.7pt right margin, so the reference design's placement is asymmetric. Recommend symmetric 36pt page padding plus an 8pt text inset.
## Items table
Column centres, measured from the header and value glyphs (reference centres the numbers):
- DESCRIPTION: left-aligned (header x 38.5, cells x 40.7)
- HOURS: centre x about 355.5
- PRICE: centre x about 438.4
- TOTAL: centre x about 512.1
Column boxes inside table x 31.2 to 549.6 (w 518.4) that reproduce those centres:
- Description 31.2-309.2 (278.0, 53.6%)
- Hours 309.2-401.8 (92.6, 17.9%)
- Price 401.8-474.7 (72.9, 14.1%)
- Total 474.7-549.6 (74.9, 14.4%)
Header: Lustria 11.68pt caps, tracking 2.24pt, #000, no background, no border.
Rows: Now 10.57pt #000, tracking 0.46pt, height 32 (31.98). Zebra #F7F7F8 on the 1st/3rd/5th/7th rows (0-based even), white otherwise. No borders. Text is vertically centred (baseline 18.6pt below the band top) and the description is inset 9.5pt from the band's left edge. The reference sample has 7 rows with values '5', '$100', '$500'.
Recommendation: use minHeight 32 (not a fixed height) with paddingVertical about 9, and alternate the zebra by index. Keep qty centred, but right-align the rupee amounts (Rate and Amount) with a 9.5pt right inset so Indian-format numbers line up on the decimal. This is a deliberate deviation from reference.
## Totals and payment
reference has one totals line: 'AMOUNT DUE: $3500' (Lustria 14.60pt caps, tracking 2.80pt, black), right-aligned at x about 535, baseline 668.7. It has no subtotal or tax rows, no bank block ('PAYABLE TO:' holds name/email/website), no notes, no amount in words, no signature and no thank-you. The footer band repeats the vendor name and address (col 1) and phone/email/web (col 2) in Now 9.6pt.
Voiced version, keeping the same idiom:
- Add a right-aligned stack above AMOUNT DUE. Labels in Lustria 9pt caps (tracking 0.192em), values in the 10.57pt body font, pitch about 16pt, values right-aligned to x about 535. Rows: SUBTOTAL, DISCOUNT (if > 0), then CGST 9% + SGST 9% or IGST 18%.
- Then 'AMOUNT DUE: <rupee amount>' at 14.6pt, with the rupee sign from the fallback font.
- Amount in words on the left, on the AMOUNT DUE row: x 38.5, max width about 280, body 9.6pt, 'Rupees ... Only'.
- Notes under the words, 9.6pt.
- Signature or the computer-generated note in footer col 3, or just above the bottom band on the right.
## Decoration and images
Raster: a single image XObject (X8), a 1321 x 1321px JPEG (DCTDecode, ICCBased sRGB, 286,646 bytes) showing light grey/white marble (reference stock). It is drawn twice as a 700 x 700pt square and clipped:
(a) Top band: clip x 0-595.5, y 7.56-63.83; image placed x -48.4 to 651.7, y -218.9 to 481.1.
(b) Bottom band: clip x 0-595.5, y 711.97-842.25; image placed x -55.2 to 644.8, y 406.7 to 1106.7.
That is about 1.9px/pt (about 136 dpi). It cannot be rebuilt as vector, and the reference design's content licence does not allow redistributing stock media extracted into a template, so do NOT bundle the extracted JPEG. Options:
- a flat band in #F2F1F3 or #F7F7F8 (zero cost)
- an Svg LinearGradient (#F4F3F5 to #ECEAEE) for a soft sheen
- a self-sourced CC0 marble JPEG, pre-cropped to two strips (about 1600x150px top, 1600x350px bottom, 80 KB or less each), placed with <Image style={{objectFit:'cover'}}>
Vector parts are trivial: 4 zebra rects #F7F7F8 at 518.4 x 32.0, and 2 hairlines #E7E1E6 0.75pt, 518.3 long. The alpha-0 rectangles in the page stream are tagged-PDF layout artefacts; ignore them.
## Mapping to Voiced fields
Template region to Voiced field:
- Empty header-left (x 38.5 to 300, y about 92 to 136, bottom-aligned with the title baseline 124.9): vendor logo. The logo is a 3:1 PNG (2172x724); fit it in 150x50 with objectFit contain. vendorName can optionally go under it in Lustria 12.65 caps, tracking 0.11em.
- 'INVOICE #1234': 'INVOICE', or 'TAX INVOICE' when taxType is not none, as the 25.18pt title. Move the number to its own right-aligned line above 'Issued:', for example 'No. INV/2026-27/0042' in the 11.55pt body font, so the tracked title stays short.
- 'Issued:': invoiceDate.
- 'Due:': dueDate (already in types.ts); show 'On Receipt' when empty.
- BILL TO: clientName / clientAddress (multi-line) / 'GSTIN: ' + clientGstin (optional) / 'Place of supply: <state> (<code>)' / 'PO: ' + poNumber.
- PAYABLE TO: bank.accountName (fallback vendorName), bankName + branch, 'A/c ' + accountNo, 'IFSC ' + ifsc, 'UPI ' (planned).
- Empty parties col C (x about 380 to 549): vendor statutory IDs (GSTIN vendorGstin, PAN vendorPan, state code), with Lustria 9pt caps labels and body-font values.
- Table to items:
- description
- HOURS becomes 'QTY': quantity + unit; the header can stay HOURS when every unit is hour
- PRICE becomes 'RATE': rate, or a dash when mode = fixed
- TOTAL becomes 'AMOUNT': computeLineAmount
- AMOUNT DUE: total, preceded by the subtotal / discount / CGST / SGST / IGST rows.
- Footer col 1: vendorName + vendorAddress.
- Footer col 2: vendorPhone, vendorEmail. Website is not in Settings; drop it or add a field.
- Footer col 3 (empty): signature image (120x40 max) + 'Authorised signatory', or 'Computer-generated invoice; no signature required.' at 8pt. Put the 'Generated with Voiced' credit at 7pt along the band bottom.
Voiced fields with no reference slot, and where they go:
- GSTIN/PAN: parties col C
- Bank details: PAYABLE TO
- Place of supply: BILL TO
- CGST/SGST/IGST: totals stack
- Amount in words: left of AMOUNT DUE
- Notes: below the words
- Signature: footer col 3
- HSN/SAC (not in the current types.ts): a grey 9pt second line under the description ('SAC 998314'), or a 50pt column taken from Description
## Overflow risks
- Title: right-aligned, tracked 25pt. 'INVOICE #1234' already fills 235.6pt of its 241pt box. 'TAX INVOICE #INV/2026-27/0042' (about 28 glyphs at about 19pt) is about 520pt and would run left across the logo zone. This is the same class of bug as the reported vendor-address/INVOICE overlap. Fix: put the number on its own line, auto-fit the font size, or both.
- Parties band: col A is 173pt wide (38.5 to 211.4), so long client names and addresses wrap, and PAYABLE TO grows from 3 lines to 5-6 (about 110pt). The rules must be borders of a flow View, not absolutely placed at 219.4/323.2.
- Unbreakable tokens at 11.55pt (emails, UPI IDs, 15-character GSTINs, websites): 'accounts@longcompanyname.co.in' is about 175pt, wider than the column. Add a hyphenation callback that allows breaks at @ . / - or shrink the text.
- Description column is about 278pt (about 45 chars per line at 10.57pt), so rows go multi-line; use minHeight 32.
- Vertical budget: the last row ends at 629, AMOUNT DUE sits at 669, the bottom band starts at 712. Adding Subtotal plus two tax rows (+48pt) and the words line means 6 or more items no longer fit on page 1 of an A4. Page 2 must still reserve the fixed bands: paddingTop at least 64 + gap, paddingBottom at least 130 + gap.
- Footer col 1 is about 150pt wide at 9.6pt. An Indian address (door no., street, area, city, state, PIN) wraps to 4-6 lines. From baseline 746.9 at 15.4pt pitch, 6 lines end at 823.9 (18pt from the edge), and 7 or more leave the page. Widen col 1 to about 210pt (shift col 2 to x about 260) and cap it at maxLines 5 with an ellipsis.
- Rupee sign in Lustria: the glyph is missing unless a fallback font is set.
## US Letter adaptation
Letter is 612x792 against the reference design's 595.5x842.25: 16.5pt wider and 50.25pt shorter. Do not scale the type.
- Horizontal: Page paddingHorizontal comes from the user margin, with a template default of 36. the reference design's effective 31.2 left / 45.7 right is an asymmetry to normalise. Content width goes from 518.4 on A4 to 540 on Letter at 36. Set column widths as percentages (53.6 / 17.9 / 14.1 / 14.4).
- Bands: absolute, full-bleed and fixed. The top band is 0 to 64 on both sizes. The bottom band is 130pt on A4 and about 100-110pt on Letter (the footer text block is about 46pt plus about 30pt of top padding).
- Vertical: on Letter, shrink the 52pt gap above the table head to about 30 and the 43pt gap above the bottom band to about 24. Page paddingTop = 64 + userTop (about 28); paddingBottom = band height + userBottom (about 16).
- The margin setting changes only Page padding; decorations always ignore margins.
## react-pdf notes
Structure (react-pdf 4.9 with yoga 3):
<Page size={size /* 'A4' | 'LETTER' */} style={{ paddingTop: 64 + 30, paddingBottom: BAND + 20, paddingHorizontal: m, fontFamily: BODY, fontSize: 11.55, lineHeight: 1.55, color: '#000' }}>
<View fixed style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 64, backgroundColor: '#F2F1F3' }} />
(or <Image fixed src={marbleTop} style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 64, objectFit: 'cover' }} />)
<View fixed style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: BAND, paddingTop: 30, paddingHorizontal: m + 4, flexDirection: 'row' }}> 3 footer columns </View>
- Header: a row View with justifyContent 'space-between' and alignItems 'flex-end'. Left logo View: flexShrink 0, maxWidth '45%'. Right meta View: flexShrink 1, alignItems 'flex-end'.
- Parties: a row View with borderTopWidth and borderBottomWidth 0.75 in #E7E1E6, paddingTop 18, paddingBottom 16. Its 3 columns each need flex 1 with flexShrink 1, otherwise text in a row will not wrap.
- Table: give the header row minPresenceAhead of about 64 so it is never left alone at a page bottom. Rows:
items.map((it, i) => <View wrap={false} style={{ flexDirection: 'row', minHeight: 32, alignItems: 'center', paddingHorizontal: 9.5, backgroundColor: i % 2 === 0 ? '#F7F7F8' : undefined }}>)
- Wrap the totals stack, AMOUNT DUE and the words line in one <View wrap={false}>.
- Absolute children of <Page> resolve against the page edge (padding ignored). The existing InvoiceDocument footer already relies on this (position absolute, bottom 24, left/right 40, with paddingHorizontal 40). The fixed bands must be matched by Page paddingTop/paddingBottom, or continuation pages will draw over them.
- Tracked caps: { fontFamily: 'Lustria', fontSize: 25.18, letterSpacing: 4.83, textTransform: 'uppercase' }. react-pdf adds letterSpacing to every glyph advance, including the last (verify in preview), so right-aligned tracked text sits one letterSpacing short of the edge. Compensate with marginRight: -4.83 (-2.80 for AMOUNT DUE, -2.24 for the table heads).
- Fonts: Font.register Lustria and the substitute from static TTF (or WOFF). The v4 docs list only TTF/WOFF and no variable fonts. Money: fontFamily: ['Lustria', 'IBM Plex Sans']; array fallback is supported in v4 and Helvetica is appended automatically.
- Continuation pages: a fixed element is cloned onto every page. Check in preview that a fixed table-header row lands correctly; if it does not, chunk the items per page and render the header for each chunk.
- Font.registerHyphenationCallback(word => splitAt(word, /[@./-]/)) lets emails, GSTINs and UPI IDs break instead of overflowing.
## Similarity to other templates
No near-duplicate in the set. This is the only template that combines photographic texture bands, zebra rows and a right-aligned tracked serif title (6 also uses a raster paper texture, with a different layout). It shares the tracked-caps title idea with 4/format.pdf and 9/10, and the grey-band idea with 9/10. It has nothing in common with the in-app IBM Plex template beyond the data blocks. Effort is medium: new header, parties band, zebra table, band decorations and a font substitution.
## Implementation notes
Built as `src/pdf/templates/marble/`: a standalone template (`tokens.ts` the type roles, palette and reference distances, `plan.ts` the pure planner, `Layout.tsx` the body, `marble.tsx` the definition and the page art) on the shared blocks. Measured against `1.pdf` with `npm run templates:compare -- marble --rows=3` (the sample images are in `/tmp/invoice-c6b/`, left reference, right ours; `--numbers` prints the baselines). Lustria is bundled for this template (`public/fonts/lustria`, OFL, regular only; the upright file is registered as its italic, like the other families without one).
**Matches the design (measured, A4):** the title baseline 124.9 with its ink ending on the right edge (reference 124.9 / 549.0); Issued and Due at 159.6 and 177.1 (159.6 / 177.1), ending on the same edge; the vendor name at x 38.5, level with the title; BILL TO and PAYABLE TO at 247.9 with the first lines at 269.0 and the 18.2 pt pitch after them (247.9 / 269.0 / 287.2 / 305.4), the columns at x 38.5, 211.4 and 380; the table heading 32.3 pt above the first row baseline (32.3) and the first band 8.85 pt under it; rows of 32 pt in `#F7F7F8` on the 1st, 3rd, 5th row, the description 9.5 pt inside the band's edge; qty centred on x 355.1 (355.5); the hairlines `#E7E1E6` at 0.75 pt from x 31.5 to 549.5; AMOUNT DUE ending on x 535.4 (535.4); the footer lines at 746.9 / 762.2 / 777.5 with the second column at x 197.8 while the address is short (746.9 / 762.2 / 777.5, 197.8). Every colour is one of the design's (a test scans the sources); the type is Lustria 25.18 / 12.65 / 11.68 / 14.6 pt at the measured tracking and Jost at the sizes of the design.
**Intentional deviations, and why:**
- **The marble photograph** is not bundled (stock media). Both bands are full-bleed, 64 pt at the top and 130 pt at the foot, filled with a soft grey sheen (a static Svg gradient from `#F4F3F5` through `#ECEAEE` back to `#F4F3F5`), on every page. The flow starts at y 86 (under the band and the continuation line) and stops 12 pt above the foot band; Letter keeps the same band, so its missing 50 pt come out of the gaps.
- **Now is drawn in Jost.** Jost sets about 12% narrower than Now, so the body roles carry 0.10 to 0.13 em of tracking on top of the design's 0.04 em; that brings a string such as "Description of service goes here" to the reference width (180 pt). The right-aligned tracked lines are drawn one tracking step into the margin, so their ink ends on the edge.
- **Header.** The number goes on its own line ("Invoice No. INV/2026-001") between the title and Issued, so a "TAX INVOICE" title never meets a long number. The title shrinks (to 13 pt at the least) when the stack is narrow; a number too wide for the stack shrinks, then splits at `@ . / -`. Due shows "On Receipt" when the invoice has no due date. A cancelled invoice has a red CANCELLED line above the number and its sentence at the foot.
- **Left corner.** The design leaves it empty: it holds the vendor name in Lustria caps (level with the title), or the opt-in logo. The logo is off by default (the template has no placeholder), 150 x 50 at x 38.5 with its bottom edge on y 136; a wordmark replaces the name, a mark keeps it under the mark.
- **Parties band.** Three columns: BILL TO (name, address, GSTIN, place of supply, PO), PAYABLE TO (the account holder, then the bank lines as "label: value", the account number as "A/c No.") and SUPPLIER (GSTIN and PAN). A column with nothing to show is left out and the next one moves up; the band grows with the tallest one.
- **Table.** HOURS / PRICE / TOTAL read Qty / Rate / Total. The rate keeps its unit on its own line ("₹1,200.00/hr") while the column can grow to take it (the column then widens, taking the room from the description); qty stays centred, the rate and total are right-aligned (9.5 pt inside the edge) so Indian-format numbers line up, which reference does not do (it centres them). HSN/SAC is an 8.5 pt sub-line under the description; wrapped descriptions grow the row (the zebra fill is the row's own).
- **Totals.** The stack (SUBTOTAL, DISCOUNT, TAXABLE VALUE, CGST / SGST / IGST) is Lustria 9 pt caps labels with the values right-aligned, over AMOUNT DUE in the design's 14.6 pt (the rupee sign comes from the fallback font). The amount in words and the reverse-charge line are at the left, level with the first row; they never reach the stack.
- **Closing.** The notes (when 90 pt tall or less) sit at the left and the signature (the user's image, or the signatory and "Computer-generated invoice; no signature is required.") at the right, above the foot band, never inside it. Longer notes flow as a block of their own before the closing; a block that would start with less than a line of room starts on the next page.
- **Foot band.** The vendor name and address (column 1, x 40.7, up to 190 pt wide, five lines on A4 and four on Letter, an ellipsis after that) and the phone and e-mail (column 2, one fitted line each, shrunk before an ellipsis) are page art, printed on every page. Column 2 stays at x 197.8 while the address is short and moves right of column 1 when it is not. A name or address with one unbreakable word is fitted to one line. The full supplier identity is in the parties band, so nothing is lost to the cap. The "Generated with Voiced" line and the page label sit inside the band, 14 pt off the page foot.
- **Margins.** 31.5 pt left and 45.8 pt right (the design's asymmetry), 86 pt at the top (the logo slot's top), 24 pt plus the 118 pt reserve at the foot; the narrow and normal presets replace the sides and the foot margin with 36 and 54 pt (the content then stops higher above the band) and the top stays at its 86 pt floor. Elastic gaps (Letter, and A4 when a one-page invoice overflows by less than the slack): header to hairline (36.8 to 14 pt), the hairlines' spacing, hairline to table heading (50.2 to 22), row padding (down to 3.5 pt a side), last row to the totals and the totals to the signature.
+173
View File
@@ -0,0 +1,173 @@
# Template 2: Highlighter (`highlighter`)
Source: `templates/reference/2.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Bold form-style invoice. Tight-tracked Poppins Bold 'Invoice', underlined fill-in fields, a signal-yellow header bar, a heavy 3pt black-framed grid table with indigo hairlines, a yellow 'total' cell, and a THANK YOU sign-off with icon-led contact lines.
## Fonts
All OFL; bundle as static TTF.
1) Poppins Bold ('Copyright 2020 The Poppins Project Authors', v4.004). upm 1000, hhea 1050 / -350 / lineGap 100, capHeight 705. Used for:
- 'Invoice': 45.68pt, letterSpacing -2.83pt (-0.062em), #1C2120
- Table heads 'Description / Qty / Price / Total': 13pt, tracking 0, #1C2120
- 'total :': 12pt, #000
- 'Bank Name : ...' and 'Account No : ...': 12pt, #1C2120, pitch 16.5 (lineHeight 1.375)
- 'THANK YOU': 25.01pt, #1C2120
2) Poppins Regular (capHeight 698). Used for the field labels 'Date:', 'Invoice number:', 'Billed to:' at 13pt with -0.37pt (-0.028em), and 'Payment Method:' at 13pt with tracking 0.
3) Montserrat Regular ('Copyright 2011 The Montserrat Project Authors', v7.200). upm 1000, hhea 968 / -251, capHeight 700. Used for the contact lines at 11.67pt, #000, pitch 22.5 (lineHeight 1.93).
The template has no item text (rows are empty); suggest Poppins Regular 11pt #1C2120.
Rupee sign: present in Montserrat (verified in the local TTF cmap). Poppins is likely to have it (ITF Devanagari family) but this is unverified, so use fontFamily ['Poppins','IBM Plex Sans'] for money.
## Colours
#1C2120 near-black ink: title, labels, header text, 3pt table frames, icons, THANK YOU.
#423869 deep indigo hairlines, 0.75pt: field underlines, table grid, the 4 lines at bottom right.
#FFE535 signal yellow: table header bar and the 'total :' cell.
#000000: contact text and the 'total :' label.
#FFFFFF: page and table body.
Expose #FFE535 as a swappable accent token.
## Layout
Coordinates are pt from the top-left; y is the baseline unless noted. Content box x 59.6 to 536.0 (about 60pt side margins).
1. Title 'Invoice' at x 60.3, baseline 98.0 (cap top about 65.8). The right half (x 352 to 536, y 50 to 114) is empty.
2. Field stack, labels at x 60.3-61.8 in Poppins Regular 13:
- 'Date:' baseline 132.2; underline y 137.4, x 97.6 to 266.0 (168.3 wide)
- 'Invoice number:' baseline 166.0; underline y 170.4, x 165.5 to 266.0 (100.5 wide)
- 'Billed to:' baseline 200.4; underline y 204.8, x 115.8 to 266.0 (150.1 wide)
Pitch 34. Each underline sits about 4.6pt below the baseline and starts 4-6pt after the label.
3. Right block at x 352.5, left-aligned: 'Payment Method:' baseline 162.9; 'Bank Name : Wealth Banking' 185.1; 'Account No : 123 4567 8901' 201.6.
4. Header bar: y 269.7 to 305.1 (h 35.4), x 59.6 to 536.0. The stroke is centred, so the outer edge runs 58.1 to 537.5. Fill #FFE535, 3pt #1C2120 frame, dividers 0.75 #423869 at x 245.5 / 309.4 / 412.2. Labels are centred on baseline 290.9: Qty centre 277.4, Price 360.8, Total 471.7, 'Description' 144.2.
5. Gap of 7.5.
6. Body: y 312.6 to 559.9 (h 247.3), same x, white, 3pt frame. Full-height dividers at x 245.5 / 309.4 / 412.2. Row rules at y 343.8, 375.3, 405.3, 436.8, 466.8, 498.3, 528.3, giving 8 rows of about 30.9.
7. Gap of 7.5, then the totals row at y 567.4 to 595.5 (h 28.1): a yellow cell at x 309.4 to 412.2 ('total :' baseline 585.2, x 333.8-368.8) plus a white value cell at x 411.0 to 535.3. Each cell has a 3pt frame.
8. Gap from 595 to 670.
9. Sign-off, left: 'THANK YOU' at x 61.8, baseline 695.0. Contact rows have an icon column at x 59.6-74.3 and text at x 81.9, baselines 732.6 / 755.1 / 777.6.
10. Right: 4 rules, 0.75 #423869, x about 323.7 to 536.0 (about 212 long), at y 694.0 / 723.3 / 752.5 / 781.5 (pitch about 29.2).
About 64pt of whitespace below the last baseline.
## Items table
Outer width 476.4 (x 59.6 to 536.0). Columns:
- Description 59.6-245.5 (185.9, 39.0%)
- Qty 245.5-309.4 (63.9, 13.4%)
- Price 309.4-412.2 (102.8, 21.6%)
- Total 412.2-536.0 (123.8, 26.0%)
Header: a separate bar, 7.5pt above the body. #FFE535 fill, 3pt #1C2120 frame, 35.4 tall, Poppins Bold 13 #1C2120 labels centred, internal dividers 0.75 #423869. 'Description' measures 144.2 at its centre, about 8pt left of the column centre; just centre it.
Body: white, 3pt #1C2120 outer frame, 0.75 #423869 grid lines both ways, 8 empty rows of about 30.9 (31.1 / 31.5 / 30.0 / 31.5 / 30.0 / 31.5 / 30.0 / 31.6). The reference file is a blank fill-in form.
Suggested cells: Poppins Regular 11 #1C2120. Description left with 9pt padding; Qty centred; Rate and Amount right-aligned with 9pt right padding (reference only centres the headers).
Option: 'pad to N empty rows' (default 8 on A4, 7 on Letter) keeps the form look when there are few items.
## Totals and payment
reference:
- One 'total :' row: a yellow label cell under the Price column and a white value cell under the Total column, 28.1pt tall, 3pt frames, 7.5pt below the body. The label is Poppins Bold 12 #000; the value cell is empty.
- Payment, top right: 'Payment Method:' (Regular 13), then 'Bank Name : ...' and 'Account No : ...' (Bold 12, label : value inline).
- Sign-off: 'THANK YOU' (Bold 25) with icon-led phone / email / address rows (Montserrat 11.67).
- 4 unlabelled ruled lines at bottom right, for notes or a signature.
- No subtotal or tax rows, no amount in words, no notes label, no signature caption.
Voiced version:
- Build the totals as a stack of the same 2-cell row (label cell at the Price column x, value cell at the Total column x), 24-28pt per row. Merge the frames: 3pt outside, 0.75 #423869 between rows.
- Rows: Subtotal, Discount (if > 0), CGST @x% + SGST @x% or IGST @x%, then Total. Total keeps the yellow label cell with a bold value; the value cell can also go yellow for emphasis.
- Amount in words to the left of the stack: x 60 to 300, Poppins Regular 10.5, top-aligned with the first totals row.
## Decoration and images
No raster images (41 KB, all vector). Everything can be rebuilt in react-pdf:
- Filled rects: header bar #FFE535 476.4 x 35.4; total label cell #FFE535 102.8 x 28.1.
- 3pt #1C2120 frames on the header bar, the body and both total cells. PDF strokes are centred on the edge (1.5pt outside), while react-pdf borders sit inside the box, so place those Views at the outer edge (x 58.1, width 479.4).
- 0.75pt #423869 hairlines: 3 field underlines, 3+3 column dividers, 7 row rules and the 4 bottom-right lines.
- 3 icons, fill #1C2120, drawn with <Svg><Path>:
- Phone: bbox x 59.6, y 721.9, 14.75 x 14.75. Native path (about 36 units, y-down), viewBox '0 0 36 36', drawn at 14.75pt:
'M34.06 26.407L30.564 22.911C28.634 20.981 25.504 20.981 23.575 22.911C22.856 23.629 22.408 24.514 22.224 25.439C16.459 24.361 10.852 18.777 10.503 13.786C11.45 13.61 12.357 13.159 13.089 12.426C15.019 10.496 15.019 7.366 13.089 5.436L9.594 1.94C7.664 0.01 4.534 0.01 2.604 1.94C-7.882 12.426 23.574 43.882 34.06 33.396C35.989 31.467 35.989 28.337 34.06 26.407Z'
- Mail: this is Material Icons 'mail' (Apache-2.0), viewBox 0 0 48 48. Drawn area 11.58 x 9.26 at x 62.5, y 746.9, so the 48-unit box is about 13.9pt:
'M40 8H8c-2.21 0-3.98 1.79-3.98 4L4 36c0 2.21 1.79 4 4 4h32c2.21 0 4-1.79 4-4V12c0-2.21-1.79-4-4-4zm0 8L24 26 8 16v-4l16 10 16-10v4z'
- Pin: this is Material Icons 'place' (Apache-2.0), viewBox 0 0 48 48. Drawn area 8.46 x 12.09 at x 64.05, y 766.4, so the 48-unit box is about 14.5pt:
'M24 4c-7.73 0-14 6.27-14 14 0 10.5 14 26 14 26s14-15.5 14-26c0-7.73-6.27-14-14-14zm0 19c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z'
The icons' visual centres sit about 4pt above each text baseline (729.3 / 751.5 / 772.5).
Alternative: the Carbon icons Phone / Email / Location (Apache-2.0; @carbon/icons is already a dependency) would keep one icon set across the app.
## Mapping to Voiced fields
- 'Invoice': 'Invoice' or 'Tax Invoice'. 'Tax Invoice' is about 237pt at 45.68pt and fits the left half.
- Empty top right (x 352 to 536, y 50 to 114): vendor logo (3:1, fit 150x50, right-aligned) plus vendorName (Poppins Bold 13), with 'GSTIN ... / PAN ...' (Poppins Regular 10) under it.
- 'Date:': invoiceDate. Add a 'Due date:' field in the same underline style (pitch 34) for dueDate.
- 'Invoice number:': number.
- 'Billed to:': clientName on the line. Sub-lines underneath at x 60.3, Poppins Regular 11, pitch 15: clientAddress, 'GSTIN: ...', 'Place of supply: ...', 'PO: ...'.
- 'Payment Method:': 'Bank Name : bankName (branch)', 'Account Name : accountName', 'Account No : accountNo', 'IFSC : ifsc', 'UPI : ...' (planned).
- Table to items: Description / Qty (with unit such as 'hr' or 'session') / Price becomes Rate (a dash when mode is fixed) / Total becomes Amount.
- 'total :': the Subtotal / Discount / CGST / SGST or IGST / Total stack.
- 'THANK YOU': keep as a static string. The icon rows take vendorPhone / vendorEmail / vendorAddress.
- The 4 ruled lines:
- Lines 1-3: notes written on the lines (Poppins Regular 10.5, baseline about 3pt above each rule), or the amount in words if there are no notes.
- Line 4: the signature line. Put the signature image above it (140x40 max) with the caption 'Authorised signatory, <vendorName>'. Without a signature, write 'Computer-generated invoice; no signature required.' on that line.
Voiced fields with no reference slot, and where they go:
- Vendor name, logo, GSTIN and PAN: top right
- Place of supply: Billed-to sub-lines
- Tax rows: totals stack
- Amount in words: left of the totals
- Notes and signature: the ruled lines
- HSN/SAC (not in types.ts): a 52pt column between Description and Qty (Description shrinks to about 134pt), or a grey 9pt sub-line
- 'Generated with Voiced': a 7pt line under the sign-off
## Overflow risks
- The underlined fields have fixed widths: Invoice number 100.5pt (about 14 chars of Poppins 12, so 'INV/2026-27/0042' at about 105pt overflows), Billed to 150pt (about 22 chars), Date 168pt. Build each as a row of label plus a value View with flexGrow 1 and a bottom border, so the value wraps (the underline moves down) or shrinks to 11pt.
- Billed-to sub-lines: only 65pt (about 4 lines at 15pt) sits between the last underline (204.8) and the header bar (269.7). Longer addresses must push the table down, so use flow layout, not absolute positions.
- Payment block: 183.5pt wide at Bold 12, so long bank or branch names wrap. 5-6 lines at 16.5 pitch from 185 reach about 268, which is the header bar's top. Put the fields and the payment block in one flow row and start the table 30pt below the taller of the two.
- Rows: reference uses fixed 30.9pt rows with page-tall dividers. Use per-row cells with borderRight instead. The description column is 186pt, about 30 chars per line at 11pt, so rows will go multi-line.
- More than 8 items breaks onto a second page, and the 3pt frame must close on each page's fragment: rows carry 3pt left/right borders, the first row a 3pt top, the last row a 3pt bottom, and every row is wrap={false}.
- Totals: the 102.8pt label cell fits 'IGST @ 18%'; the 123.8pt value cell at Bold 12 fits up to about Rs 99,99,99,999.00 (with the rupee sign).
- Contacts: the address has about 228pt on one line (81.9 to 310, before the ruled lines at 323.7), about 38 chars at 11.67pt. Indian addresses wrap to 2-3 lines; at 22.5 pitch, two extra lines end around 823, inside the bottom margin. Use a 16pt pitch for wrapped continuation lines, and let the sign-off block flow instead of pinning it at 695.
- Notes longer than 3 lines overflow the ruled area. Generate the ruled lines from the wrapped line count, or cap the notes length.
## US Letter adaptation
- Keep the 60pt side margins (template default, user-overridable). Content width goes from 476.4 to 492 on Letter; set columns by percentage.
- Absorb the 50pt height loss: collapse the 75pt gap between the totals (595) and THANK YOU (670) to about 30pt, and the 64pt gap between the field stack and the header bar to about 40pt. With row padding on, pad to 7 empty rows on Letter.
- The sign-off block (THANK YOU, contacts, ruled lines) appears once, at the end. Anchor it to the bottom of the last page with a flexGrow spacer, not with fixed.
## react-pdf notes
- Page: <Page size={size} style={{ paddingTop: 50, paddingBottom: 60, paddingHorizontal: 60, fontFamily: 'Poppins', color: '#1C2120' }}>.
- Title: <Text style={{ fontWeight: 700, fontSize: 45.68, letterSpacing: -2.83, lineHeight: 1 }}>. Negative letterSpacing is fine.
- Field:
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginTop: 21 }}>
<Text style={{ fontSize: 13, letterSpacing: -0.37 }}>{label}</Text>
<View style={{ flexGrow: 1, flexShrink: 1, marginLeft: 4, borderBottomWidth: 0.75, borderBottomColor: '#423869', paddingBottom: 1 }}>
<Text style={{ fontSize: 12 }}>{value}</Text>
</View>
</View>
The field column is about 206 wide (60 to 266).
- Top row: flexDirection 'row', justifyContent 'space-between'. Left fields 206 wide; right payment/vendor column 183.5 wide.
- Header bar:
<View minPresenceAhead={62} style={{ flexDirection: 'row', height: 35.4, backgroundColor: '#FFE535', borderWidth: 3, borderColor: '#1C2120', marginBottom: 7.5 }}>
Cells use % widths with borderRightWidth 0.75, borderRightColor '#423869', justifyContent 'center'.
- Body rows:
<View wrap={false} style={{ flexDirection: 'row', minHeight: 30.9, borderLeftWidth: 3, borderRightWidth: 3, borderBottomWidth: 0.75, borderLeftColor: '#1C2120', borderRightColor: '#1C2120', borderBottomColor: '#423869' }}>
The first row adds borderTopWidth 3; the last row uses borderBottomWidth 3 with borderBottomColor '#1C2120'. Per-side border colours are supported.
- Totals: a column of 2-cell rows, lined up with the Price/Total columns by a left spacer equal to Description% + Qty% of the width.
- Icons: import { Svg, Path } from '@react-pdf/renderer'; then <Svg viewBox='0 0 48 48' style={{ width: 14, height: 14 }}><Path d={MAIL} fill='#1C2120' /></Svg>.
- Fonts: register Poppins 400/500/700 and Montserrat 400 from static TTF/WOFF. For money use fontFamily ['Poppins','IBM Plex Sans'].
## Similarity to other templates
Closest siblings are 7 and 11 (Poppins, a colour-filled header on a boxed grid table, boxed totals) and 8 (filled blue header plus an icon contact row). Only 2 has underlined fill-in fields, a detached header bar and 3pt frames. It is unrelated to the in-app template and to format.pdf/4.pdf. If 7 and 11 are built too, share one 'boxed grid table' component with accent colour and frame weight as parameters.
## Implementation notes
Built as `src/pdf/templates/highlighter/`: a standalone template (`tokens.ts` the type roles, palette and reference distances, `plan.ts` the pure planner, `Layout.tsx` the body, `highlighter.tsx` the definition) on the shared blocks and the Carbon icon paths (`ContactIcon`). Measured against `2.pdf` with `npm run templates:compare -- highlighter --rows=3` (images in `/tmp/invoice-c6b/`, left reference, right ours; `--numbers` prints the baselines). The reference page is a blank form, so the comparison sample here fills the fields and rows; every distance below was measured with the design's own content shape (no due date, no address, a two-line payment block).
**Matches the design (measured, A4):** the title baseline 98.0 at x 60.3 (98.0 / 60.3); the fields at 132.2 / 166.3 / 200.4 (132.2 / 166.0 / 200.4) with their label at x 60.3 and the value 5 pt after it on a 0.75 pt `#423869` underline ending at x 266; "Payment Method:" at 162.9 and the bold bank lines at 185.1 and the 16.5 pt pitch (162.9 / 185.1 / 201.6), x 352.5; the table bar 38.4 pt tall (frame included) with the Qty and Price headings centred on x 277.4 and 360.8 (277.4 / 360.8) and the Total heading 1.9 pt right of the design's (the design sets it 2.4 pt left of its column centre); a 4.5 pt gap under the bar, the body's own 3 pt frame, hairlines between the rows and columns; rows of 30.65 pt; the totals box one price-column wide plus one total column, its total label cell yellow; the sign-off and its ruled lines 212.3 pt wide ending 1.5 pt inside the frame's edge. Every colour is one of the design's four (a test scans the sources); the type is Poppins 400/700 and Montserrat 400 at the design's sizes.
**Intentional deviations, and why:**
- **Content box.** The 3 pt strokes are centred on x 59.6 and 536.0, so the frames reach 1.5 pt outside them: the content box is the frame's outer edge (58.1 to 537.5, 479.4 wide), and everything is measured from it. Margins are 58.1 / 57.8 pt at the sides, 50 pt at the top (the title's line box is the design's 64 pt zone) and 36 pt plus the footer reserve at the foot; the narrow and normal presets replace the margins with 36 and 54 pt, and the frame, the table columns and the totals box follow the content width.
- **Fields.** Date, Invoice number, a Due date field in the same style (only when the invoice has one), then Billed to with the client's name on its underline; the address, GSTIN, place of supply and PO number are 11 pt lines under it. A value that does not fit its underline wraps (the underline moves down) or, for an unbreakable token, shrinks and then splits at `@ . / -`. Pitch is a steady 34.1 pt (the design's is 33.8 / 34.4). The fields and the payment block sit side by side and the table starts under the taller one, at least 18 pt below it (the design leaves 64 pt, which Letter and a longer client block take back, down to 14 pt).
- **Top right.** The vendor's name (Poppins Bold 13), GSTIN and PAN, right-aligned in x 352 to 536, or the opt-in logo (off by default; 150 x 50, right-aligned, its top on y 50) above them; a wordmark replaces the name, a mark keeps it under the mark. The zone is 64 pt tall and grows with a logo, which moves the rest down. A cancelled invoice has a red CANCELLED line there and its sentence at the foot.
- **Payment block.** The account holder's name is added as its own line, and the bank lines read "Bank Name : ...", "Account No : ..." (then Branch and IFSC).
- **Table.** The design's rows are empty; ours carry the item: description left (9 pt in) with an HSN/SAC sub-line, qty centred, price and total right-aligned 9 pt inside their cells (the unit stays on the rate's line, "₹1,200.00/hr", while the column can take it). The form look pads with empty rows to 8 on A4 and 7 on Letter, but the pad rows give way first: they are dropped before the sign-off would have to move to a second page, and a long invoice has none. The 3 pt frame is closed at the foot after the last row only; where a table breaks onto a second page the page ends on the row's hairline (the frame's sides run to it), and the next page repeats the bar and the body's top rule.
- **Totals.** A stack of two-cell rows (label cell | value cell) lined up with the price and total columns: Subtotal, Discount, Taxable value, CGST / SGST / IGST ("CGST @ 9% :") with 0.75 pt rules between the rows and the 3 pt frame around them, and the "total :" row with the yellow label cell and a bold value. When a label needs more room than the price column has, the box grows to the left. The amount in words and the reverse-charge line are at its left, level with its first row.
- **Sign-off.** THANK YOU and the phone, e-mail and address with Carbon icons (the design's own Material paths are not used, so the app keeps one icon set); the phone and e-mail are one fitted line each (a wrapped phone number can break after a bracket), the address wraps. The ruled lines carry the notes, one wrapped line per ruled line (three lines at least; more than six lines and the notes flow as a block of their own before the sign-off), and the signature block (the image or "Computer-generated invoice; no signature is required.", the rule, the name and designation) takes the last one. The sign-off sits at the foot of the last page.
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): fields to table bar (down to 14 pt), the row padding (down to 4.5 pt a side) and the totals to the sign-off (24 to 12 pt). The type is never scaled.
+160
View File
@@ -0,0 +1,160 @@
# Template 3: Monolith (`monolith`)
Source: `templates/reference/3.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Stark monochrome Poppins. An oversized 60pt black INVOICE, a right-aligned vendor/date/number stack, black hairline section rules, a thick-ruled table header, centred number columns, a compact right-hand totals block, and a two-column footer (payment info / vendor contact) framed by rules.
## Fonts
All OFL (Poppins v4.004, 'Copyright 2020 The Poppins Project Authors'); bundle static TTF at 400/500/700. upm 1000, hhea 1050 / -350 / lineGap 100; capHeight Bold 705, Medium 697, Regular 698. Tracking is 0 throughout and everything is #000.
- Poppins Bold:
- 'INVOICE' at 60.02pt
- 'BILLED TO:' and 'PAYMENT INFORMATION :' at 14.5pt
- at 12.5pt: the vendor names ('CAVEIN', 'SUDIUM LOVRA'), 'INVOICE NO.15325', the table heads, the Subtotal/Tax/Total labels and the Total value
- Poppins Medium 12.5pt: client lines, item descriptions and values, Subtotal/Tax values, payment and vendor lines. Pitch 17.25 (lineHeight 1.38).
- Poppins Regular 12.5pt: the date '15 March 2025' and the ':' separators in the payment grid.
'INVOICE' at 60.02pt measures 249.75pt of advance, i.e. 4.161 x fontSize.
Rupee sign: cannot be checked from the Poppins subset, so use fontFamily ['Poppins','IBM Plex Sans'].
## Colours
Strict monochrome: #000000 for text and every rule, #FFFFFF page. No fills and no greys. Optionally expose an accent token for the title and the 1.5pt table rules so a brand colour can be applied.
## Layout
Coordinates are pt from the top-left; y is the baseline unless noted. Content box x about 35 to 560.5 (about 35pt side margins).
1. Header row:
- Left: 'INVOICE', Poppins Bold 60.02. Glyph origin at x 50.7, about 16pt in from the 35pt content edge; baseline 96.3; cap top 54.0; advance 249.8, ending at x 300.5.
- Right, right-aligned to x 560.6: 'CAVEIN' (Bold 12.5) baseline 63.7, with its cap top 54.9 lined up with the title's cap top; '15 March 2025' (Regular 12.5) 84.7; 'INVOICE NO.15325' (Bold 12.5) 105.7. Pitch 21 (lineHeight 1.68).
2. Rule 0.75 at y 116.75, x 35.5 to 560.0.
3. 'BILLED TO:' (Bold 14.5) at x 34.4, baseline 153.0, 36.2 below the rule. Client lines (Medium 12.5) at x 35.5, baselines 178.1 / 195.4 / 212.6 (pitch 17.25).
4. Rule 0.75 at y 238.3 (x 34.4 to 559.0), 25.7 below the last line.
5. Free band from 238 to 326 (88pt), empty.
6. Table: 1.5pt rule at y 326.2; header baseline 350.4 (Bold 12.5); 1.5pt rule at y 365.5 (x 36.1 to 560.0). The header band is 39.3pt.
7. Item rows: descriptions on baselines 389.9 / 442.1 / 491.1 (Medium 12.5, x 44.4); numbers on 396.9 / 449.2 / 498.5. The numbers sit about 7pt lower, a reference misalignment to fix rather than copy. Pitch is about 52 / 49pt.
8. Rule 0.75 at y 521.6 (x 36.4 to 560.3).
9. Totals block on the right, x 344.5 to 560: Subtotal baseline 541.6, Tax 564.6 (pitch 23), a 0.75 rule at y 577.5 (x 344.5 to 560.0), Total 597.7.
10. Empty from 598 to 705 (107pt).
11. Footer:
- Rule 0.75 at y 705.1 (x 36.4 to 560.3).
- Left: 'PAYMENT INFORMATION :' (Bold 14.5) at x 34.4, baseline 728.0. Payment lines (Medium 12.5): account name 758.9; 'Bank : Pinky Bank' 776.1; 'Account No. : 0123 4567 8901' 793.4. Labels at x 35.5, ':' at x 149.4 (Regular), values at x 170.8.
- Right: vendor name 'SUDIUM LOVRA' (Bold 12.5) at x 395.2, baseline 726.0. Lines (Medium 12.5) at x 396.3: address 758.7, phone 776.0, website 793.2.
12. Rule 0.75 at y 812.45 (x 36.4 to 560.3), about 30pt above the page bottom.
## Items table
Width 523.9 (x 36.1 to 560.0). The header sits between 1.5pt black rules above (326.2) and below (365.5), and the body is closed by a 0.75pt rule (521.6). No vertical lines, fills or zebra.
Measured column centres: DESCRIPTION is left-aligned at x 44.4 (8.3pt inset); PRICE centre 311.5; QTY centre 407.8; TOTAL centre 506.0. Equal 98pt number columns land within 1.5pt of those centres:
- Description 36.1-261 (about 225, 43%)
- Price 261-359
- Qty 359-457
- Total 457-555, plus 5pt of right padding
PRICE comes before QTY here; the others in the set put QTY first.
Header labels are Poppins Bold 12.5 caps; cells are Poppins Medium 12.5. Reference centres the numbers. As a deliberate deviation, right-align the rupee amounts with a 10pt right pad and keep qty centred.
Rows are about 50pt tall with generous whitespace: the first baseline is 24.4pt below the header rule and the last is 23.1pt above the closing rule. Fix the 7pt description/number offset by aligning every cell to the same first baseline (alignItems 'flex-start', paddingVertical about 14).
## Totals and payment
reference:
- Totals block: right-aligned, 215.5pt wide (x 344.5 to 560), two 108pt columns with centred text. Labels Poppins Bold 12.5 (centre x 398.5); values Medium 12.5 (centre 505.9). Subtotal and Tax rows at 23pt pitch, then a 0.75pt rule, then Total about 20pt below it with both label and value in Bold.
- Payment block, footer left: heading 'PAYMENT INFORMATION :' in Bold 14.5, then a 3-column key/value grid: label (x 35.5) | ':' (x 149.4) | value (x 170.8), Medium 12.5, pitch 17.25. The first line is the account holder.
- Vendor block, footer right: name in Bold 12.5, then address / phone / website in Medium 12.5.
- No amount in words, notes, signature or thank-you.
Voiced version:
- Totals rows at the same 23pt pitch: Subtotal, Discount, CGST (9%), SGST (9%) or IGST (18%), then the rule, then Total.
- Amount in words left of the totals block: x 36 to 330, Poppins Medium 10.5, top-aligned with Subtotal. Notes go below it.
- Signature in the empty 598-705 band on the right (x 396 to 560): an image up to 150x45 plus 'Authorised Signatory' in Medium 10, or the computer-generated note.
## Decoration and images
No images and no fills, only black strokes:
- 0.75pt rules at y 116.75, 238.3, 521.6, 577.5 (totals block only, 215.5pt long), 705.1 and 812.45
- 1.5pt rules at y 326.2 and 365.5
Every rule except the totals rule spans the full content width. All of it is trivial to rebuild with borderTop/borderBottom on flow Views.
## Mapping to Voiced fields
- 'INVOICE': 'INVOICE' or 'TAX INVOICE', with the font size auto-fitted.
- 'CAVEIN', top right: vendor logo, right-aligned, at most 28pt tall so the 3-line stack keeps its 21pt pitch (a 3:1 PNG becomes 84x28). Use vendorName (Bold 12.5) when there is no logo.
- '15 March 2025': invoiceDate in the same format. Add a 'Due: 30 March 2025' line (Regular 12.5) for dueDate.
- 'INVOICE NO.15325': 'INVOICE NO. ' + number.
- BILLED TO name / address / phone: clientName / clientAddress (wrapping) / 'GSTIN: ' + clientGstin / 'Place of supply: ...' / 'PO: ' + poNumber. Voiced has no client phone.
- Free 88pt band (238 to 326): vendor statutory line(s) on the left, 'GSTIN: vendorGstin PAN: vendorPan State: vendorStateCode' in Medium 10.5, with the due date or PO on the right. Or collapse the band.
- Table: description / rate (a dash when mode is fixed) / qty with unit / amount.
- Subtotal / Tax / Total: subtotal, discount, CGST + SGST or IGST rows with rates, total.
- PAYMENT INFORMATION: bank.accountName; 'Bank : ' bankName; 'Branch : ' branch; 'Account No. : ' accountNo; 'IFSC : ' ifsc; 'UPI : ' (planned).
- Footer right, 'SUDIUM LOVRA' and its lines: vendorName, vendorAddress (wrapping), vendorPhone, vendorEmail. Website is not in Settings.
Voiced fields with no reference slot, and where they go:
- Logo: top right
- GSTIN/PAN: the free band or the vendor footer
- Amount in words: left of the totals
- CGST/SGST/IGST rows: totals block
- Signature or computer-generated note: right side of the 598-705 band
- Notes: left side of that band
- HSN/SAC (not in types.ts): a grey 10pt sub-line under the description such as 'SAC 998314', or a 60pt column between Description and Price
## Overflow risks
- Header collision, the same class as the reported bug. 'INVOICE' already ends at x 300.5. 'TAX INVOICE' at 60pt is about 385pt and would end near 436, while the right stack's widest line starts at 447.5 ('INVOICE NO.15325'). A realistic number such as 'INVOICE NO. INV/2026-27/0042' is about 190pt in Bold 12.5 and would start near 370, overlapping the title. Fix: make the header a flex row with a fixed max width on the right stack (about 200pt, text wraps, alignItems flex-end), and set the title size to min(60, fit-to-available-width), measured with fontkit or a precomputed width table. Alternatively drop to 44pt for 'TAX INVOICE'.
- Client block: reference has a single 162pt address line. Long addresses wrap (524pt of content width allows about 80 chars per line at 12.5pt), which is fine in flow layout, but the rule at 238.3 must be a border after the content.
- Items: descriptions sit in a column of about 217pt (about 33 chars per line in Medium 12.5) and wrap, so rows grow. Rows are about 50pt tall and reference fits only 3, so 4-5 items or more push onto page 2. Reduce paddingVertical to about 9 when there are more than 5 items.
- Totals values are centred in a 108pt column. 'Rs 12,34,567.00' (with the rupee sign) in Medium 12.5 is about 95pt and fits; Rs 1 crore and above is about 110pt and overflows. Right-align the values and let the column grow (flexShrink 0, minWidth 108).
- Footer: reference anchors it at the page bottom (705 to 812). With IFSC/Branch/UPI the payment grid grows to 6 lines (104pt at 17.25 pitch). The vendor column is 164pt wide and with a wrapped address, phone, email, GSTIN and PAN it reaches about 7 lines (about 120pt). The footer then needs about 150pt; starting at 705 it would end near 855, past the 842pt page. Make it a flow block after the totals with wrap={false} so it moves to the next page whole, or move GSTIN/PAN up into the free band.
- Payment grid: the label column is fixed at 114pt (35.5 to 149.4), which suits the labels. The value column is about 225pt, so long bank names wrap.
## US Letter adaptation
- Keep the 35pt side margins (user-overridable). Content width goes from 524 to 541 on Letter; the number columns stay at 98pt and Description takes the extra 17pt.
- The 50pt height loss is absorbed by collapsing the free 88pt band to about 38pt, or removing it when empty. The footer's bottom rule stays about 30pt above the page bottom.
- The title auto-fit works from available width, so Letter's extra width helps 'TAX INVOICE' slightly.
## react-pdf notes
- Page:
<Page size={size} style={{ paddingTop: 30, paddingBottom: 30, paddingHorizontal: 35, fontFamily: 'Poppins', fontWeight: 500, fontSize: 12.5, lineHeight: 1.38, color: '#000' }}>
- Header:
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingBottom: 11, borderBottomWidth: 0.75 }}>
<Text style={{ fontWeight: 700, fontSize: titleSize, lineHeight: 1, marginLeft: 16, flexShrink: 1 }}>...</Text>
<View style={{ width: 200, flexShrink: 0, alignItems: 'flex-end' }}> 3 lines, lineHeight 1.68 </View>
</View>
- Rules as borders: the client block is a View with borderBottomWidth 0.75 and paddingVertical 14. The table header is a View with borderTopWidth 1.5, borderBottomWidth 1.5, height 39.3, alignItems 'center', and minPresenceAhead about 60.
- Rows:
<View wrap={false} style={{ flexDirection: 'row', paddingVertical: 14, alignItems: 'flex-start' }}>
The rows container ends with borderBottomWidth 0.75.
- Totals:
<View wrap={false} style={{ alignSelf: 'flex-end', width: 215.5 }}>
Each row is flexDirection row with two 108-wide columns, textAlign center. The Total row has borderTopWidth 0.75 and paddingTop 8.
- Footer: a <View wrap={false}> with borderTopWidth 0.75 and borderBottomWidth 0.75. Two columns: flex 1 on the left, width 164 on the right. Payment grid rows:
<View style={{ flexDirection: 'row' }}>
<Text style={{ width: 114 }}>Bank</Text>
<Text style={{ width: 21, fontWeight: 400 }}>:</Text>
<Text style={{ flex: 1 }}>...</Text>
</View>
- To push the footer to the bottom of the last page, put a <View style={{ flexGrow: 1 }} /> spacer between the totals and the footer. Do not use fixed; the footer should appear once.
- Title auto-fit: width = font.layout(text).advanceWidth / unitsPerEm x size via fontkit. Poppins Bold 'INVOICE' is 4.161 x size; precompute 'TAX INVOICE' the same way.
- Fonts: Poppins 400/500/700 as static TTF/WOFF. Money fallback: ['Poppins','IBM Plex Sans'].
## Similarity to other templates
Structurally this is the closest of the three to the existing in-app InvoiceDocument: a title with a meta stack, rule dividers, a ruled table with a heavy top rule, right-hand totals with a rule above Total, and bank details at bottom left. It is also close to format.pdf/4.pdf (rules, a column-header row with a rule, right-hand totals). It is the cheapest to build, as a restyle of the existing component: Poppins, a 60pt auto-fit title, 1.5pt table rules, centred numbers and a two-column footer. Within the set, 7 and 11 share the big Poppins Bold INVOICE with the brand at top right; 3 is the monochrome, rules-only variant.
## Implementation notes
Built as `src/pdf/templates/monolith/`: a standalone template (`tokens.ts` the type roles and reference distances, `plan.ts` the pure planner, `Layout.tsx` the body, `monolith.tsx` the definition), on the shared blocks and the `AccentRule` decor primitive. Measured against `3.pdf` with `npm run templates:compare -- monolith` (`/tmp/invoice-c6a/compare-monolith.png`, left reference, right ours; `--numbers` prints the baselines).
**Matches the design (measured, A4, no due date so the stack is the design's three lines):** INVOICE origin x 50.7 with its baseline at 96.0 (reference 96.2); the vendor name, date and INVOICE NO. lines at baselines 63.7 / 84.7 / 105.7 with their right edge at x 560.3 (reference 560.6); BILLED TO at baseline 153.0 and x 35 (reference 153.0, 34.4); client lines at 178.1 with the 17.25 pitch; Poppins 400/500/700 at the design's sizes (60.02 / 14.5 / 12.5 / 10.5), black only (a test scans the sources for any other colour); 0.75 pt rules, the table between two 1.5 pt rules around a 39.3 pt header; the totals block 215.5 pt wide with centred labels, a 0.75 pt rule over Total and the 23 pt row pitch; the footer grid with its 114 pt label column and the colon column at x 149; the vendor column at x 395.
**The logo.** The slot is the text-only "CAVEIN" placeholder (46.8 x 9 pt), so the logo is fitted to its 160 x 28 pt allowance, right-anchored, and the brand zone of the right-hand stack reserves the logo's whole box: the date and number lines are laid out under it, never over it (the earlier contact sheet showed a 28 pt logo covering the date line). A wordmark replaces the vendor name, a mark keeps it to its left, a tall logo is held to 28 pt, and with no logo the vendor name is printed in Bold 12.5 at the design's position. The zone is the `SUPPLIER_ID` box and the date and number lines the `META_ID` box, so the harness proves they do not meet.
**Intentional deviations, and why:**
- **Title.** The design's 60 pt is shrunk (to 30 pt at the least) when the right-hand stack leaves less than the title needs: TAX INVOICE comes out at about 51 pt on A4, and a long invoice number shrinks first (IdText) before the title does.
- **Right-hand stack.** The design's three lines plus a "Due:" line (Regular 12.5) when the invoice has a due date, and a red CANCELLED line on a cancelled one. The rule under it follows the stack, so an invoice with a logo or a due date starts 13 to 34 pt lower than the design.
- **Supplier identifiers.** The design's empty band under the client holds a SUPPLIER heading with the GSTIN (through `Slot.Gstin`) and PAN, in the footer's right-hand column x. They are on page 1 whatever the length of the invoice. The supplier's address, phone and e-mail are the footer's vendor block, as in the design, so on a multi-page invoice they appear on the last page only.
- **Table.** Numbers are right-aligned with a 10 pt pad (the design centres them; the doc's recommendation) except QTY, which stays centred; the cells of a row share one baseline (the reference design's description and numbers are 7 pt apart); the rate's unit is `/hr` or a second line, HSN/SAC a 10 pt sub-line. Row padding is 16 pt for up to 3 rows, 12 for up to 5 and 8 beyond, then the elastic gaps take over.
- **Elastic gaps.** The 88 pt band under the client (down to 24), the row padding (down to 6), the gaps above BILLED TO and under the client block (down to 14) and the two gaps in the footer (down to 8) give back Letter's missing 50 pt, and the overflow of an invoice that is a little too long for one page. A 2-item tax invoice with a bank block fits one A4 page that way; with 3 rows and a signature image the closing block moves to page 2 (the items and totals stay together on page 1).
- **Amount in words and reverse charge** are the totals' left column (Medium 10.5, x 35..329). **Notes** and the **signature** (an image up to 150 x 45, or the signatory name and designation and the computer-generated line) form the band above the footer's top rule; notes taller than 110 pt flow as their own block before the closing.
- **Footer position.** The closing block ends 24 pt above the "Generated with Voiced" line, so its bottom rule is at y 804 where the reference design's is at 812.
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (band, info gaps, row padding) alone, as in Marble: the footer's own two gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 4 or 5 rows, Letter with 4).
- **Flow notes guard.** The flow-notes block is preceded by a zero-height `TableGuard` asking for 84 pt (14 gap + 70 hold), so it never starts with less than its label and first line of room and collapses into zero-height lines at a page foot; it starts on the next page instead (Marble's guard).
+158
View File
@@ -0,0 +1,158 @@
# Template 4: Linea (could also be labelled 'Original': this is the vendor's own format.pdf design) (`linea`)
Source: `templates/reference/4.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Airy, all-charcoal minimalism. A very large letter-spaced Montserrat 'INVOICE' is followed by a hairline rule. Everything else is 10pt Montserrat. The item table has no borders between two hairlines, and a script signature sits bottom-right. There is no logo, no colour and no boxes.
## Fonts
- Montserrat Regular (400): the title 'INVOICE' is 38.01pt, letterSpacing 7.6pt (0.20em), uppercase. All other regular text is 10pt, letterSpacing 0.5pt (0.05em), with a 13.5pt line pitch (lineHeight 1.35). That covers client lines, bank lines, DATE:/DUE DATE: labels and values, the table header (uppercase, NOT bold), item rows, the SUBTOTAL label and value, and 'Tax' with its rate.
- Montserrat Bold (700), 10pt, letterSpacing 0.5pt: 'ISSUED TO:', 'PAY TO:', 'INVOICE NO:' and its value, and the grand-total label 'TOTAL' and its value.
- BuongiornoRastellinoCyr-Script, 60.02pt, no tracking: the signature 'DanielGallego'. It is live text, not an image. The font is reference-licensed and cannot be redistributed, so replace it with the uploaded signature image.
- Metrics from the embedded descriptors: Montserrat ascent 968, descent -251, capHeight 700 per 1000 em. Cap height is 7.0pt at 10pt and 26.6pt at 38pt.
- Montserrat is OFL. The local copies ~/.local/share/fonts/Montserrat-Regular.ttf and Montserrat-Bold.ttf are Version 9.000 and include U+20B9 (₹), which I checked by parsing the cmap. Bundle these two TTFs plus OFL.txt in public/fonts.
## Colours
Single colour only. All text and every rule is #3D3B3A (PDF rg 0.2392 0.2314 0.2275 = rgb 61,59,58, a warm charcoal). There are no other greys and no accent. The page background is #FFFFFF, set by an explicit full-page white fill. Reference also writes three invisible (alpha 0) tagged rectangles that duplicate the rules for accessibility. Ignore them.
## Layout
Page is 595.5 x 842.25pt (the reference design's A4). In react-pdf use A4 (595.28 x 841.89); the difference is under 0.05%. y is measured from the top edge.
**Grid:** hairline rules span x 69.64-525.86 (456.2pt wide, centred on the page). Body text starts at x 77.4, 7.8pt inside the rule ends. The right text edge is 519.2 for headers and meta values, and 514.4 for money values. Implied margins: left/right 69.6 for rules; 77.4 (left) and 76.3 (right) for text; top 135 to the title cap-top; bottom about 70 below the signature ink.
1. **Empty top band**, y 0-135.
2. **Title row**, y 135-162. 'INVOICE' spans x 59.55-270.2, baseline 161.65, cap-top 135.0. It starts 18pt LEFT of the text column and 10pt left of the rule start. A 0.75pt hairline runs from x 290.05 to 535.57 at y about 148.7, the vertical middle of the capitals, with a 20pt gap after the title. The rule overshoots the table's right edge by about 10pt, mirroring the title's overhang, so the row (59.5-535.6) is centred on the page.
3. **Empty space**, y 162-262 (about 100pt).
4. **Info row**: two columns sharing the first baseline 269.22.
- Left, at x 77.4: 'ISSUED TO:' (Bold) at baseline 269.22, then 3 lines at 285.19 / 298.69 / 312.20. That is 15.97 from label to first line, then a 13.5 pitch.
- Right, meta block with 2 sub-columns. Labels are right-aligned at x about 447: 'INVOICE NO:' (Bold) x 376.2-447.7, 'DATE:' 414.6-446.6, 'DUE DATE:' 387.3-446.8. Values are right-aligned at x about 518.1-519.2: '01234' (Bold) 487.8-519.2, dates 465.3-518.1. Baselines are 269.22 / 284.69 / 299.40. The label-to-value gap is about 18.7pt. The meta block spans x 376-519 (143pt).
5. **PAY TO**, left column at x 77.6: 'PAY TO:' (Bold) at baseline 337.73, 25.5pt below the last client line. Then 3 lines at 353.98 / 367.49 / 380.99.
6. **Empty space**, y 381-441.
7. **Items table**, x 69.64-525.86. Header baseline 447.93; rule at y 461.15; rows start at baseline 484.25 with a 21.46 pitch (7 rows, last at 613.33); closing rule at y 631.44.
8. **Totals**, no box. SUBTOTAL at baseline 649.82, with the label at x 77.45 and the value right-aligned at 514.4. 'Tax' (baseline 665.04) and 'TOTAL' (baseline 680.09) form a right-anchored label/value pair: labels right-aligned at 460.3, values right-aligned at 514.4, 15pt pitch.
9. **Signature**, bottom-right: script text x 352.7-526.2, baseline 757.37, ink about y 712-772.
10. About 70pt empty at the bottom. No header, footer or page number.
## Items table
- **Columns (x):**
- DESCRIPTION: left-aligned; header at 77.45, cells at 81.6. The 4pt indent is a reference artefact and optional.
- UNIT PRICE: centred on x 325.2 (header 293.6-356.8; cell '100' 316.3-334.2).
- QTY: centred on x 416.4 (header 405.7-427.1).
- TOTAL: right-aligned; header right edge 519.2, money cells right edge 514.4.
- Unit price shows a bare number; the currency symbol appears only in TOTAL.
- **Header:** Montserrat Regular 10pt, UPPERCASE, letterSpacing 0.5pt, #3D3B3A. No fill, no top border. ONE 0.75pt #3D3B3A rule 13.2pt below the header baseline (y 461.15), spanning x 69.64-525.86, which is 7.8pt beyond the text on both sides.
- **Body:** Montserrat Regular 10pt, letterSpacing 0.5pt. First baseline is 23.1pt below the rule. Pitch is 21.46pt (13.5 line plus about 4pt padding top and bottom). No row dividers, no zebra, no column borders. A closing 0.75pt rule sits 18.1pt below the last baseline (y 631.44).
- **Numeric alignment:** unit price and qty centred, amount right-aligned. The sample has no decimals.
- **Suggested Voiced widths** (440.6-441.8pt between text edges): description flex:1 (about 210pt, wraps); rate 96pt centred; qty 48pt centred; amount 86pt right. Column centres stay within about 12pt of the design while fitting '₹1,500.00/hr' and '₹1,00,000.00'.
## Totals and payment
- **SUBTOTAL:** a full-width row. The label 'SUBTOTAL' (Regular, caps) sits at the left text edge, x 77.45. The value (Regular) is right-aligned at 514.4. Baseline 649.82, 18.4pt below the closing rule.
- **Tax row:** 'Tax' label right-aligned at 460.3, value '10%' right-aligned at 514.4, baseline 665.04. The value is the RATE only; the sample never prints a tax amount.
- **Grand total:** 'TOTAL' (Bold) right-aligned at 460.3 and '$770' (Bold) right-aligned at 514.4, baseline 680.09. There are no rules, boxes or size change; only the weight sets the total apart.
- **Payment info:** NOT at the bottom. The 'PAY TO:' bank block sits in the left column directly under 'ISSUED TO:', with bank name, 'Account Name: …' and 'Account No.: …'.
- **Signature:** a script-font name bottom-right (60pt, right edge 526.2).
- No notes, terms, thank-you line or footer.
## Decoration and images
- **Title hairline:** 0.75pt #3D3B3A, x 290.05-535.57, y about 148.7, the vertical centre of the 38pt capitals.
- **Table hairlines:** two 0.75pt #3D3B3A rules, x 69.64-525.86, at y 461.15 and 631.44.
- **No raster images at all.** The 42 KB file is just three font subsets. The signature is live text in BuongiornoRastellinoCyr-Script.
- Reference wraps everything in one Form XObject scaled by 0.24 (2482x3510 design units). This doesn't matter for the re-implementation.
- **Fully reproducible in react-pdf** with Views: height 0.75 plus backgroundColor, or borderBottomWidth 0.75.
## Mapping to Voiced fields
**Design slots mapped to Voiced fields:**
- **Title 'INVOICE':** 'INVOICE', or 'TAX INVOICE' when taxType !== 'none'.
- **ISSUED TO block:** clientName; clientAddress (wrap, keep line breaks); 'GSTIN: {clientGstin}' only if set; 'Place of Supply: {state name} ({placeOfSupplyStateCode})'.
- **Meta block (right):** 'INVOICE NO:' with the number (Bold); 'DATE:' invoiceDate; 'DUE DATE:' dueDate, omitting the row if empty; an optional 'PO NO:' poNumber row.
- **PAY TO block:** bank.bankName; 'Account Name: …'; 'Account No.: …'; then 'IFSC: …', 'Branch: …' and the planned 'UPI: …'. The block grows from 3 to 6 lines.
- **Table:**
- Description.
- UNIT PRICE: in rate mode, money(rate) plus a short unit ('/hr', '/min', '/sec', '/session', '/unit'); otherwise '—'.
- QTY: quantity in rate mode, else 1.
- TOTAL: computeLineAmount.
- **Tax row:** replace '10%' with real rows that carry amounts: 'CGST @ 9%' and 'SGST @ 9%', or 'IGST @ 18%'. Put a 'DISCOUNT' row (when greater than 0) before the tax rows, styled like them.
- **Signature slot:** the signature image, in a box of about x 352-526 / y 712-772, max 170x56pt, right-aligned, with 'Authorised Signatory' at 8pt. With no image, print 'Computer-generated invoice; no signature is required.' at 8pt, right-aligned, in the same spot. Never render the vendor name in a script font as a stand-in signature.
**Voiced fields with NO slot, and where to put them:**
- **Vendor name, address, phone, email, GSTIN, PAN.** The design has no vendor identity at all. Add a 'FROM' block right-aligned under the title rule, in the empty band x 290-535 / y about 170-255. Name in Bold 10pt; lines in Regular 9.5-10pt at a 13.5 pitch; 'GSTIN … · PAN …' on one line; maxWidth 245pt. It is in its own row below the title, so a long address can only push content down. It can never overlap the title, which is the reported header bug.
- **Vendor logo:** optional, in the empty top band above the title (x 59.5, y about 56-120, max 150x48pt).
- **Amount in words:** on the left side of the totals block (x 77.4, width at most 300pt) beside the Tax/TOTAL rows, or full width under TOTAL. 9pt, with the label 'AMOUNT IN WORDS' in Bold 8pt caps.
- **Notes:** full width below the amount in words, above the signature row.
- **HSN/SAC:** not in the data model. If added, use an 8pt second line under each description ('SAC 998314') or a 44pt column between DESCRIPTION and UNIT PRICE.
- **Footer:** 'Generated with Voiced — a Bongbetic product' plus 'Page n / N', fixed, 7pt, #3D3B3A, at y about 818.
## Overflow risks
- **Title:** 'TAX INVOICE' at 38pt with 7.6pt tracking is about 326pt wide, ending near x 386. That is 96pt past the fixed rule start (290). The rule must be a flex filler (minimum about 40pt), or the tracking must drop to about 0.12em for 'TAX INVOICE'.
- **Meta block:** only 143pt wide (x 376-519). A number like 'INV/2026-27/0001' in Bold 10pt with 0.5pt tracking is about 105pt, plus the 72pt label and 18pt gap, about 195pt in total. The block must grow leftwards: give the client column flex:1 with at least a 24pt gap, and the meta column flexShrink:0.
- **Client and bank column:** about 260pt from x 77.4 to the meta block, so long names and addresses wrap at roughly 45 characters per line. PAY TO grows to 6 lines (+40pt) and pushes the table down.
- **UNIT PRICE column:** about 70pt in the design, too narrow for '₹1,500.00 per hour' (about 100pt). Use short units or a second 8pt line.
- **Totals values:** they sit in only a 54pt gap after the labels (460.3 to 514.4). '₹12,34,567.89' (about 85pt) would run into 'CGST @ 9%'. Use a fixed value column of about 90pt; labels then end around x 420.
- **Descriptions:** they wrap at about 210pt (about 36 characters). The fixed 21.46 pitch must become padding (4pt top and bottom) so multi-line rows grow.
- **Rows per page:** page 1 holds about 7-8 rows before the totals and signature (about 150pt) spill to page 2. That needs a repeating header and totals/signature blocks that never split.
- **Amount in words:** up to about 100 characters, so 2 lines at a 300pt width.
- **Hyphenation:** react-pdf hyphenates by default. Register a no-op hyphenation callback so GSTINs, account numbers and URLs never get a stray hyphen.
## US Letter adaptation
- **US Letter (612x792):** 16.5pt wider and 50.25pt shorter than A4.
- **Horizontal:** keep the 69.6pt rule margins with the 7.8pt text inset, so content width grows from 456.2 to 472.8. Anchor meta, totals and signature to the right margin; anchor ISSUED TO, PAY TO and the table's left edge to the left margin. The title overhang (-18pt) and rule overshoot (+10pt) stay relative to the margins.
- **Vertical:** take the 50pt from the empty space. Cut the top band from 135 to about 100 before the title cap-top, and the title-to-ISSUED TO gap from about 100 to about 80. Nothing else changes, and the 7 sample rows still fit on Letter page 1.
- **Configurable margins:**
- Express every x as marginLeft + offset, or pageWidth - marginRight - offset. Keep the 7.8pt text inset inside the rule margin.
- Clamp the title (-18pt) and rule (+10pt) overhangs to stay at least 12pt from the paper edge.
- Express vertical anchors as marginTop + offset. Content flows, so smaller margins simply give more rows per page.
## react-pdf notes
- **Fonts:** Font.register({family:'Montserrat', fonts:[{src:'/fonts/Montserrat-Regular.ttf', fontWeight:400},{src:'/fonts/Montserrat-Bold.ttf', fontWeight:700}]}). Page style: {fontFamily:['Montserrat','IBM Plex Sans'], fontSize:10, letterSpacing:0.5, lineHeight:1.35, color:'#3D3B3A'}. Array fallback in fontFamily is supported in react-pdf v4; I checked the docs.
- **Page:** size A4 or LETTER, paddingHorizontal 77.4 (the text column). Rules use marginHorizontal -7.8 to reach 69.6 and 525.9.
- **Header:** the optional logo band, then the title row: View {flexDirection:'row', alignItems:'center', marginLeft:-18, marginRight:-16} holding Text {fontSize:38, letterSpacing:7.6} and a rule View {flex:1, height:0.75, backgroundColor:'#3D3B3A', marginLeft:12.4}.
- marginLeft is 12.4 because react-pdf, like CSS, adds the 7.6pt tracking after the last glyph; 12.4 + 7.6 gives the design's 20pt visual gap.
- For Montserrat the middle of the text box is within 0.3pt of the cap middle, so alignItems 'center' places the rule correctly.
- **Vendor FROM block:** a separate View {alignSelf:'flex-end', maxWidth:245, marginTop:≈10} with textAlign right.
- **Info row:**
- Row View {flexDirection:'row', marginTop:≈100 for the design's empty space}.
- Left View {flex:1, paddingRight:24}; right meta View {flexShrink:0}.
- Each meta row is a row View: label Text (textAlign right, width 90) and value Text (textAlign right, minWidth 54, marginLeft 18).
- PAY TO is an in-flow View with marginTop 17 under ISSUED TO.
- **Table:**
- Header row: View {flexDirection:'row', paddingBottom:8.5, borderBottomWidth:0.75, borderBottomColor:'#3D3B3A', marginHorizontal:-7.8, paddingHorizontal:7.8}. Give it the `fixed` prop so it repeats on continuation pages. Verify this; if it doesn't repeat, draw it on later pages through the page-level render prop.
- Rows: View {flexDirection:'row', paddingVertical:4} with wrap={false}.
- The table container's borderBottomWidth 0.75 plus paddingBottom 9 gives the closing rule.
- **Totals:** View wrap={false}, with minPresenceAhead about 60 on the preceding element so the totals never end up alone on a page.
- SUBTOTAL row: justifyContent 'space-between'.
- Then View {alignSelf:'flex-end', width:≈230} with rows of label (flex:1, right-aligned) and value (width 90, right-aligned). TOTAL uses fontWeight 700.
- **Signature:** View wrap={false}, alignSelf 'flex-end', marginTop about 30. The Image needs an explicit width/height box plus objectFit 'contain' and objectPosition 'right'.
- **Footer:** View fixed, absolute, bottom 20, left/right 77.4, fontSize 7, using render={({pageNumber,totalPages}) => …}.
- **Hyphenation:** Font.registerHyphenationCallback(w => [w]).
## Similarity to other templates
- **Byte-identical page content to /media/Toto/Documents/bongbetic/invoice/format.pdf.** It is the same reference page; only the Info/XMP title, export timestamps, reference design ID, xref offsets and trailer /ID differ. So this IS the vendor's original format, still carrying the reference design'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).
- **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 reference, right ours).
**Matches the design (measured, A4):** title baseline 161.6 pt and x 59.5 as in reference; 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 (reference 21.46); table rules at x 69.6-525.6 (reference 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); the reference design'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 (reference 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. the reference design'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.
+163
View File
@@ -0,0 +1,163 @@
# Template 5: Monogram (`monogram`)
Source: `templates/reference/5.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
The Linea grid restyled in Open Sans. A heavy, tracked 'INVOICE' faces a circular script-monogram logo. Labels and table headers are bold and tracked, and payment info shares the bottom row with a script signature.
## Fonts
- **Open Sans Bold (700):**
- Title 'INVOICE': 38.01pt, letterSpacing 3.8pt (0.10em).
- 10pt labels with letterSpacing 1.0pt (0.10em): 'ISSUED TO:', 'INVOICE NO:' and its value, the table header 'DESCRIPTION/RATE/QTY/TOTAL', 'SUBTOTAL' and its value, 'TOTAL' and its value, 'PAYMENT INFO:'.
- Logo caption 'MORGAN MAXWELL': 9.31pt, letterSpacing 0.47pt (0.05em).
- **Open Sans Medium (500)**, 10pt, letterSpacing 1pt: client lines and bank lines (address-type text).
- **Open Sans Regular (400)**, 10pt, letterSpacing 1pt: 'DATE:'/'DUE DATE:' labels and values, the table body, and 'Tax' with its rate. Stacked lines use a 13.5pt pitch (lineHeight 1.35).
- **reference-licensed fonts, not to be shipped:**
- TheBigmaker 325.55pt: the monogram 'm.' (logo art).
- BuongiornoRastellinoCyr-Script 70.02pt: the signature 'Maxwell'.
- The vendor's logo and signature images replace both.
- **Metrics** from the descriptors: Open Sans ascent 1069, descent -293, capHeight 714 per 1000; cap height is 7.1pt at 10pt.
- **Bundling:** Open Sans is OFL; download static TTFs for 400/500/700 from Google Fonts. It isn't installed on this machine, so I couldn't check U+20B9 (₹) coverage. Verify the chosen TTF, and keep fontFamily ['Open Sans','IBM Plex Sans'] so ₹ falls back to Plex if it's missing.
## Colours
Single colour only. Every glyph, rule and the monogram ring is #3D3B3A (rgb 61,59,58). The page is #FFFFFF. The monogram's gap comes from a #FFFFFF rotated rectangle painted over the ring, so that trick only works on white.
## Layout
Page is 595.5x842.25 (A4). Same grid as Linea: rules x 69.64-525.86; text column 77.4 to 519.2; money right edge 514.4.
1. **Header band**, y about 58-200.
- Title 'INVOICE': x 69.3-248.6, baseline 143.98, cap-top 116.8. It sits flush with the rule start (69.6), not the text column.
- Monogram logo, top-right; group bounding box about x 387-531 / y 58-198.
- Ring: outer diameter 116.4 (x 414.2-530.6, y 81.3-197.7), centre 472.4/139.5, visible stroke 3.75pt.
- About 84° of the ring is missing on the left (roughly 7:20 to 10:10 o'clock), where a script 'm.' crosses it (baseline 195.75, advance x 398.5-496.9).
- Caption 'MORGAN MAXWELL' is centred at x about 448, baseline 169.81 (x 399.3-496.9).
- The ring centre sits about 4.5pt above the title baseline.
2. **Empty space**, y 200-318 (about 118pt).
3. **Info row**, first baseline 325.42.
- Left, at x 77.6: 'ISSUED TO:' plus 3 Medium lines at 341.38 / 354.89 / 368.39.
- Right, meta block. Labels right-aligned at about 447.7: 'INVOICE NO:' 375.0-447.75, 'DATE:' starts 415.3, 'DUE DATE:' starts 388.8. Values: '01234' 486.6-519.2; dates 458.1-518.1. Baselines 325.42 / 340.88 / 355.60.
4. **Empty space**, y 368-428.
5. **Table:** header baseline 435.09; rule at y 447.56; rows at baseline 471.41 + n × 24.94 (5 rows, last at 570.58); closing rule at y 587.46.
6. **Totals:**
- SUBTOTAL at 606.59: Bold, label x 77.45, value right-aligned at 514.4.
- Tax at 625.98: label right-aligned at 460.3, '10%' right-aligned at 514.4.
- TOTAL at 643.86: Bold.
7. **Empty space**, y 644-700.
8. **Bottom row:**
- Left: 'PAYMENT INFO:' (Bold) at baseline 708.85, x 77.6, plus 3 Medium lines at 725.11 / 738.61 / 752.12.
- Right: signature (script, 70pt) x 392.3-518.1, baseline 759.31, ink about y 705-775.
9. About 65pt empty at the bottom; no footer.
## Items table
- **Columns:** same geometry as Linea.
- DESCRIPTION: left; header at 77.45, cells at 78.0.
- RATE: centred on 325.2 (header 311.3-339.2; cell '100' 315.7-334.8).
- QTY: centred on 416.4 (header 405.4-427.4).
- TOTAL: right-aligned; header right edge 519.2, cells right edge 514.4.
- **Header:** Open Sans Bold 10pt, UPPERCASE, letterSpacing 1pt, no fill. A single 0.75pt #3D3B3A rule 12.5pt below the header baseline (y 447.56), x 69.64-525.86.
- **Body:** Open Sans Regular 10pt, letterSpacing 1pt. First baseline is 23.85pt below the rule. Pitch is 24.94pt (13.5 line plus about 5.7pt padding top and bottom). No dividers or zebra striping. A closing 0.75pt rule sits 16.9pt below the last baseline (y 587.46).
- **Alignment:** rate and qty centred, amount right-aligned. '$' appears only in the TOTAL column.
- **Suggested Voiced widths:** description flex:1, rate 96 centred, qty 48 centred, amount 86 right. Open Sans with 1pt tracking averages about 5.6pt per character, so descriptions fit about 36 characters per line.
## Totals and payment
- **SUBTOTAL:** a full-width row with label AND value in Bold (Linea uses Regular here). Baseline 606.59, 19.1pt below the closing rule. Label at x 77.45, value right-aligned at 514.4.
- **Tax:** 'Tax' (Regular) right-aligned at 460.3, '10%' right-aligned at 514.4, baseline 625.98. Rate only, no amount.
- **TOTAL:** Bold label right-aligned at 460.35 and Bold value right-aligned at 514.4, baseline 643.86. No rules, boxes or size change; rows are about 18-19pt apart.
- **Payment:** the 'PAYMENT INFO:' block sits bottom-left (baseline 708.85; bank name, Account Name and Account No. in Medium). It shares a row with the signature on the right, and their bottoms roughly line up: last bank line 752.1, signature baseline 759.3.
- No notes, terms, thank-you line or footer.
## Decoration and images
- **Rules:** two 0.75pt table rules at y 447.56 and 587.46, x 69.64-525.86.
- **No raster images** (the file is 54 KB).
- **Monogram construction (vector plus text):**
- A circle path stroked at 7.5pt but clipped to its own interior, so the visible ring is 3.75pt (outer r 58.2, inner r 54.45, centre 472.43/139.50).
- A white rectangle, 134.4x77.8pt and rotated 34.5°, is painted over it. Its corners are about (356.7,58.8), (467.5,135.0), (423.4,199.1) and (312.6,122.9).
- The rectangle erases the arc from about 144.6° to 229° (maths angles), i.e. 84° on the left.
- The script 'm.' (TheBigmaker 325.6pt) and the caption text sit on top.
- **To draw the ring as vector without masking**, use an SVG arc only. Centre-relative path 'M -45.9 -32.6 A 56.33 56.33 0 1 1 -37.0 42.5' (y-down, large-arc 1, sweep 1), stroke #3D3B3A, strokeWidth 3.75, fill none.
- **Signature:** live script text at 70pt.
## Mapping to Voiced fields
- **Title:** 'INVOICE' or 'TAX INVOICE'. At 38pt with 3.8pt tracking, 'TAX INVOICE' is about 277pt wide and ends at x about 346, so it clears a logo that starts at x 387 or later.
- **Monogram slot** (x 387-531, y 58-198): the vendor logo Image in a box of about 130x116pt, objectFit contain, right-aligned, vertically centred on y about 139.5. Optional fallback when there is no logo: the ring arc above, the vendor's initial in an OFL script (e.g. Great Vibes or Allura, or simply Montserrat), and a vendor-name caption in Open Sans Bold 9.3pt, letterSpacing 0.47.
- **Vendor name, address, phone, email, GSTIN, PAN: no slot in the design.** Put a right-aligned FROM block directly under the logo: right edge at x 519-526, y about 205-305, maxWidth about 240pt. Name in Bold 10pt; lines in Medium 10pt or 9pt, letterSpacing 1pt. It is its own row below the header, so it cannot overlap the title.
- **ISSUED TO:** clientName, clientAddress, 'GSTIN: …' (optional), 'Place of Supply: …'.
- **Meta:** INVOICE NO / DATE / DUE DATE, plus an optional PO NO row.
- **Table:** DESCRIPTION; RATE (money(rate) plus '/hr' etc., '—' for fixed lines); QTY; TOTAL (line amount).
- **Totals:** SUBTOTAL; DISCOUNT when present; then 'CGST @ x%' and 'SGST @ x%', or 'IGST @ x%', each WITH its amount, replacing the 'Tax 10%' row; TOTAL.
- **PAYMENT INFO:** bank name, Account Name, Account No., IFSC, Branch, and the planned UPI.
- **Signature slot** (bottom-right, right edge 518): the signature image in a box of about 150x60pt, plus 'Authorised Signatory' at 8pt. Without an image, use the computer-generated note.
- **Fields with no slot:**
- Amount in words: on the left of the totals block beside Tax/TOTAL (x 77.4, at most 300pt wide), or full width below TOTAL.
- Notes: between the totals and the bottom row, full width, 9pt Regular.
- HSN/SAC: an 8pt sub-line under the description (not in the data model yet).
- Footer and page numbers: fixed, 7pt, at y about 818.
## Overflow risks
- **Header:** a wide wordmark logo (say 200pt) would collide with 'TAX INVOICE', which ends at about 346. Lay the header out as a row: title with flexShrink:1, logo box with maxWidth 140pt.
- **FROM block:** at most 240pt wide under the logo. Long addresses wrap to 3-4 lines (about 36 characters per line at Open Sans 10pt with 1pt tracking). It may push 'ISSUED TO' down, which is fine in flow layout.
- **Meta block** (x 375-519 = 144pt): long invoice numbers need the same grow-leftwards fix as Linea.
- **RATE column:** about 70pt in the design. Abbreviate unit labels or put them on a second 8pt line.
- **Totals:** labels end at 460.3 and values at 514.4, leaving only 54pt for amounts. Use a 90pt value column.
- **Description width:** Open Sans with 1pt tracking is wide, so the roughly 210pt description column holds about 36 characters per line. Rows must grow via padding (5.7pt) instead of a fixed 24.94 pitch.
- **Page 1 capacity:** about 6-8 rows. After that the totals (about 60pt) and the bottom row (payment plus signature, about 80-110pt with a 6-line bank block) spill to page 2. Keep the bottom row wrap={false}.
- **PAYMENT INFO** grows from 3 to 6 lines (+40pt). Keep the signature bottom-aligned with it (alignItems flex-end).
## US Letter adaptation
- **Width:** keep the 69.6pt rule margins, so content width goes from 456.2 to 472.8. Anchor the title to the left margin (x = marginLeft). Anchor the logo, FROM block, meta, totals and signature to the right margin; the logo may overshoot the rule end by 5pt.
- **Height (50pt shorter):** move the header band up (top from 58 to about 40) and cut the header-to-ISSUED TO gap from about 118 to about 85. The bottom row stays right after the totals plus about 56pt of space. When page 1 has little content, a flex spacer can push the bottom row to the page bottom.
- **Configurable margins:** the header band's top equals marginTop. The logo box keeps its 130x116 size but shrinks proportionally if pageWidth minus margins is under 440.
## react-pdf notes
- **Fonts:** register Open Sans 400/500/700 (static TTFs). Base style: {fontFamily:['Open Sans','IBM Plex Sans'], fontSize:10, letterSpacing:1, lineHeight:1.35, color:'#3D3B3A'}. Labels use fontWeight 700; address lines use 500.
- **Page:** paddingHorizontal 77.4. Give the title marginLeft -8 so it sits flush with the 69.6 rule start.
- **Header:** View {flexDirection:'row', justifyContent:'space-between', alignItems:'center', minHeight:140}.
- Title Text {fontSize:38, fontWeight:700, letterSpacing:3.8}.
- Logo View {width:144, height:140, alignItems:'center', justifyContent:'center', marginRight:-12}.
- The Image needs an explicit width/height box for objectFit 'contain' to work.
- **Optional monogram fallback:** <Svg width={120} height={120} viewBox='-60 -60 120 120'><Path d='M -45.9 -32.6 A 56.33 56.33 0 1 1 -37.0 42.5' stroke='#3D3B3A' strokeWidth={3.75} fill='none'/></Svg>, plus an absolutely positioned initial and caption. react-pdf Views have no clip or mask, so draw the arc rather than masking a full circle.
- **FROM block:** View {alignSelf:'flex-end', maxWidth:240, marginTop:8}, textAlign right.
- **Info row, table and totals:** exactly as Linea, except:
- Row paddingVertical 5.7 and header paddingBottom 7.5.
- SUBTOTAL is bold.
- letterSpacing is 1. react-pdf also adds tracking after the last glyph, so right-aligned columns end 1pt short; add marginRight:-1 if matching exactly.
- **Bottom row:** View {flexDirection:'row', justifyContent:'space-between', alignItems:'flex-end', marginTop:56}, wrap={false}. Left payment View {maxWidth:'55%'}; right signature box about 150x60.
- **Pagination:** table header row `fixed`; item rows wrap={false}; footer fixed with page numbers; hyphenation callback as in Linea.
## Similarity to other templates
- **Same reference design and grid as 4.pdf.** It is page 5 of design and shares 4.pdf's rules, column centres 325.2 / 416.4, money right edge 514.4, meta anchors about 447.7 / 519 and totals label anchor 460.3. It is a typography-and-decoration variant of Linea:
- Open Sans instead of Montserrat.
- Bold labels and header with 0.1em tracking.
- Row pitch 24.94 instead of 21.46.
- A monogram logo.
- The payment block moved to the bottom row.
- 'RATE' instead of 'UNIT PRICE', and a bold SUBTOTAL.
- **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.
## 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 (reference 143.98) and x 69.6 (flush with the rule start, 69.3 in reference); 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 the reference design'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 the reference design'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.
+177
View File
@@ -0,0 +1,177 @@
# Template 6: Serenity (`serenity`)
Source: `templates/reference/6.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
A boutique-stationery look. A warm cream paper texture, with a giant handwritten 'invoice' running up the left edge into a long swash. A circular-text stamp logo sits top-right, and a narrow Open Sans content column has hairline rules.
## Fonts
- **Open Sans Bold**, 10pt, letterSpacing 1pt (0.1em): 'ISSUED TO:', 'INVOICE NO:' and its value, the table header 'DESCRIPTION/UNIT PRICE/QTY/TOTAL', 'SUBTOTAL' and its value, 'TOTAL' and its value, 'BANK DETAILS', 'THANK YOU'.
- **Open Sans Medium**, 10pt, letterSpacing 1pt: client lines and bank lines.
- **Open Sans Regular**, 10pt, letterSpacing 1pt: DATE/DUE DATE labels and values, the table body, and 'Tax' with its rate. Line pitch 13.5 (lineHeight 1.35).
- **SimpleSerenityScript**, 279.48pt: the lowercase 'invoice', rotated 90° counter-clockwise. A reference-licensed decorative script; ascent 767, descent -454 per 1000.
- **Poppins Regular**, 7.98pt, uppercase, tracked about 0.17em: the circular stamp text 'DESIGN BY NATALIE • DESIGN BY NATALIE •'.
- **Montserrat Regular**, 16.61pt: the stamp's centre letter 'N'.
- **No signature font:** the signature is vector artwork.
- **Bundling:**
- Open Sans, Poppins and Montserrat are OFL. Bundle Open Sans 400/500/700; Poppins and Montserrat only if the stamp is recreated.
- SimpleSerenityScript can't be redistributed. Ship the 'invoice' lettering as an SVG asset (outlines), or reset it in an OFL script such as Ms Madi, Allison or Great Vibes.
## Colours
- **Ink:** text, rules, the script word, the flourish, the stamp and the signature are all #3D3B3A (rgb 61,59,58).
- **Background:** a full-bleed paper texture. Its measured average is #ECE8E1 (sRGB about 236,232,225), from decoding the JPEG's DC coefficients (Y 232.4 / Cb 123.9 / Cr 130.7).
- The grain is only in brightness: 8x8 block means range Y 228-236, with darker fibres down to about 203.
- No gradient or vignette: 12 page regions match within ±1.
- The image dictionary says /ColorTransform 0, but that is a producer quirk; the data is YCbCr. The ICC profile is a 536-byte Google/Skia profile, close to sRGB.
- **Page fill:** #FFFFFF underneath, fully covered.
## Layout
Page is 595.5x842.25. Two zones:
- A decorative band on the left (x about 55-155).
- A content column: rules x 162.91-531.39 (368.5pt); text from x 171.8 to a right edge of 524.7; money right edge 519.9.
Implied margins: left 162.9 (rules) / 171.8 (text); right 64.1 / 70.8; top about 128 (stamp); bottom about 59.
1. **Background:** the texture covers the page; the image bleeds to x -9.2..604.5, y -72.8..915.3.
2. **Left band:** script 'invoice' rotated 90° counter-clockwise, reading bottom to top.
- Baseline at about x 150, tilted 1.1°. The first glyph is at y 771.7; the last glyph ends at y about 409.8. Ink extends left to x about 60.
- A swash flourish continues upward from y about 411 to y about 142.5 (x 56-82).
- Band bounding box about x 55-155, y 142-772.
3. **Stamp**, top-right: centre (485.5, 172.7), bounding box about x 441-531, y 128-218.
- Ring text on radius 35.3pt (to the glyph baselines). Letters are upright at the top and read clockwise.
- Two round dots of 1.96pt diameter at (454.0,194.1) and (516.3,151.2) separate the two repeats.
- Inner ring: 30.9pt diameter, 0.75pt line, at (486.0,172.4).
- 'N' at baseline 178.53, x 478.8-492.3.
4. **Empty space**, y 218-313.
5. **Info row**, first baseline about 320.3/321.0.
- Left: 'ISSUED TO:' at x 172.0, baseline 320.29, plus Medium lines at 336.25 / 349.76 / 363.26.
- Right, meta block. Labels right-aligned at about 451.8: 'INVOICE NO:' 379.0-451.7, 'DATE:' 420.5-451.8, 'DUE DATE:' 394.1-452.0. Values right-aligned at 524.7: '01234' 492.1-524.7, dates 464.8-524.8. Baselines 321.04 / 335.75 / 350.47.
6. **Table:** header baseline 443.63; rule at y 456.09; rows at baseline 479.94 + n × 25.09 (5 rows, last at 580.32); closing rule at y 597.20.
7. **Totals:**
- SUBTOTAL at 616.33: label x 173.8, value right-aligned at 519.9.
- Tax at 636.86: label right-aligned at 465.9.
- TOTAL at 653.54.
8. **Empty space**, y 654-730.
9. **Bottom row**, baseline 736.98.
- Left: 'BANK DETAILS' at x 182.7, an 11pt inset from the column, plus Medium lines at 753.24 / 766.74 / 780.25.
- Right: 'THANK YOU' right-aligned at 532.4 (x 464.9-532.4), with the drawn signature below it (x 441.9-542.7, y 749.8-783.0).
10. About 59pt empty at the bottom.
## Items table
- **Columns:**
- DESCRIPTION: left at x 171.8 for both header and cells. It is only about 160pt wide (171.8 to about 335).
- UNIT PRICE: centred on 370.4 (header 338.1-402.6; cell '100' 360.8-379.9).
- QTY: centred on 441.8 (header 430.8-452.8).
- TOTAL: right-aligned; header right edge 524.8, cells right edge 519.9.
- **Header:** Open Sans Bold 10pt, caps, letterSpacing 1pt, no fill. A single 0.75pt #3D3B3A rule 12.5pt below (y 456.09), x 162.91-531.39.
- **Body:** Open Sans Regular 10pt, letterSpacing 1pt. The sample text is lowercase, but that is content, not a style rule.
- First baseline is 23.85pt below the rule.
- Pitch is 25.09, about 5.8pt padding top and bottom.
- No dividers or zebra striping.
- A closing rule sits 16.9pt below the last baseline (y 597.20).
- **Alignment:** unit price and qty centred, amount right-aligned.
- **Suggested Voiced widths** (352.9pt text column): description flex:1 (about 149pt); rate 84 centred (centre about 363); qty 40 centred (about 425); amount 80 right. Centres stay within about 17pt of the design.
## Totals and payment
- **Totals** (no rules or boxes):
- SUBTOTAL: Bold, label x 173.8, value right-aligned at 519.9, baseline 616.33.
- 'Tax': Regular label right-aligned at 465.9, '10%' right-aligned at 519.9, baseline 636.86.
- 'TOTAL': Bold label right-aligned at 465.9, Bold value right-aligned at 519.9, baseline 653.54.
- **Payment:** a 'BANK DETAILS' block bottom-left, with bank name, Account Name and Account No. in Medium.
- **Thank-you:** 'THANK YOU' in Bold caps, right-aligned on the same baseline as 'BANK DETAILS' (736.98), with a hand-drawn signature under it.
- No notes, terms or footer.
## Decoration and images
- **Background raster:** one JPEG, 2304x1384px (DCTDecode, ICC-based RGB, 988,001 bytes, 94% of the file). Its placement matrix rotates it 90° and stretches it unevenly to 613.7x988.1pt (about 165-168 dpi), bleeding past every edge.
- Recommendation: don't ship it. Use Page backgroundColor '#ECE8E1', optionally plus a downsampled or tiled texture (at most 150 KB, objectFit cover, fixed).
- **Vertical script word:** live text (SimpleSerenityScript 279.5pt, rotated +91.1° in PDF space).
- **Flourish:** 2 filled vector paths (142 and 40 points), x 56.1-82.1, y 142.5-411.0, clipped to x -66.8..188.3 / y 142.4..411.5. They live inside Form X23 (object 16), right after 'X9 Do'.
- **Stamp:** 39 individually rotated Poppins glyphs on a circle (r 35.3), plus two 1.96pt round dots (squares clipped to circles), an inner ring (1.5pt stroke clipped inside, so 0.75pt visible) and the 'N'.
- **Signature:** 6 filled vector subpaths (Form X15), x 441.9-542.7, y 749.8-783.0.
- **Rules:** two 0.75pt rules, x 162.91-531.39, at y 456.09 and 597.20.
- **Reproducibility in react-pdf:**
- Rules, the texture colour, rotated text and SVG paths are all possible.
- Circular text is not native, since Svg has no textPath; it would need 39 absolutely positioned, rotated Text glyphs. Best to treat the stamp as the logo slot.
- The simplest faithful route for the band is one static SVG of 'invoice' plus the flourish. Convert the text to paths at implementation time (e.g. with mutool or Inkscape). viewBox about x 50-160 / y 138-776, drawn with react-pdf <Svg><Path/>.
## Mapping to Voiced fields
- **Decorative 'invoice' script:** static art, never data.
- Always add a real-text 'INVOICE' or 'TAX INVOICE' label so the document is searchable and compliant.
- Place it above the meta block: Open Sans Bold 10pt, letterSpacing 1pt, right-aligned at 524.7, baseline about 300.
- **Stamp slot** (about 90x90pt, centre 485.5/172.7): the vendor logo Image, objectFit contain. A circular vendor-name ring around it (per-glyph rotated Text) is a nice-to-have only.
- **Vendor name, address, phone, email, GSTIN, PAN: no slot.** Put a FROM block in the space between the stamp and ISSUED TO (y about 225-305).
- Right-aligned at 524.7 under the stamp, maxWidth about 250pt; or left at 171.8 if the address is long.
- Open Sans Bold name plus Medium lines.
- **ISSUED TO:** clientName, clientAddress, GSTIN (optional), Place of Supply.
- **Meta:** INVOICE NO / DATE / DUE DATE, plus an optional PO NO.
- **Table:** description; UNIT PRICE (money(rate) plus '/hr', '—' for fixed lines); QTY; TOTAL.
- **Totals:** SUBTOTAL, DISCOUNT, CGST+SGST or IGST rows with amounts, TOTAL.
- **BANK DETAILS:** bank name, Account Name, Account No., IFSC, Branch, UPI.
- **THANK YOU and drawn signature:** keep 'THANK YOU' (or 'FOR {VENDOR}'). Put the signature image under it (box about 100x40pt, right-aligned at 532); otherwise the computer-generated note at 8pt.
- **Fields with no slot:**
- Amount in words: left part of the totals block beside Tax/TOTAL (x 171.8, at most 200pt wide), or full width under TOTAL.
- Notes: full width between the totals and the bottom row.
- HSN/SAC: an 8pt sub-line under the description.
- Footer and page numbers: fixed, 7pt, at y about 818, left edge 171.8.
## Overflow risks
- **Narrowest of the three:** 352.9pt of text width, so the description column is only about 150pt (about 25 characters per line at Open Sans 10pt with 1pt tracking). Most real descriptions will wrap to 2-3 lines. Consider letterSpacing 0.5 for the body in this template.
- **ISSUED TO column:** about 200pt (171.8 to the meta block at about 379), so long client names and addresses wrap heavily.
- **Meta block:** only 146pt (379-524.7). Long invoice numbers need it to grow leftwards; cap the client column with flex:1 and a 20pt gap.
- **Totals:** only 54pt between the labels (465.9) and the money edge (519.9). Use a 90pt value column.
- **Stamp slot:** square, about 90pt, so wide wordmark logos come out tiny. Offer a 140x60 landscape box variant.
- **Bottom row:** BANK DETAILS grows to 6 lines (+40pt) next to the signature box. With many items, the bottom row (about 90pt) and the totals move to page 2.
- **Decorative band:** 630pt tall. On continuation pages it should repeat (fixed); either way the content must keep its 162.9pt left offset.
- **Hyphenation:** same as the other templates.
## US Letter adaptation
- **Width (+16.5pt):** keep the band anchored to the page's left edge (x about 55-155). The content column becomes [max(marginLeft, 162.9) … pageWidth - marginRight (about 64)], i.e. 162.9-548 on Letter (385pt, 16.5pt wider).
- **Height (-50pt):** the band (y 142-772) would end only 20pt from the Letter bottom edge. Shift it up about 32pt (to y 110-740) or scale it by 0.94. Move the stamp up to top about 96, and cut the stamp-to-ISSUED TO gap from about 95 to about 70.
- **Texture:** a background colour, or an image with objectFit 'cover', adapts automatically.
- **Configurable margins:** the band is decoration anchored to the page edge, outside the margin system. Even so, the content column's left padding must stay at least the band's right edge + 8pt (about 163), whatever the user's left margin. Right, top and bottom margins apply normally.
## react-pdf notes
- **Page style:** {backgroundColor:'#ECE8E1', paddingLeft:171.8, paddingRight:70.8, paddingTop:≈128, paddingBottom:≈60, fontFamily:['Open Sans','IBM Plex Sans'], fontSize:10, letterSpacing:1, lineHeight:1.35, color:'#3D3B3A'}. Rules use marginLeft -8.9 and marginRight -6.7 to span 162.9-531.4.
- **Optional texture:** <Image fixed src={paperTexture} style={{position:'absolute', top:0, left:0, width:'100%', height:'100%', objectFit:'cover'}}/> as the FIRST child. Absolute children are placed against the page box, ignoring padding.
- **Band:** <View fixed style={{position:'absolute', left:50, top:138, width:110, height:640}}><Svg viewBox='…'><Path d=…/></Svg></View>.
- If you reset the word in a font instead, use Text {position:'absolute', fontFamily:<OFL script>, fontSize:≈280, transform:'rotate(-90deg)', transformOrigin:'left top'}. Place it so its baseline lies on x about 150, starting at y about 772. react-pdf's rotate() is positive clockwise, so -90 reads bottom to top. Tune visually, and draw the flourish as an Svg Path.
- **Stamp → logo:** View {position:'absolute', top:128, right:64, width:90, height:90} with an Image, objectFit contain. Alternatively keep it in flow at the top of the column (alignSelf flex-end).
- **Everything else:** Monogram's components with the narrower column.
- **Bottom row:** View {flexDirection:'row', justifyContent:'space-between', alignItems:'flex-start'}, wrap={false}.
- Left: bank block, with paddingLeft 10.9 to match the design's 182.7 inset, or 0 to align with the column.
- Right: a column {alignItems:'flex-end'} holding 'THANK YOU' and the signature Image (about 100x40).
- **Print-friendly toggle:** drop the background colour and texture, since beige floods toner.
## Similarity to other templates
- **Same reference design** (ID, page 6).
- **Typography identical to 5.pdf:** Open Sans 10pt, 1pt tracking, Bold labels, Medium address lines. The table and totals spacing also matches: header to rule 12.5, rule to first row 23.85, last row to rule 16.9, rule to SUBTOTAL 19.1.
- **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.
- **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 the reference design'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.** the reference design'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 the reference design'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 the reference design's. The swash is our own drawing (a tapered spine), not the reference design'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.** the reference design'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. the reference design'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.
+147
View File
@@ -0,0 +1,147 @@
# Template 7: Crimson Grid (`crimson-grid`)
Source: `templates/reference/7.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Bold corporate boxed grid. A huge black Poppins INVOICE sits top-left, with the logo and a 2-line name top-right. Below: a 1.5pt crimson rule, a table fully ruled in 0.75pt black with a crimson header row, a ruled totals box, and a full-bleed crimson footer band carrying a cream contact line.
## Fonts
One family: Poppins, static weights 400/600/700. Metrics per 1000 units: ascent 1050, descent -350, cap ~700.
- Poppins Bold 700: title 'INVOICE' 43.8pt #000000, tracking +0.37pt (~0.0085em). Total-row label and value 12pt.
- Poppins SemiBold 600: brand lockup 'STUDIO'/'SHODWE' 15.44pt uppercase, baselines 80.75 and 95.22 (pitch 14.47, i.e. lineHeight 0.94). Section headings 12pt uppercase (vendor name, 'BILL TO', 'NOTES / TERMS:', 'PAYMENT METHOD', 'PREPARED BY'). Table header labels 11pt #FFFFFF.
- Poppins Regular 400: body 12pt (meta, party lines, notes, bank lines, prepared-by). Table cells and totals 11pt. Footer contact line 12.44pt #EFEDDF, tracking +0.8pt (~0.064em).
- Line pitch: 12pt body lines 16.5pt (lineHeight 1.375). Meta lines 18.68pt. Heading baseline to first body baseline: 23.98 (party and payment blocks), 20.8 (notes).
- Any other measured tracking (~0.1pt) is rounding in the reference design's per-glyph placement. Use letterSpacing 0.
- The embedded subsets have no U+20B9 (₹). Check that the full Poppins TTFs include it before bundling.
## Colours
Exact fill operators:
- #A00000 (rg 0.6275 0 0), crimson: header rule, table header row fill, footer band.
- #000000: title, all body text, table and totals grid lines.
- #FFFFFF: page background, table header labels, separators between header cells.
- #EFEDDF (0.9373 0.9294 0.8745), cream: footer contact text.
- #ED1C24 (0.9294 0.1098 0.1412): placeholder flame logo only. Drop it; the vendor logo replaces it.
No transparency: every ExtGState has ca/CA 1. The ca=0 rectangles are invisible accessibility tag boxes.
## Layout
A4 595.5x842.25pt, y measured from the page top. Side margins 59.55 (= 10% of width) and 58.8. Content x 59.55-536.70 (477.15 wide).
| Region | Geometry (pt) |
|---|---|
| Title INVOICE | x 59.55, baseline 100.58 (cap top ~69.7), text box ~186 wide |
| Logo mark | x 420.0-452.4, y 53.0-98.7 (32x46) |
| Brand name | x 466.1 (13.7 right of logo), baselines 80.75/95.22, right edge <= 536.7 |
| Header rule | y 128.85, x 59.55-536.70, 1.5pt #A00000 |
| Meta | x 59.55, baselines 173.58 'Invoice Number: #...' and 192.26 'Invoice Date: ...' |
| Party row | 2 columns: vendor at x 59.55, BILL TO at x 304.31 (51.3% split). Heading baseline 231.07; lines 255.05/271.55/288.06/304.56 |
| Items table | y 335.84-497.90 (40.5 header + 3 rows of 40.52) |
| Notes (left) | heading baseline 523.33, body 544.12/560.62, wraps at ~182 wide |
| Totals box (right) | x 353.59-536.70, y 509.21-632.26 |
| Payment (left, x 59.55) and Prepared by (right, x 322.44, misaligned with 304.31; normalise) | heading baselines 686.95; lines 711.72/728.23/744.74 |
| Footer band | full bleed, y 779.26-842.25 (63 tall), text centred, baseline 815.88 |
Vertical gaps: rule to meta cap top ~36. Last meta baseline to party heading cap top ~30. Last party baseline to table 31.3. Table to totals box 11.3. Totals bottom to payment cap top ~46. Last payment baseline to band 34.5. In the title/brand row the bottoms roughly align (title baseline 100.6, logo bottom 98.7), so use alignItems flex-end.
## Items table
4 columns, fully ruled. Grid-line centres at x 59.93 | 308.96 | 406.84 | 447.86 | 536.33, giving widths 249.0 / 97.9 / 41.0 / 88.5 (52.3% / 20.5% / 8.6% / 18.6%).
- Header row: 40.5 tall, fill #A00000. Poppins SemiBold 11 #FFFFFF. 'Item & Description' left-aligned; 'Unit Price', 'Qty', 'Amount' right-aligned. 0.75pt #FFFFFF separators between header cells. Reference artefact: cells 1 and 3 have black top/bottom edges while cells 2 and 4 have white ones. Normalise to a black 0.75 outer box with white inner verticals.
- Body rows: 40.52 tall, white, 0.75pt #000000 on every cell edge (outer box, verticals, row lines). Poppins Regular 11 #000. Description left-aligned; unit price, qty and amount right-aligned. Horizontal padding 9.4 on both sides.
- Vertical: baseline is 23.64 below the row top. In react-pdf (natural line 15.4 at 11pt) that is paddingTop 12.1 and paddingBottom 13.0, or minHeight 40.5 with alignItems center.
- No zebra striping. Keep numbers right-aligned. The Qty column is only 41 wide (~4 digits), so put the unit with the rate (e.g. '/hr').
- Recommendation: make each money column at least 95-100pt for en-IN amounts (see overflow_risks).
## Totals and payment
- Totals box, right side: x 353.59-536.70 (183.1 wide = 38.4% of content). 2 columns: label 93.45 | value 88.9. The value-column edge at 447.41 lines up with the table's Qty|Amount line at 447.86. Rows: 'Sub-Total' 40.52, 'Tax (6%)' 40.52, 'Total' 42.02. Full 0.75pt black grid. Labels Regular 11 left-aligned (padding 9.4), values right-aligned. Total row is Poppins Bold 12 in both cells.
- Notes/Terms sit left of the totals box, top-aligned with it (heading baseline 523.33; box top 509.21).
- PAYMENT METHOD: SemiBold 12 heading, then Regular 12 lines 'Bank:', 'Account Name:', 'Account Number:'.
- PREPARED BY: heading, then a name line and a 'title, company' line. No signature graphic.
- Footer band: one centred line 'email | website | phone'.
- Missing from the template: amount in words, signature, thank-you line, due date, page number.
## Decoration and images
No raster XObjects. Everything is vector shapes plus live text.
- Logo: flame made of 3 bezier subpaths, #ED1C24, 32x46. Replace with a vendor logo Image.
- Header rule: 1.5pt #A00000 line.
- Table header: 4 #A00000 rectangles (one per cell). Grid strokes 0.75pt.
- Footer band: #A00000 rectangle drawn with bleed (x -30..626, y 779.3..872). Implement as a View, absolute, left 0, right 0, bottom 0, height 63.
- 3 invisible (ca=0) rectangles tagged 'Image' over the logo, rule and band are accessibility tags. Ignore them.
Everything can be built with View backgroundColor and borders. Svg is only needed for an optional vector logo.
## Mapping to Voiced fields
- Logo slot: vendor logo (Image maxHeight 46, maxWidth ~110, objectFit contain). If the logo is a wordmark (e.g. public/brand/sample-wordmark-logo.png), hide the text lockup.
- Brand lockup: vendorName (SemiBold 15.4 uppercase, wraps, right-aligned).
- 'INVOICE': show 'TAX INVOICE' when taxType != none, else 'INVOICE'.
- Meta lines: 'Invoice No: {number}', 'Invoice Date: {date}', NEW 'Due Date: {dueDate}' (3rd line, same 18.68 pitch), optional 'PO No: {poNumber}'.
- Left party block (heading = vendor name): vendorAddress (multi-line), vendorPhone, vendorEmail, NEW 'PAN: {vendorPan}', NEW 'GSTIN: {vendorGstin}'.
- BILL TO: clientName, clientAddress, 'GSTIN: {clientGstin}' (optional), NEW 'Place of Supply: {state} ({code})'.
- Table: Item & Description = description. Unit Price = rate with unit (e.g. '₹1,500.00 / hr'); em dash for fixed-mode lines. Qty = quantity (1 for fixed mode). Amount = line amount.
- HSN/SAC (no slot): grey 9pt sub-line under the description, or an optional 5th column (~55pt) taken from the description width.
- Totals box: Sub-Total, Discount (if > 0), then CGST (r/2 %) and SGST (r/2 %) or IGST (r %), then Total (bold). This makes 4-6 rows.
- Amount in words (no slot): full-width line under the notes/totals row, SemiBold 11 'Amount in words:' followed by Regular 11 text. Indian-system strings run 60-110 chars, so 1-2 lines.
- Notes/Terms: notes, with a default 'Payment due within {paymentTermsDays} days'.
- PAYMENT METHOD: bank fields Bank, Account Name, Account No, IFSC, Branch, UPI (planned). Up to 6 lines.
- PREPARED BY: rename to 'AUTHORISED SIGNATORY'. Show the signature Image (maxHeight ~36) and vendorName, or Regular 9 fine print 'Computer-generated invoice; no signature required.'
- Footer band: 'vendorEmail | vendorPhone', optionally ' | GSTIN ...'. Voiced has no website field. A page number 'n / N' can sit at the band's right end.
## Overflow risks
- Header (same class as the reported INVOICE overlap bug): the brand area is only 420-536.7 (116pt). A long vendorName wraps to 3+ lines and pushes into the rule at 128.85. 'TAX INVOICE' at 43.8pt is ~285pt wide (59.6 to ~345), leaving ~75pt before the logo. Use a flex row, never absolute positioning: title flexShrink 0; brand View flex 1, maxWidth ~40%, alignItems flex-end; rule below in normal flow.
- Party blocks: the template ends at baseline 304.56 (4 lines) and the table starts at 335.84. A Voiced vendor block (address 2-3 lines, phone, email, PAN, GSTIN = 6-8 lines) adds 33-66pt, so the table must flow, not sit at a fixed y.
- BILL TO column is 232pt (~36 chars per line at 12pt). Long addresses wrap, which is fine in flow.
- Items: with 40.5pt rows only ~6 fit before the totals on page 1. The 249pt description column holds ~40 chars per line at 11pt, so long text grows the row. More than 8 rows go to page 2, which needs the header row repeated.
- Money: the Amount column's inner width is 69.7pt, and '₹10,00,000.00' at 11pt Poppins is ~72pt, so it overflows from 10 lakh up. Unit Price (79pt inner) is fine up to ~99 lakh. Widen Amount to ~100pt (taking from description) or set numbers at 10pt.
- Totals box: with Discount, CGST and SGST it has 5-6 rows x 40.5 = 200-245pt, which reaches the PAYMENT block (686.95) and the band (779) on A4. Use compact rows (~26pt) and flow.
- Payment block: 6 bank lines x 16.5 = 99pt from 711.7 ends at ~810, past the band top at 779. Let it flow to page 2 or tighten it. Page paddingBottom must be >= 63 + 16.
- Footer line: about 60 chars fit in 477pt at 12.44pt with 0.8 tracking. Email + phone + GSTIN can exceed that: use fontSize 10, or maxLines 1 with ellipsis, or 2 lines (the 63pt band fits 2).
## US Letter adaptation
- US Letter 612x792 is 16.5pt wider and 50.25pt shorter. Keep side margin M (default 59.55 = 10% of A4 width, or the user's setting). Content width = 612 - 2M (492.9 at the default). Column percentages from items_table scale with it. Keep the totals box at 183pt (or 38.4%), right-aligned.
- Footer band stays fixed at 63pt at the bottom (top at 729 on Letter). To recover ~50pt: rule-to-meta gap 36 -> 26; meta-to-party gap 30 -> 22; table rows 40.5 -> 32 (header 34); totals rows 40.5 -> 30; totals-to-payment gap 46 -> 28.
- Configurable margins: top margin = logo top (53 by default); title baseline = top + 47.6. The band ignores side margins (full bleed) but its text uses M. Page paddingBottom = 63 + bottom gap (16-24).
- the reference design's 595.5x842.25 page differs from react-pdf A4 (595.28x841.89) by under 0.05%; ignore it.
## react-pdf notes
- Font.register Poppins 400/600/700 from bundled .ttf files. react-pdf takes TTF/WOFF only, not WOFF2 or variable fonts.
- <Page size={'A4'|'LETTER'} style={{fontFamily:'Poppins', paddingTop:53, paddingBottom:83, paddingHorizontal:M}}>.
- Footer band: <View fixed style={{position:'absolute', left:0, right:0, bottom:0, height:63, backgroundColor:'#A00000', justifyContent:'center', paddingHorizontal:M}}> containing <Text style={{color:'#EFEDDF', fontSize:11, letterSpacing:0.8, textAlign:'center'}}>. Absolute offsets are relative to the page edge; the existing InvoiceDocument footer already relies on this. This is the template's only absolute element.
- Header: View row, justifyContent space-between, alignItems flex-end. Title Text (fontSize 43.8, fontWeight 700, flexShrink 0) plus a brand View (row, alignItems center, flexShrink 1, maxWidth '40%') holding Image and Text.
- Rule: View with borderBottomWidth 1.5, borderBottomColor '#A00000', marginTop ~28.
- Party and payment rows: View row with two Views at width '51%' and '49%' (or flex 1 each with gap 16).
- Table: outer View {borderLeftWidth:.75, borderTopWidth:.75, borderColor:'#000'}. Header row <View fixed style={{flexDirection:'row', backgroundColor:'#A00000'}}>. In @react-pdf/layout 5.2 splitNodes copies a fixed child into the table's continuation fragment (checked in source), so the header repeats on following pages; confirm in a render. Body rows <View wrap={false} style={{flexDirection:'row', borderBottomWidth:.75}}>; cells padding 12/9.4 with borderRightWidth .75 (white in the header).
- Bottom row: left View flex 1 (notes, amount in words); right totals View width 183, wrap={false}, same cell borders.
- Payment/Prepared-by row: wrap={false} plus minPresenceAhead so it never orphans.
- Baselines: react-pdf's renderLine draws the baseline at line top + ascent, with no half-leading. So Text top = reference baseline - 1.05 x size for Poppins.
- Emails and GSTINs: Font.registerHyphenationCallback(w => [w]) stops '-' being inserted. Tokens wider than the column still need a character-split fallback.
## Similarity to other templates
- Near-duplicate: 11.pdf ('Rimberio'). Same structure and placeholder copy: Invoice Number/Date, vendor and BILL TO columns, 4-column ruled grid with a coloured header row, NOTES/TERMS beside a ruled totals box, PAYMENT METHOD and PREPARED BY. Differences: orange accent, Inter body with a Poppins Bold title, mirrored header (name left, INVOICE right), filled Total row, corner shapes instead of the rule and footer band. Build 7 and 11 as one ruled-grid component with theme props (accent, fontFamily, headerMirror, totalRowFill, decoration set).
- Weaker resemblance: 2.pdf (ruled grid with a yellow header and a ruled total cell).
- None of 7/8/9 resembles the in-app InvoiceDocument.tsx (IBM Plex, hairline rules, title-left/meta-right). The closest in the set is 3.pdf. Not similar to format.pdf (identical to 4.pdf).
## Implementation notes
Built as `src/pdf/templates/ledger/` (one family component, `Layout.tsx`, shared with Tangerine Ledger; tokens in `crimson.tsx`, planning in `plan.ts`) on the decor primitives in `src/pdf/decor/`. Measured against `7.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-crimson-grid.png`, left reference, right ours).
**Matches the design (measured, A4):** title baseline 100.6 pt at x 59.5, 43.8 pt Poppins Bold; the 1.5 pt #A00000 rule at y 128.85; meta baselines 173.6 / 192.3; party heading to first line 23.9 pt; BILL TO at x 304.3; table text at x 69.7, header row 42 pt (reference 40.5 plus the 0.75 pt rules) and 40.5 pt single-line rows; header fill #A00000 with white inner verticals and a black outer box; the 63 pt full-bleed band; all colours exact.
**Intentional deviations, and why:**
- **Margins.** Top 53 and sides 59.55 as designed. The bottom margin floor is 26 pt plus a 53 pt footer reserve, so the flow is at least 79 pt (band 63 + 16) above the page bottom on every margin preset and on Letter. The footer text is placed by `chrome.bottom` (14 pt above the edge), inside the band whatever the preset.
- **Header.** Title and brand block are one row without `alignItems: flex-end` (lint bans it): the brand block is placed from the measured slot offsets. 'TAX INVOICE' (about 281 pt) shrinks to at most 60 % of its size before the brand block (at most 40 % of the width) is squeezed. A wordmark logo replaces the whole flame + name lockup; a mark logo sits left of the vendor name in 15.44 pt caps (13.7 pt gap); without a logo only the name shows.
- **Meta** adds 'Due Date:' and 'PO No:' lines (same 18.7 pt pitch); 'Invoice Number:' keeps the design's wording.
- **Parties.** The vendor block's heading is the vendor name in caps, followed by address, phone, e-mail, PAN and the GSTIN (slot). the reference design's PREPARED BY (x 322.4, misaligned) is normalised to the BILL TO column (x 304.3) and renamed AUTHORISED SIGNATORY.
- **Table.** The grid is drawn as per-cell borders (`TableRows` `grid`), not lines over the text, so it survives page breaks: the header repeats with its rules and every row owns its bottom rule. An HSN/SAC column (at least 56 pt) appears on tax invoices. Unit Price shows '₹1,200.00/hr' on one line when it fits, else the amount over 'per hour' at 8.5 pt; the money columns are at least 100 / 45 / 100 pt and grow to their widest amount. Rows grow with wrapped descriptions (padY 12.55).
- **Totals box** is widened (about 210 pt: label at least 96, value at least 100) with 28 pt rows instead of 40.5, so Sub-Total, Discount, Taxable value, CGST/SGST (or UTGST, IGST via `Slot.TaxRows`) and Total fit. The Total row is bold 12 pt without a fill, as designed.
- **Notes / Terms** sit left of the totals box when they are at most 110 pt tall; longer notes flow below the table (a long note must never make the unbreakable totals block unplaceable). Amount in words and the reverse-charge line run full width under the box.
- **Closing.** Bank lines are 10.5 / 14.5 pt (not 12 / 16.5) so six lines fit; the signature is the user's image or the computer-generated line with the signatory name.
- **Footer band.** The contact line is 'email | phone' (no website field exists; the GSTIN is printed through its slot only) at 11 pt with 0.05 em tracking instead of 12.44 pt / 0.064 em, and is fitted to one line. The standard footer (supplier name, 'Generated with Voiced', Page n of N) is added inside the band in cream at 7 pt; the continuation band on pages 2+ is grey so it stays readable on white.
- **Letter.** The 49.9 pt deficit comes out of the rule gap (21.5 to 12), meta (31.4 to 20), party (22.3 to 14), table (27.4 to 16), totals (11.3 to 6) and notes (12 to 8) gaps, 50.4 pt in all; type and row padding are not scaled. The band stays 63 pt at the bottom edge.
- **Grid colour.** The reference artefact (black top/bottom edges on cells 1 and 3 only) is normalised to a black outer box with white inner verticals in the header, as the doc recommends.
+159
View File
@@ -0,0 +1,159 @@
# Template 8: Cobalt Stripe (`cobalt-stripe`)
Source: `templates/reference/8.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Friendly cobalt-blue business style. Swoosh logo and bold name on the left, a large cobalt INVOICE on the right, and a grey rule with sky-blue accent ends. A cobalt header bar sits over light-blue zebra rows. Cobalt label bars mark PAYMENT METHOD and GRAND TOTAL. Script signature; footer of icon contacts.
## Fonts
Families: Open Sans 400/700 for body text; Poppins 400/700 for name, title and footer; BD Script for the placeholder signature. Metrics per 1000 units: Open Sans ascent 1068.85, descent -292.97, cap 714; Poppins 1050/-350.
- Poppins Bold: vendor name 17.38pt #1B1B1B, tracking -0.49pt (-0.028em). 'INVOICE' 31.45pt #3871C1, tracking +1.35pt (+0.043em).
- Poppins Regular: footer contact text 10pt #1B1B1B.
- Open Sans Regular: tagline 10pt uppercase #1B1B1B; website text beside the header rule 10pt uppercase #000000; 'Invoice to :' 11pt; client contact lines 10pt #595A5A; date 12pt; table body 10pt; 'Sub Total :'/'Tax 15% :' and their values 11pt; bank lines 11pt; terms body 10pt #595A5A.
- Open Sans Bold: client name 15pt; 'Invoice no : 12345' 12pt; table header 11pt #FFFFFF uppercase; bar labels and values 11pt #FFFFFF; 'Thank you for business with us!' 11pt; 'Term and Conditions :' 11pt; signer name 13pt; signer title 11pt.
- BD Script Regular: signature 25pt #1B1B1B. Licensed by reference, so do not bundle it; this slot becomes the signature image.
- Pitches: name to tagline baseline 15.66. Client contact lines 10pt at ~19pt (lineHeight ~1.9, airy). Right-hand meta 19.2. Bank lines 11pt at 15.76 (1.43). Terms 10pt at 14.25 (1.425). Table rows 17.82. Totals rows 20.76.
- All other tracking is ~0.
## Colours
Exact fill operators:
- #3871C1 (0.2196 0.4431 0.7569), cobalt: INVOICE, table header bar, PAYMENT METHOD bar, GRAND TOTAL bar, middle logo swoosh.
- #BEDEF1 (0.7451 0.8706 0.9451), light blue: even zebra rows and the 1.5pt column separators.
- #51ADE5 (0.3176 0.6784 0.898), sky: 3pt rule accents, footer icons, one logo swoosh.
- #2C3892 (0.1725 0.2196 0.5725), navy: logo swoosh only.
- #1B1B1B (0.1059 grey), near-black: primary text.
- #595A5A (0.349 0.3529 0.3529): secondary text (client contact lines, terms body).
- #A6A6A6 (0.651 grey): 1.5pt grey rules.
- #000000: website text beside the header rule.
- #FFFFFF: page background, odd rows, text on bars.
No transparency.
## Layout
A4, y measured from the top. Content x 59.55-535.95 (476.4 wide).
| Region | Geometry (pt) |
|---|---|
| Logo | x 59.5-93.8, y 59.5-93.7 (34x34) |
| Vendor name | x 102.25 (8.5 right of logo), baseline 74.56 |
| Tagline | x 102.25, baseline 90.22 |
| INVOICE | right-aligned to 535.94, baseline 87.26 (starts at x 395.65; cap top ~65.1) |
| Header rule | y 113.77: grey 1.5pt round-cap line x 60.3-414.9, with a sky 3pt accent x 61.0-96.7 over its left end |
| Rule text | right-aligned to 535.94, baseline 117.07 (vertically centred on the rule), starts at 425.85 (11pt after the rule ends) |
| Bill-to (left) | 'Invoice to :' baseline 173.83; name 195.91; lines 219.92/239.49/257.94 |
| Meta (right) | 'Invoice no : ...' baseline 175.18; date 194.41; right edge 535.94 |
| Table | header bar 292.18-312.95; rows 312.95-455.54 (8 x 17.82) |
| Sub/Tax | baselines 477.03/497.79; labels' right edge 417.36; values' right edge 532.82 |
| Bars | y 514.00-534.76: PAYMENT METHOD x 59.46-193.28; GRAND TOTAL x 321.46-535.98 |
| Bank lines | x 60.95, baselines 561.79/577.55 |
| Divider | y ~600.5, x 60.2-236.8, grey 1.5 round caps (drawn with a 0.75pt slope; treat as horizontal) |
| Thank-you | x 60.95, baseline 624.52 |
| Terms | heading baseline 666.91; body 684.63/698.88/713.14/727.39; box ~185 wide |
| Signature (right) | script baseline 688.52; name 710.57; title 728.14; right edge 530.45 |
| Footer rule | y 747.02: grey 1.5 x 74.9-520.6; sky 3pt accents x 61.05-96.75 and 498.75-534.45 |
| Footer contacts | icons y ~760-783 at x 59.55 (phone 21x21), 186.57 (mail 21x16), 376.15 (pin 18x23); text baseline 774.49 at x 85.17/212.41/398.09 |
Whitespace below the footer is ~59.6, equal to the side margin. Gaps: header rule to 'Invoice to' cap top 52.2; last client line to table 34.2.
## Items table
5 columns across the full content width 59.55-535.95. Boundaries at x 59.55 | 84.42 | 256.25 | 341.19 | 446.62 | 535.95, giving NO 24.9 (5.2%), DESCRIPTION 171.8 (36.1%), QTY 84.9 (17.8%), PRICE 105.4 (22.1%), TOTAL 89.3 (18.8%).
- Header bar: #3871C1, 20.76 tall, no border. Open Sans Bold 11 #FFFFFF uppercase. NO, DESCRIPTION, QTY and TOTAL centred; PRICE right-aligned to ~415 so it lines up with the price values.
- Body rows: 17.82 tall, single line. Zebra: odd rows #FFFFFF, even rows #BEDEF1. No horizontal rules and no outer border. Vertical separators 1.5pt #BEDEF1 at the 4 inner boundaries (visible only on white rows).
- Body text Open Sans Regular 10 #1B1B1B. NO centred. Description left-aligned at 91.23 (6.8 inset). QTY centred (~298.5). PRICE right-aligned at 414.9, which is 31.7 short of the column edge. TOTAL right-aligned at 532.82 (3.1 inset).
- Vertical: baseline is 12.29 below the row top. The react-pdf natural line is 13.6 at 10pt, so paddingTop ~1.6 and paddingBottom ~2.6. For wrapping text use paddingVertical 3 with minHeight 17.82.
- Recommendation: right-align PRICE and TOTAL with an even 6-8pt inset. Set the zebra colour as a row backgroundColor by index. Draw separators as cell borderRight, not absolute lines, so they grow with wrapped rows.
## Totals and payment
- Sub-totals (right side, no box): 'Sub Total :' and 'Tax 15% :' in Open Sans 11 #1B1B1B. Labels' right edge 417.36, values' right edge 532.82, pitch 20.76.
- GRAND TOTAL bar: #3871C1, x 321.46-535.98 (214.5 wide = 45% of content), 20.76 tall. Label Open Sans Bold 11 white, right-aligned to 417.36 (starts at 333.33). Value bold white, right-aligned at 532.82. Reference sets the value baseline 1.2pt higher; use one baseline.
- PAYMENT METHOD bar: #3871C1, x 59.46-193.28 (133.8 wide), same row as GRAND TOTAL. Bold white label at x 60.95 (only 1.5 inset; use ~6). Bank lines below: 'Bank Name : ...' and 'Account Number : ...' in Open Sans 11.
- Grey divider (176.6 long), then 'Thank you for business with us!' bold 11.
- 'Term and Conditions :' bold 11, then the grey 10pt body.
- Signature block right-aligned at 530.45: BD Script 25 signature, Open Sans Bold 13 name, Bold 11 title.
- Footer: rule with sky accent ends, then 3 icon-and-text contact items.
- Missing from the template: amount in words, due date, GST/PAN, page number.
## Decoration and images
No raster XObjects. Everything is vector shapes plus live text.
- Placeholder logo: 3 bezier swooshes (#2C3892, #3871C1, #51ADE5) plus 2 tiny #1B1B1B specks, 34x34. Replace with a vendor logo Image.
- Rules: grey 1.5pt round-cap lines with sky 3pt round-cap accent segments 35.7 long, at the left end (header) and at both ends (footer).
- Bars: plain cobalt rectangles (table header, PAYMENT METHOD, GRAND TOTAL).
- Zebra rectangles and 1.5pt light-blue vertical separators.
- Icons: outline-style phone (with signal arcs), envelope and map pin, #51ADE5, 16-23pt. Render with react-pdf <Svg><Path/></Svg>. Copy the path data of Carbon 'Phone', 'Email' and 'Location' (32px viewBox) from @carbon/icons-react, which is already a dependency, and draw them at 14-16pt.
- Many invisible (ca=0) rectangles tagged 'Image' are accessibility tags. Ignore them.
## Mapping to Voiced fields
- Logo: vendor logo (height <= 34-40, width <= 120, contain).
- Vendor name: vendorName (Poppins Bold 17.4, tracking -0.5).
- Tagline 'CREATIVE AGENCY': 'GSTIN {vendorGstin}' in Open Sans 10 uppercase.
- 'INVOICE': 'TAX INVOICE' or 'INVOICE'.
- Rule text 'REALLYGREATSITE.COM': 'PAN {vendorPan}' or vendorEmail in uppercase (Voiced has no website field).
- 'Invoice to :' becomes 'Billed to :'. Name: clientName (bold 15). Grey lines: clientAddress (wrapping), 'GSTIN {clientGstin}', 'Place of supply: {state} ({code})'.
- Right-hand meta: 'Invoice no : {number}' and invoiceDate, NEW 'Due date : {dueDate}' (12pt, 19.2 pitch), optional 'PO : {poNumber}'.
- Table: NO = row index; DESCRIPTION = description; QTY = quantity (with unit in rate mode); PRICE = rate (e.g. '/hr'); TOTAL = amount. HSN/SAC (no slot): split the 84.9-wide QTY column into HSN/SAC ~50 + QTY ~35, or use a grey sub-line under the description.
- Sub/Tax rows: Subtotal, Discount, then CGST x% and SGST x%, or IGST x%.
- GRAND TOTAL bar: total.
- Amount in words (no slot): 1-2 right-aligned lines directly under the GRAND TOTAL bar, Open Sans 9.5 #595A5A, 214.5 wide.
- PAYMENT METHOD bar and lines: bank fields Bank Name, Account Name, Account No, IFSC, Branch, UPI.
- Thank-you line: keep it static, or make it configurable.
- Terms: notes, plus 'Payment due within {paymentTermsDays} days'.
- Signature: the script text becomes the signature Image (height <= 36, right-aligned). Name = vendorName. Title = 'Authorised Signatory'. With no signature, show 'Computer-generated invoice; no signature required.' in Open Sans 9 #595A5A, right-aligned.
- Footer: phone = vendorPhone; email = vendorEmail; address = vendorAddress (short form, or 2 lines).
## Overflow risks
- Header (same class as the reported overlap bug): the name starts at 102.25 and the right-aligned INVOICE starts at 395.65, leaving ~290pt (~20 chars at 17.4pt). 'TAX INVOICE' is ~230pt wide and starts at ~306, leaving ~200pt (~14 chars). Use a flex row: brand View flex 1 with minWidth 0 so it wraps, title flexShrink 0, gap >= 16.
- The header rule has a fixed length of 354.6. Rule text wider than ~110pt (e.g. 'GSTIN 19ABCDE1234F1Z5' is ~115pt) collides. Make the rule a flex 1 View and the text flexShrink 0 with marginLeft 11.
- The bill-to and meta columns have no explicit widths; use 55% left and 45% right. Long client addresses wrap at 10pt, and the ~19pt pitch uses height quickly, so use a 14-15 pitch for addresses.
- Table rows are drawn single-line at 17.82. Wrapped descriptions make rows taller, so zebra colours and separators must be per-row and per-cell styles.
- TOTAL column inner width is ~83pt. '₹12,34,567.00' at 10pt Open Sans is ~65pt and fits; amounts in crores (~85pt) are borderline. PRICE column is fine.
- On A4 the bottom area is laid out by fixed positions: bank 561-578, divider 600, thank-you 625, terms 667-727, signature 670-728, footer rule 747. Six bank lines (+63pt) or long notes push into the footer, so the content must flow above a fixed footer.
- The footer address slot is only ~138pt (398 to 536) at 10pt, ~27 chars. Indian addresses will overflow. Use a flex row with space-between, the address item flex 1 and maxLines 2.
- More than ~20 items spill to page 2, which must repeat the header bar.
## US Letter adaptation
- Letter: content width = 612 - 2M (492.9 at M = 59.55). The description column takes the extra 16.5 (flex). Bars keep their proportions (PAYMENT 28%, GRAND TOTAL 45%) or keep fixed widths anchored left/right.
- Height is 50.25 shorter. The footer (rule 95pt above the bottom, plus contacts) stays anchored to the bottom (rule at y 697 on Letter). To recover ~50pt: header rule to 'Invoice to' cap top 52 -> 36; client line pitch 19 -> 15; client block to table 34 -> 24; bank to divider 22 -> 14; thank-you to terms 42 -> 28.
- Configurable margins: top = logo top (59.5 by default, = M). The footer block's bottom offset = M. Rules span the content width; accent segments stay 35.7.
## react-pdf notes
- Register Poppins 400/700 and Open Sans 400/700 as static TTFs. Set the page fontFamily to 'Open Sans'.
- Page: paddingTop M, paddingBottom ~110 (room for the footer), paddingHorizontal M.
- Header: View row, alignItems center: [Image 34x34] [View marginLeft 8.5, flex 1, minWidth 0, containing the name Text and tagline Text] [Text INVOICE, flexShrink 0, textAlign right, letterSpacing 1.35, marginRight -1.35].
- Rule row: View row, alignItems center. Rule = View flex 1, height 1.5, bg #A6A6A6, borderRadius .75, containing an absolute accent child (left 0, top -0.75, width 35.7, height 3, bg #51ADE5, borderRadius 1.5). Absolute positioning is fine here because it is decoration inside a relative box. Then the Text with marginLeft 11.
- Table: header row <View fixed style={{flexDirection:'row', backgroundColor:'#3871C1', height:20.76, alignItems:'center'}}>. Body rows <View wrap={false} style={{flexDirection:'row', minHeight:17.82, alignItems:'center', backgroundColor: i%2 ? '#BEDEF1' : '#FFFFFF'}}>. Cells use % widths with borderRightWidth 1.5 and borderRightColor '#BEDEF1' (except the last cell).
- Totals: View alignSelf flex-end, width 214.5. Label/value rows use row + space-between. GRAND TOTAL bar is a View of the same width, bg #3871C1, paddingHorizontal 12/3.
- Bars and bottom area: use two columns that flow independently. Left column flex 1: PAYMENT bar (width 133.8), bank lines, divider, thank-you, terms. Right column width 214.5: GRAND TOTAL bar, amount in words, then the signature (alignItems flex-end, wrap={false}).
- Footer: <View fixed style={{position:'absolute', left:M, right:M, bottom:M}}> holding the rule (accents at both ends) and a row of 3 items (Svg icon 14pt + Poppins 10 Text).
- Baselines: Text top = baseline - 1.069 x size for Open Sans, baseline - 1.05 x size for Poppins.
- Right-aligned tracked text: textkit 7.0.1 also adds letterSpacing after the last glyph, so add marginRight equal to minus the letterSpacing.
## Similarity to other templates
- No near-duplicate in the set. It is the only template with cobalt bars, zebra rows and a swoosh logo. It shares the 'Studio Shodwe' placeholder copy with 7 and 11, but the layout is unrelated.
- Shared-component candidate: the icon contact footer (phone/email/address) also appears in 2, 9 (circled icons) and 12/13. The zebra rows loosely echo 1 (grey zebra) and 12/13 (pill rows).
- Does not resemble the in-app InvoiceDocument.tsx (closest in the set is 3.pdf), or format.pdf / 4.pdf.
## Implementation notes
Built as `src/pdf/templates/cobalt/`: a standalone template (`tokens.ts` the type roles, palette and reference distances, `plan.ts` the pure planner, `Layout.tsx` the body, `cobalt.tsx` the definition) on the shared blocks, the `CapRule` decor primitive (a grey round-capped rule with accent segments, one static Svg) and the Carbon icon paths. Measured against `8.pdf` with `npm run templates:compare -- cobalt-stripe` (`/tmp/invoice-c6a/compare-cobalt-stripe.png` and `-nologo.png`, left reference, right ours; `--numbers` prints the baselines).
**Matches the design (measured, A4, the 8-row sample without GST data):** vendor name baseline 74.5 (reference 74.6) at x 59.5; INVOICE origin x 395.4 and baseline 87.2 (395.6 / 87.3), tracked +0.043 em with its ink ending on the right edge; the rule text baseline 117.1 and the header rule centred on y 113.8 with its sky accent at the left; "Billed to :" 173.8, the client name 195.8 and the first contact line 219.8 (173.8 / 195.9 / 219.9); "Invoice no" and the date at 174.8 / 194.0 (175.2 / 194.4); the table bar 20.76 pt tall at y 292 with its heading baseline at 304.6 (304.7), the five columns at 24.9 / 84.9 / 105.4 / 89.3 pt and rows of 17.82 pt, the first row baseline 325.7 (325.2); the price ends 31.7 pt inside its column (x 414.7, reference 414.9) and the total 3.1 pt inside the edge; Sub Total at 477.0 with its label at x 363.5 (477.0 / 363.7); the GRAND TOTAL bar 214.5 x 20.76 beside the PAYMENT METHOD bar 133.8 wide, both cobalt; the bank lines 541.0 (reference 561.8 less its second tax row); every colour is one of the design's (a test scans the sources).
**Intentional deviations, and why:**
- **Script signature.** Not reproduced (BD Script is licensed by reference). The signature slot is the user's image (up to 36 pt tall, right-aligned, 5.5 pt inside the edge) or, without one, the signatory name and designation plus "Computer-generated invoice; no signature is required."
- **Header content.** The tagline under the name is the supplier's GSTIN (through `Slot.Gstin`, in caps) and the text at the end of the rule is the PAN. A logo goes in the measured slot: a mark (at most 34.25 pt square) keeps the name and tagline beside it; a wordmark (at most 181.5 x 34.25 pt, left-anchored) replaces the name and the tagline moves under it, which makes the header 13 pt taller. With no logo the name starts at the margin. The rule row follows the header, so it is never nearer than 6 pt to it.
- **Right-hand column.** "Invoice no : N" and the date as in the design, plus "Due date" and "PO" lines; a red CANCELLED line on a cancelled invoice. The client's GSTIN and place of supply follow the address in the grey 10 pt lines, whose pitch is 17.5 pt (reference 19.2) so longer blocks fit.
- **Table.** HSN/SAC is an 8.5 pt grey sub-line under the description (no slot in the design), which makes a row 11.5 pt taller; wrapped descriptions grow the row, and the zebra fill and the 1.5 pt light-blue separators are per cell, so they grow with it. The rate's unit is `/hr` or a second line. `TableRows` gained two additive options for this template: `innerRules` (separators between the columns only, no outer edge and no top rule) and `headAlign` (the heading centred over a left-aligned column).
- **Totals.** "Sub Total :", Discount, Taxable Value and the CGST / SGST / IGST rows (labelled "CGST 9% :") end at the label column's right edge (x 417.4 of the design); the amount in words and the reverse-charge line sit under the GRAND TOTAL bar, right-aligned in 9.5 pt grey. The PAYMENT METHOD bar is level with the GRAND TOTAL bar as in the design whenever the invoice still fits its page that way; with a longer stack (a GST invoice has up to five rows against the design's two) it moves up beside the first row, which gives back up to the stack's height (104 pt for five rows), and the bank lines follow it.
- **Closing.** The divider, "Thank you for business with us!" and the terms (the notes; taller than 110 pt they flow as a block of their own before the closing) at the left, the signature at the right, then the icon footer: the footer rule with accents at both ends, and phone, e-mail and address with Carbon icons (26 pt boxes, sky). The footer is part of the closing block, so it is on the last page only, not on every page, and everything in it is audited. The contacts keep their natural width at 32.5 pt apart; when they do not fit, the address wraps into what is left and the phone and e-mail shrink only if the address would get under 120 pt.
- **Elastic gaps** (Letter, and A4 when a one-page invoice overflows by less than the slack): header to client (42 to 26 pt), client to table (30.5 to 16), last row to the totals (9.8 to 4), thank-you to terms (26 to 14) and terms to the footer rule (16 to 8). Type is never scaled.
- **Closing moves alone.** When the items and totals fit page 1 only with the gaps squeezed and the closing block moves to page 2, the squeeze is computed from the gaps that help page 1 (header to client, client to table, last row to totals) alone, as in Marble: the terms and footer gaps stay nominal and no longer take a share of the deficit. Before, the share was spread over all gaps including the closing block's, so page 1 came out a few points short and the totals spilled to page 2 as well (A4 with 7 rows).
- **Margins.** 54.2 pt at the top (the INVOICE line box starts there so its baseline lands at 87.2), 59.55 at the sides and 35.5 plus the 24 pt footer reserve at the foot, which puts the icons' bottom 59.5 pt above the page foot as in the design.
+147
View File
@@ -0,0 +1,147 @@
# Template 9: Slate Band (`slate-band`)
Source: `templates/reference/9.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
Modern geometric. A full-bleed slate top band (white logo, name, invoice number) sits over an ocean-blue band with a widely letter-spaced white INVOICE. Montserrat throughout; airy rows separated by soft grey rules; letter-spaced TOTAL with a blue dash; footer of circled icons.
## Fonts
One family: Montserrat, static weights 400/500/700/800. Metrics per 1000 units: ascent 968, descent -251, cap 700.
- ExtraBold 800: 'INVOICE' 30pt #FDFCFD, tracking +6.71pt (0.224em). Labels 'Date:', 'Billed to:', 'From:' 12pt #2D3743. 'Payment method:' and 'Note:' 12pt #4D5869.
- Bold 700: table header labels 14pt #4D5869 uppercase, tracking +2.24 to 2.40pt (~0.165em). 'TOTAL' 14pt with tracking 2.24; its value 14pt with tracking ~0.58 (0.04em).
- Medium 500: vendor name in the band, 12pt #FFFFFF, tracking ~-0.2.
- Regular 400: invoice number 12pt #FFFFFF (tracking 0.27, 0.022em). Date value 13pt #2A2A2A (0.61, 0.047em). Party lines 12pt #2D3743 (~0.28, 0.023em). Table body 12.03pt #4D5869 (~0.3, 0.025em). Payment and note values 12pt #4D5869 (~0.25). Footer contacts 10pt #4D5869 (-0.2, -0.02em).
- Pitches: party lines 16.5 (lineHeight 1.375). Heading to first line 21.26. Date row to party headings 23.1. Payment to note 21.6.
- Montserrat 9.0 is installed locally as 18 static TTFs in ~/.local/share/fonts. fc-list ':charset=20b9' confirms all of them include ₹.
## Colours
Exact fill operators:
- #4D5869 (0.302 0.3451 0.4118), slate: top band; table header labels, body, totals, payment/note and footer text.
- #0278B2 (0.0078 0.4706 0.698), ocean blue: INVOICE band, 3pt total dash, footer icons.
- #FDFCFD (0.9922 0.9882 0.9922): page background (drawn over #FFFFFF) and the INVOICE text.
- #FFFFFF: logo mark, vendor name, invoice number.
- #54AFE6 (0.3294 0.6863 0.902): logo dot only.
- #2D3743 (0.1765 0.2157 0.2627): Date/Billed to/From labels and party text.
- #2A2A2A: date value.
- #DEE2E5 drawn in a transparency-group XObject at ca 0.5 (/G5 set before Do; the inner /G3 ca 1 does not cancel it). Flattened on #FDFCFD it is #EEEFF1. Used for the table header band.
- #8B95A4 drawn in groups at ca 0.53 (/G8). Flattened it is #C1C5CE. Used for the 2.25pt row rules and the footer rule.
In react-pdf use the flattened hex values, or opacity on the View.
## Layout
A4, y measured from the top. Content x 59.55-535.94. The table content is inset to 69.4-525.95 (9.8 inset).
| Region | Geometry (pt) |
|---|---|
| Slate band | full bleed, y 0-103.52 (#4D5869; drawn down to 111.46 underneath the blue band) |
| Logo | x 59.55-84.60, y 55.85-86.98 (25x31): white curved mark + 5.67pt #54AFE6 dot |
| Vendor name | x 92.36 (7.8 right of logo), baseline 74.42 |
| Invoice number | 'NO. 000001' right-aligned to 535.94, baseline 74.80 |
| Blue band | full bleed, y 103.52-157.35 (53.83 tall) |
| INVOICE | x 59.55, baseline 139.07 (cap centre ~128.6; band centre 130.4) |
| Date row | 'Date:' x 59.55, baseline 193.46 (= band bottom + 36.1); value x 109.16, baseline 193.56 |
| Parties | Billed to at x 59.55, From at x 341.96 (59.3% split). Headings baseline 216.57; lines 237.83/254.34/270.84 |
| Table header band | full bleed, y 302.07-360.41 (58.34 tall); labels baseline 337.32 |
| Rows | baselines 394.00/446.02/498.54/552.66/605.17. 2.25pt rules at 415.24/469.26/521.78/575.89/628.41, x 69.0-525.2 |
| Total row | dash x 69.66-111.51 at y 668.50 (3pt). 'TOTAL' at x 371.94; value right-aligned to 525.95; baseline 670.00 |
| Payment / Note | x 69.37, baselines 704.44 / 726.02; values inline at x 196.58 / 113.84 |
| Footer rule | y 755.58, x 59.04-536.46, 2.25pt |
| Footer contacts | ring icons ~19pt at x 69.66/198.30/366.13 (y 771.2-790.4); text baseline 783.81 at x 94.69/223.34/391.41 |
Whitespace below the icons is ~52pt. The footer rule is 86.7 above the page bottom.
## Items table
4 columns inside the inset box 69.4-525.95 (456.5 wide). Approximate column spans: SERVICE 69.4-270 (44%); QTY 270-350 (17.5%, centre 309.4); PRICE 350-452 (22.3%, centre ~401); TOTAL 452-525.95 (16.2%, right edge).
- Header: full-bleed band #DEE2E5 at 50% (~#EEEFF1), 58.34 tall. Montserrat Bold 14 #4D5869 uppercase, tracking ~2.3. SERVICE left-aligned at 69.37, QTY and PRICE centred, TOTAL right-aligned at 525.95. Baseline is 35.25 below the band top and the natural line is 17.1, so react-pdf paddingVertical ~20.
- Body: no fill. Montserrat Regular 12.03 #4D5869, tracking 0.3. Each row is ~52.5-54 tall and ends in a 2.25pt rule #C1C5CE (butt caps) spanning the inset box. Text baseline is ~29.3-30.9 below the previous rule and ~21-23 above the next, so paddingTop ~18.4 and paddingBottom ~19-20 (natural line 14.7).
- Alignment as drawn: service left (reference jitter between 69.0 and 71.7; use 69.4), qty centred, price centred (right edges drift between 418, 420 and 422), total right.
- Recommendation: right-align PRICE and TOTAL. Offer a compact density (rows 30-34), because the default 52.5 fits only 5 rows on page 1.
## Totals and payment
- There is only a TOTAL row, with no subtotal or tax rows. A 3pt #0278B2 dash, 41.85 long, at the left (x 69.66, y 668.5, level with the label baseline). 'TOTAL' Bold 14 with tracking 2.24, starting at 371.94 (centred under PRICE). Value Bold 14, right-aligned at 525.95.
- 'Payment method:' ExtraBold 12 followed inline by a Regular 12 value ('Cash'). 'Note:' followed inline by its value ('Thank you for choosing us!'). Pitch 21.6.
- Missing from the template: bank block, signature, amount in words, due date.
- Footer: full-width soft rule, then 3 circled icons (phone, globe, pin) with Montserrat 10 text.
## Decoration and images
No raster XObjects. Everything is vector shapes plus live text.
- Two full-bleed bands (slate 0-103.5, blue 103.5-157.35) and the full-bleed table header band (50% alpha group).
- Soft rules (53% alpha) and the opaque 3pt blue dash.
- Placeholder logo: white curved 'G'-like mark (14 beziers) with a light-blue dot. Replace with the vendor logo. It sits on dark slate, so it needs a light/white logo variant or a white rounded chip (padding ~4) behind it.
- Footer icons: filled rings ~18.4-19.3 in diameter and 0.6-1.5pt thick, #0278B2, each containing a ~9-10pt glyph (phone, globe, pin). Build as <Svg> with <Circle r stroke='#0278B2' strokeWidth 1.2 fill='none'/> plus <Path d={carbonIcon}/>. Swap the globe for Carbon 'Email', since Voiced has no website field.
- Invisible (ca=0) rectangles tagged 'Image' are accessibility tags. Ignore them.
## Mapping to Voiced fields
- Logo: vendor logo (height <= 31, width <= 120). Needs a light variant to show on #4D5869.
- Vendor name: vendorName (Medium 12, white).
- 'NO. 000001': 'NO. {number}'.
- 'INVOICE': 'TAX INVOICE' or 'INVOICE' (keep the 6.7 tracking).
- Date row: 'Date: {invoiceDate}', NEW 'Due: {dueDate}' on the same baseline in the right column at x 341.96.
- Billed to (left): clientName, clientAddress, 'GSTIN {clientGstin}', 'Place of supply: {state} ({code})'.
- From (right): vendorName, vendorAddress, vendorPhone, vendorEmail, 'PAN {vendorPan}', 'GSTIN {vendorGstin}'.
- Table: SERVICE = description (HSN/SAC as a grey 10pt sub-line); QTY = quantity (with unit); PRICE = rate; TOTAL = amount.
- Subtotal / Discount / CGST+SGST / IGST (no slot): right-aligned summary rows above TOTAL. Label Bold 11, tracking 1.5, #4D5869, at x ~372; value Regular 12, right-aligned at 525.95; pitch ~20.
- TOTAL: total.
- Amount in words (no slot): full-width line under the TOTAL row, 'Amount in words:' ExtraBold 11 followed by Regular 11, #4D5869.
- Payment method: label 'Payment details:' with a block of label/value lines (Bank, A/c name, A/c no, IFSC, Branch, UPI). The inline single-value pattern does not scale.
- Note: notes.
- Signature (no slot): right column beside Payment/Note (x ~372-526) with the signature Image (height <= 36), vendorName and 'Authorised signatory' (Regular 10). With no signature, the computer-generated note.
- Footer: vendorPhone, vendorEmail (envelope icon), vendorAddress.
## Overflow risks
- Slate band header: the name (12pt from 92.4) and the right-aligned number (starts at 463.7) leave ~360pt free. Still use a flex row: name flex 1, number flexShrink 0. A wordmark logo wider than ~120pt pushes the name along.
- 'TAX INVOICE' at 30pt with 6.7 tracking is ~300pt (59.6 to ~360); it fits.
- Parties: only 3 lines fit between 237.8 and the table band at 302.07. A Voiced From block is 6-8 lines (+50-80pt) and Billed to is 4-5, so the table band must flow, never sit at a fixed 302.
- The right column (341.96-536 = 194pt, ~28 chars at 12pt with tracking) wraps long emails and addresses. Emails with no break points need a character-splitting hyphenation callback.
- The TOTAL column is ~74pt. At 12pt Montserrat with 0.3 tracking, '₹1,00,000.00' is ~81pt and '₹12,34,567.00' ~91pt, so amounts overflow from 1 lakh up. Widen TOTAL to ~105 (QTY to ~55, PRICE to ~95), or set the body at 10.5pt.
- Centred prices leave ragged right edges when widths vary; right-align them.
- With 52.5pt rows, 5 rows reach the bottom of page 1. Descriptions wrap within the ~200pt column, which is fine in flow.
- Payment/Note values are inline, so long notes wrap under the label. Use a row with a fixed label width and a flex 1 value.
- The footer address slot starting at 391.4 has only ~135-145pt. Use maxLines 2 or a short address.
## US Letter adaptation
- Bands and the full-bleed table header band: left 0, right 0, width auto.
- Content: side margin M (59.55 by default); table inset M + 9.8.
- Height is 50.25 shorter. The footer rule stays anchored 86.7 above the bottom (y 705.3 on Letter). To recover ~50pt: rows 52.5 -> ~42 (saves 10 x 5), or compact rows at 34; optionally slate band 103.5 -> 90 and blue band 53.8 -> 48.
- Configurable margins: band content uses M horizontally. Date baseline = band bottom + 36. The footer's bottom offset is ~52 below the icons (about M_bottom).
## react-pdf notes
- Register Montserrat 400/500/700/800. Copy the TTFs from ~/.local/share/fonts into the app assets and the xbps; react-pdf never reads fontconfig.
- Page style {paddingTop:M, paddingHorizontal:M, paddingBottom:~100}. Bands are normal-flow (not fixed) Views with marginTop -M and marginHorizontal -M; Yoga accepts negative margins. That makes them full bleed on page 1 only, and continuation pages keep their top margin.
- Slate band: View height 103.5, bg #4D5869, row, space-between, alignItems center, paddingHorizontal M. Blue band: View height 53.8, bg #0278B2, justifyContent center, paddingHorizontal M, with Text INVOICE (letterSpacing 6.7).
- Table header band: <View fixed style={{marginHorizontal:-M, paddingHorizontal:M+9.8, height:58.3, backgroundColor:'#EEEFF1', flexDirection:'row', alignItems:'center'}}> as the first child of the table View. layout 5.2 splitNodes repeats a fixed child in the continuation fragment; confirm visually. Rows: <View wrap={false} style={{marginHorizontal:9.8, flexDirection:'row', paddingTop:18.4, paddingBottom:19.5, borderBottomWidth:2.25, borderBottomColor:'#C1C5CE'}}>.
- Total row: View row, alignItems center: dash View (width 41.85, height 3, bg #0278B2), a flex 1 spacer, the label (letterSpacing 2.24), then the value.
- Footer: <View fixed style={{position:'absolute', left:M, right:M, bottom:~52}}> holding the rule (borderTopWidth 2.25, #C1C5CE) and a row of Svg ring icons with 10pt Text.
- Baselines: Text top = baseline - 0.968 x size.
- The tracked header labels have TOTAL right-aligned, so add marginRight -2.3 (textkit 7.0.1 adds letterSpacing after the last glyph).
## Similarity to other templates
- Near-duplicate: 10.pdf ('Liceria & Co.'). The body is identical: Date / Billed to / From, the full-bleed grey SERVICE-QTY-PRICE-TOTAL band, rule-separated rows, TOTAL with an accent dash, inline Payment method / Note. The header differs: teal/grey corner swoosh artwork instead of the slate and blue bands, a dark letter-spaced INVOICE on white, and NO. in the right column. 10 also has a 'Contact:' block instead of the icon footer. Build 9 and 10 as one component with header/decoration variants.
- Shares Montserrat and letter-spaced uppercase INVOICE/headers with format.pdf (identical to 4.pdf), but the structure is different.
- Does not resemble the in-app InvoiceDocument.tsx; the closest in the set is 3.pdf.
## Implementation notes
Built as `src/pdf/templates/band/` (one family component, `Layout.tsx`, shared with Teal Swoosh; tokens in `slate.tsx`, planning in `plan.ts`) on the decor primitives in `src/pdf/decor/`. Measured against `9.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-slate-band.png`, left reference, right ours; also `-nologo` and the logo variants).
**Matches the design (measured, A4):** dark band 0-103.5, blue band to 157.35 (53.8 tall), INVOICE in Montserrat 800 30 pt with 0.224 em tracking at x 59.5; the name and 'NO.' baseline at 74.7 (reference 74.4 / 74.8), the number right-aligned to the content edge; Date row baseline 193.4 (label and value share it); party heading to first line 21.3 pt; header band 58.3 tall and full bleed; table text at x 69.4 (the 9.8 pt inset) with 2.25 pt #C1C5CE rules from 69 to 526; TOTAL with the 41.85 x 3 pt dash on its baseline; the footer rule 86.7 pt above the page bottom.
**Intentional deviations, and why:**
- **Bands.** Both are first-page decor inside `FirstPageHeader`, full bleed from the page edges (so they also fill Letter and any margin preset); their heights follow the flow (page top margin + logo row + gap), so they stay glued to the logo row whatever the preset. They overlap by half a point so no viewer shows a seam.
- **Logo (dark surface).** The slot is the measured 25 x 31 mark / 99 x 31 lockup. A logo with alpha uses its white knockout; one without alpha sits on a white rounded chip (4 pt padding) drawn as decor behind it. A wordmark replaces the name; a mark keeps the vendor name beside it in white Medium 12.
- **Number and due date.** 'NO. {number}' in the band; 'Due:' goes in the right column of the Date row (the From column), 'PO No:' under Date.
- **Table.** SERVICE / QTY / PRICE / TOTAL with PRICE right-aligned (centred prices left a ragged edge); the HSN/SAC code is a 10 pt sub-line under the description; the rate unit is '/hr' on the same line or a second 10 pt line when it does not fit. Rows are 44 pt (padY 13.5 + the rule) instead of 52.5, the compact density the doc offers, so a Voiced invoice (longer supplier block, tax rows, words, bank) still fits a page; the header band keeps its 58.3 pt. Money columns are at least 55 / 95 / 105 pt and grow to their widest amount (a 12-digit amount does not overflow from 1 lakh up).
- **Totals.** The design has only TOTAL. A right-anchored stack (SUBTOTAL, DISCOUNT, TAXABLE VALUE, CGST/SGST or IGST via `Slot.TaxRows`, tracked Bold 11 labels over Regular 12 amounts, 20 pt pitch) and a thin rule sit above the TOTAL row; the amount in words runs full width below it.
- **Payment, note, signature.** 'Payment details:' is a label plus bank lines (the inline single value does not scale); the Note sits under it when it is at most 100 pt tall, else flows below the table; the signature (the user's image or the signatory name and the computer-generated line) is the right column.
- **Footer.** Circled Carbon icons (phone, e-mail instead of the globe because Voiced has no website field, location) with 1.2 pt rings, drawn as static Svg decor on every page. Decor is not audited, so each text is fitted to one line (phone, e-mail) or two with an ellipsis (address); the full address is printed in the From block. The standard footer (name, 'Generated with Voiced', Page n of N) is added below the icons in grey 7 pt.
- **Margins.** Top 55.75 (the logo top), sides 59.55, bottom floor 20 plus a 79 pt reserve: the flow ends at least 99 pt above the page bottom (rule 86.7 + 12), on every preset.
- **Letter.** The 49.9 pt deficit comes out of the band's bottom padding (16.5 to 8), the gaps to the date row, parties and table, and the gap above the totals (54 pt of slack); type, rows and the header band are not scaled.
+215
View File
@@ -0,0 +1,215 @@
# Template 10: Teal Swoosh (`teal-swoosh`)
Source: `templates/reference/10.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
A clean corporate Montserrat layout. Headings are wide-tracked caps, the table sits on a full-bleed pale-grey band with soft row rules, and teal, gunmetal and silver swooshes fill the top-right and bottom-left corners (the two corners are 180° rotations of each other).
## Fonts
Only Montserrat is used (SIL OFL). Bundle static TTFs at weights 400, 700 and 800.
- ExtraBold 800:
- Title 'INVOICE': 30pt, letterSpacing 7.83pt (about 0.26em, reference setting 260).
- Field labels 'Date:', 'Billed to:', 'From:', 'Payment method:', 'Note:' and 'Contact:': 12pt, letterSpacing 0.30pt (0.025em).
- Bold 700:
- Table header 'SERVICE QTY PRICE TOTAL': 14pt uppercase, letterSpacing 2.8pt (0.2em).
- 'TOTAL' label: 14pt, letterSpacing 2.8.
- Total amount: 14pt, letterSpacing 0.7 (0.05em).
- Brand name: 10.75pt, no tracking.
- Regular 400:
- Party and footer lines: 12pt, letterSpacing 0.30, line pitch 16.5pt (lineHeight 1.375).
- Date value: 13pt, letterSpacing 0.65.
- Table body: 12pt (12.03), letterSpacing 0.36.
- Tagline: 7.26pt.
All text is one colour, #333132.
₹: use fontFamily ['Montserrat','IBM Plex Sans']. Montserrat's U+20B9 still needs checking. IBM Plex Sans has the glyph (confirmed with fc-list).
## Colours
- Page: #FFFFFF. All text: #333132 (charcoal).
- Accent teal #32B3C7: logo triangle, 3pt accent bar beside TOTAL, corner fills.
- Logo dark triangle: #282829.
- Table header band: #DEE2E5 at 50% opacity, which is flat #EFF1F2 on white.
- Row rules: #8B95A4 at 53% opacity, flat #C2C7CF, 2.25pt thick.
- Swoosh gunmetal: raster gradient #343233 to #747474 in diagonal metallic stripes. Approximate stops: #3F3E3F, #747474, #343233, #6A6A6A.
- Swoosh silver: raster gradient #BEBCBE to #D8D7D7 in vertical stripes. Stops: #D5D4D4, #C3C0C0, #D6D5D5, #C5C2C2, #D8D7D7, #BEBCBE.
- Teal edge shade: #333132 at 32% opacity over teal, flat ≈#328997.
## Layout
Units are pt with the origin at the top-left of the 595.5 × 842.25 page. For text, y is the **baseline**.
Margins: the header and parties run from x 59.5 to 536. The table and footer are inset further, x 69.5 to 526.
| # | Region | Position / content |
|---|---|---|
| 1 | Brand row | Logo mark 41×27 at (59.6, 59.5). Brand name Bold 10.75 at x 107, baseline 77.8. Tagline Regular 7.26, baseline 87.2 |
| 2 | Title | 'INVOICE' ExtraBold 30, letterSpacing 7.83, at x 59.5, baseline 144.0 (cap top ≈122.5), 182 wide |
| 3 | Meta row | 'Date:' ExtraBold 12 at x 59.5, baseline 193.5. Value Regular 13 at x 109.2. 'NO. 000001' Regular 12 at x 342.0, baseline 193.8 |
| 4 | Parties (2 columns) | Left column x 59.5–330 'Billed to:'. Right column x 342–536 'From:'. Labels at baseline 216.6. Three lines at 237.8 / 254.3 / 270.8 (16.5 pitch) |
| 5 | Table header band | Full bleed, x 0–595.5, y 302.1–360.4 (58.3 tall). Labels at baseline 337.3 |
| 6 | Item rows | 5 rows, 52.5–54 rule to rule. Baselines 394.0, 446.0, 498.5, 552.7, 605.2. Rules at y 415.2, 469.3, 521.8, 575.9, 628.4, spanning x 69–525 |
| 7 | Total row | Teal bar x 69.7–111.5 at y 668.5 (3pt). 'TOTAL' centred on the PRICE column (x 371.9–431.5), baseline 670.0. Amount right-aligned to 525.9 |
| 8 | Footer info | Left at x 69.4: 'Payment method:' baseline 704.4 with its value at x 196.6; 'Note:' baseline 726.0 with its value at x 113.8. Right at x 359.9: 'Contact:' baseline 704.4, then 3 lines at 723.2 / 739.7 / 756.2 |
| 9 | Decor | Top-right swoosh cluster at x ≥ 253.6, y ≤ 188.1. Bottom-left cluster at x ≤ 342, y ≥ 654.1 |
Vertical rhythm:
- Title baseline to meta: 50.
- Meta to party label: 23.
- Last party line to band: 31.
- Band bottom to first row baseline: 33.6.
- Last rule to TOTAL: 41.6.
- TOTAL to footer: 34.4.
## Items table
**Columns (A4):**
- SERVICE: left-aligned at x≈70. Reference varies between 69.0 and 71.7; normalise to 70.
- QTY: centred at ≈309.5.
- PRICE: centred at ≈400.5.
- TOTAL: right-aligned to 526. The header label's tracked right edge is also at 526.
**Header:** a full-bleed band, running edge to edge rather than just the table width. Colour #EFF1F2, 58.3 tall. Text is Montserrat Bold 14, uppercase, letterSpacing 2.8, #333132, baseline vertically centred at 337.3.
**Rows:**
- Montserrat Regular 12, letterSpacing 0.36, #333132.
- Very airy: about 53pt per row, which is ≈20pt of padding above and below a single line.
- A rule sits under every row, including the last: 2.25pt, #C2C7CF, x 69–525.
- No zebra striping, vertical lines or outer border.
**Voiced adaptation:**
- Right-align PRICE and TOTAL. A ₹ amount like '₹12,34,567.00' is about 95pt wide at 12pt.
- Suggested widths inside x 69.5–526: description flex 1 (at least 200), optional SAC column 50, QTY 50 centred, RATE 100 right, AMOUNT 100 right.
- Put the rate unit on a second line under the rate in 8.5pt.
- Body text 11pt with row padding of about 10, giving 34–38pt rows, so 10–12 items fit on page 1.
- Row heights must grow for multi-line descriptions.
## Totals and payment
**Source:**
- There is no subtotal or tax row, only TOTAL. The row has a teal 3pt accent bar at the left (x 69.7–111.5, y 668.5), a 'TOTAL' label (Bold 14, letterSpacing 2.8, centred at ≈400 over the PRICE column) and the amount (Bold 14, letterSpacing 0.7, right edge 525.9), all on baseline 670.
- The footer has 'Payment method:' with an inline value and 'Note:' with an inline value at left. Labels are ExtraBold 12, values Regular 12, with about an 8pt gap.
- At right is 'Contact:' followed by phone, email and address.
- There is no signature.
**Voiced:**
- Above TOTAL, in a right-hand block at x 345–526, add rows for SUBTOTAL, an optional DISCOUNT, then CGST @ x% plus SGST @ x%, or IGST @ x%.
- Labels: Montserrat Bold 10.5, letterSpacing 1.6, uppercase.
- Values: Regular 12, right-aligned at 526, 18–20 pitch.
- Then a 0.75pt #C2C7CF rule and the original TOTAL row, with the accent bar kept on that baseline.
- Amount in words: one line under TOTAL across x 69.5–526, Montserrat Regular 10, for example 'Amount in words: Rupees … Only'.
- Bottom-right ('Contact:' slot): bank details headed 'Payment details:'. Lines are bank, A/c name, A/c no., IFSC, branch and UPI, at 10.5pt with 15 pitch. This area stays clear of the swoosh all the way to the page bottom.
- Bottom-left: 'Note:' for notes, at most 3 lines.
- Signature or computer-generated note: place it in the right column below the bank details. Signature image up to 140×48, then the vendor name and 'Authorised signatory'.
## Decoration and images
**Raster images:** 4 Flate ICC-RGB images, each just a gradient clipped by a vector path. obj137 and obj159 are 310×282 px (gunmetal); obj140 and obj156 are 428×217 px (silver). There are no photos. All of it can be redrawn as vector with react-pdf Svg and LinearGradient.
**Bottom-left cluster.** Page pt, y down. The paths run past the page edge to y 866 and are clipped. Draw in this order:
1. Gunmetal band: `M207.7,866.4 C95.8,813.0 21.1,726.7 -25.4,654.1 L-25.4,866.4 Z`. Fill with a diagonal gradient (0,0 to 1,1): #3F3E3F at 0, #747474 at .35, #343233 at .7, #6A6A6A at 1.
2. Silver crescent (curve fit, max error 0.6pt): `M20,703 C78.8,780.4 163.7,834.8 255,866 L342,866 C220.8,849.9 100.9,796.5 20,703 Z`. Fill with a horizontal multi-stop silver gradient. It sits outside the gunmetal band with a white gap between them and is 8–15pt thick where visible.
3. Teal corner: `M132.2,866.4 L-25.4,866.4 L-25.4,672.7 C2.0,727.2 56.2,808.8 153.5,866.4 Z`, #32B3C7.
4. Teal edge shade: `M153.5,866.4 C56.2,808.8 2.0,727.2 -25.4,672.7 L-25.4,676.7 C-0.1,730.4 48.2,808.5 132.2,866.4 Z`, #333132 at fillOpacity .32.
**Top-right cluster:** the same four paths rotated 180° about the page centre, so (x, y) becomes (595.5 − x, 842.25 − y). For example:
- Gunmetal: `M387.8,-24.2 C499.7,29.2 574.4,115.5 620.9,188.1 L620.9,-24.2 Z`.
- Teal: `M463.3,-24.2 L620.9,-24.2 L620.9,169.5 C593.5,115.0 539.3,33.5 441.9,-24.2 Z`.
On-page footprints:
- Bottom-left: about x 0–236, y 690–842. At the bottom edge the gunmetal reaches x≈157 and the crescent x≈236.
- Top-right: the mirror of that.
Other elements:
- Logo mark (59.6–100.5 × 59.5–86.4): two overlapping triangles, #282829 and #32B3C7. Reference placeholder; replace with the vendor logo.
- Rules and the accent bar are plain strokes with butt caps.
## Mapping to Voiced fields
- **Logo mark** → vendor logo. Image with maxHeight 40, maxWidth 120, objectFit contain, at (59.5, ≈55).
- **'LICERIA & CO.'** → vendorName, Bold, enlarged to 12–14pt. The 'Travel Agency' tagline slot is optional: leave it empty or use a trade line.
- **'INVOICE'** → 'INVOICE', or 'TAX INVOICE' when taxType ≠ none. At 30pt with letterSpacing 7.83, 'TAX INVOICE' is about 330 wide and ends at x≈390. That fits, because the swoosh is at x ≥ 540 on the title line.
- **'Date:'** → invoiceDate. **'NO.'** → number.
- **Due date (no slot):** add a second meta line 'Due:' at baseline ≈210, with 'PO:' to its right when present. Shift everything below down by 16.5.
- **'Billed to:'** → clientName and clientAddress (multi-line). Add 'GSTIN: …' and 'Place of supply: 19 – West Bengal' (no slot) as extra lines.
- **'From:'** → vendorName, vendorAddress, vendorPhone, vendorEmail. Add 'PAN: …' and 'GSTIN: …' (no slot) as extra lines; the block grows to 6–8 lines.
- **Table** → items. Description in SERVICE, quantity in QTY (1 for fixed lines), rate plus unit in PRICE ('—' for fixed lines), amount in TOTAL.
- **HSN/SAC (no slot):** optional 50pt column, or a 9pt grey line under the description.
- **Subtotal, discount, CGST/SGST/IGST (no slot):** stacked above TOTAL.
- **Total** → the TOTAL row. **Amount in words (no slot):** under TOTAL.
- **Bank details (no slot for most lines)** → the bottom-right 'Contact:' area (bank, A/c name, A/c no., IFSC, branch, UPI); vendor phone/email are already in 'From:'.
- **'Payment method:' / 'Note:'** → notes stay bottom-left; 'Payment method:' is dropped or reduced to one inline value such as 'Bank transfer / UPI'.
- **Signature (no slot)** → right column, below the bank details.
## Overflow risks
- **Header vs top-right swoosh.** The silver crescent's inner edge crosses y 0 at x≈360, sits at x≈478–523 for y 56–88, and at x≈550–575 for y 112–139. The gunmetal band starts at x≈432 at y 0 and is at x≈505–540 for y 53–86. Keep brand-row content within x ≈440, so the vendor name is at most about 320 wide and wraps to 2 lines. Keep the title within x 520.
- **Header overlap bug.** Vendor contact details sit in 'From:', away from the title, so the address/INVOICE overlap only stays fixed if the two party columns are fixed-width flex children (270.5 and 194) that wrap.
- **'From:' column is only ~194 wide.** Long emails and addresses wrap, and with PAN/GSTIN the block is 6–8 lines. It must push the table down rather than use an absolute y.
- **Table.** The description column is about 210 wide and wraps. With 53pt rows only about 5 items fit before the totals on page 1. Centred PRICE and TOTAL collide for long ₹ values.
- **Bottom-left swoosh.**
- Content at x 69 must stay above y ≈745, at x 150 above ≈800, and at x ≥ 250 it is clear to about 835.
- The source's left footer ends at baseline 726.
- A 6-line bank block plus notes on the left would run into the crescent and gunmetal. That is why the bank block moves to the right (x ≥ 345 is clear to the bottom).
- Alternatively, give this template paddingBottom ≈110.
- **Existing app footer.** The fixed footer at bottom 24, left 40 would land on the bottom-left teal. Move its text to x ≥ 300 for this template.
- **Multi-page.** The band header must repeat on continuation pages, and both swoosh clusters must repeat (fixed).
## US Letter adaptation
US Letter is 612 × 792: 16.5pt wider and 50.25pt shorter than the source.
- **Width.** Keep margins of 59.5 (header) and 69.5 (table), so content widens to 493 and 473. Give the extra width to the description column and the right party column. The header band stays full-bleed: marginHorizontal = −page paddingX.
- **Decorations.** Anchor them to the page corners, not to A4 coordinates:
- Top-right: Svg with top 0, right 0, width 342, height 188.1, viewBox '253.5 0 342 188.1'.
- Bottom-left: Svg with left 0, bottom 0, width 342, height 188.2, viewBox '0 654.1 342 188.2'.
- Both keep their size on Letter.
- **Height.** Recover the 50pt by shrinking the band from 58 to 46, the row pitch from 53 to 40, and the title-to-meta gap from 50 to 36.
- **Configurable margins.** Map them to Page padding. This template needs a bottom margin of at least about 100 so flowing text never enters the bottom-left swoosh. Decorations ignore the margins.
## react-pdf notes
**Page:** `<Page size={'A4'|'LETTER'} style={{paddingTop:55, paddingHorizontal:59.5, paddingBottom:110, fontFamily:['Montserrat','IBM Plex Sans'], color:'#333132'}}>`.
**Decorations:** the first children are two `<Svg fixed style={{position:'absolute', …}}>` clusters. Check that react-pdf measures absolute offsets from the page edge rather than from inside the padding; the current footer relies on this. If it doesn't, offset by −padding.
**Flow, top to bottom:**
- Header: a row with Image plus a View holding name and tagline, maxWidth 370.
- Title: Text with fontWeight 800, fontSize 30, letterSpacing 7.83.
- Meta rows: justify space-between. The left cell is 282.5 wide, so the right cell starts at x 342.
- Parties: a row of two Views, 270.5 and 194 wide; Text wraps.
- Table header band: a View with marginHorizontal −59.5, paddingHorizontal 69.5, paddingVertical ≈20 and backgroundColor '#EFF1F2'. For continuation pages, repeat it with a fixed View whose render function returns it only when pageNumber > 1.
- Rows: each row a View with wrap={false}, borderBottomWidth 2.25, borderBottomColor '#C2C7CF', paddingVertical 12–20.
- Totals: a View with wrap={false} and minPresenceAhead 80.
- Footer: a row with the left part flex 1 and the right part 181 wide.
**Details:**
- react-pdf's letterSpacing adds space after every glyph, including the last. the reference design's measurements here do the same, so right-aligned tracked labels already line up.
- No raster assets are needed. Gradients go in `<Defs><LinearGradient id='gun'>…</LinearGradient></Defs>` with fill='url(#gun)'.
- Fonts: `Font.register({family:'Montserrat', fonts:[{src:'…/Montserrat-Regular.ttf', fontWeight:400}, {src:'…/Montserrat-Bold.ttf', fontWeight:700}, {src:'…/Montserrat-ExtraBold.ttf', fontWeight:800}]})`. Use static TTFs.
## Similarity to other templates
Near-duplicate of 9.pdf. Both use the same Montserrat type scale, and these elements sit in the same positions:
- Date and NO.
- Billed to and From.
- The full-bleed table band, 52.5pt rows with 2.25pt rules, and the accent bar with TOTAL.
- Payment method and Note.
9.pdf swaps the swooshes for a slate top bar (logo plus NO.), a blue full-width 'INVOICE' band and an icon contact footer. 10.pdf has a logo/brand row, swoosh corners and a 'Contact:' block. Build 9 and 10 as one layout component with a header/decor variant. Not related to format.pdf or the current IBM Plex in-app template.
## Implementation notes
Built as `src/pdf/templates/band/` (the family component shared with Slate Band; tokens in `teal.tsx`) on the decor primitives in `src/pdf/decor/` (`CornerSwoosh`, with `LinearGradient` fills and keep-out rectangles). Measured against `10.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-teal-swoosh.png`, left reference, right ours).
**Matches the design (measured, A4):** logo mark at (59.5, 59.5); INVOICE in Montserrat 800 30 pt with 0.26 em tracking, baseline 144; Date and 'NO.' on one row (x 59.5 and 342), Billed to / From at x 59.5 / 342 (270.5 and 193.7 wide); the full-bleed #EFF1F2 header band 58.3 tall, 2.25 pt #C2C7CF rules; the teal / gunmetal / silver corner art at the design's coordinates (the paths are the spec's, mirrored numerically for the top-right cluster, gradients flipped with them).
**Intentional deviations, and why:**
- **Swooshes.** Static Svg paths with gradients in a single `Defs` (react-pdf resolves `url(#id)` from the first direct `Defs` only), anchored to the page corners (`left 0 / bottom 0`, `right 0 / top 0`, 342 x 188 pt), so they also fit Letter. The bottom-left cluster is fixed on every page. The top-right cluster is **page 1 only** (it sits in `FirstPageHeader`): on pages 2+ the table band starts at the top margin right under where the crescent would be, and the flow may not enter a keep-out. The gradient stops (dark at the teal edge, a highlight about two thirds along, dark again at the page edge) were read off the rendered original; the doc lists only the colours.
- **Keep-outs.** Staircases of 8 pt strips follow the silver crescent's inner edge plus 4 pt, per corner (bottom-left on every page, top-right on page 1); the audit's `keepout` check runs against them for text, images and Svg, fixed chrome included. The bottom padding is 110 pt so the flow stays above the crescent on every page and size; the footer text starts at x 200 (right of the crescent at the footer's height, no supplier name there), and the continuation band keeps its normal place.
- **Header.** The brand block stays left of x 440 (the design's limit): a mark logo has the vendor name beside it at 13 pt Bold (the design's 10.75 pt is a short placeholder), wrapping to more lines when long; a wordmark replaces the name; the tagline slot is dropped.
- **Meta.** Left column: Date, then 'Due:'; right column: 'NO. {number}', then 'PO:'. (The design's second meta line is added; everything below shifts down by one line, 16.5 pt.)
- **Table, totals.** As for Slate Band (docs/templates/09-slate-band.md): PRICE and TOTAL right-aligned, HSN/SAC as a sub-line, 44 pt rows instead of 52.5, a right-anchored stack of SUBTOTAL / DISCOUNT / TAXABLE VALUE / CGST+SGST (or IGST) above TOTAL with the 41.85 x 3 pt teal dash on its baseline, amount in words under it.
- **Bottom block.** The bank details (headed 'Payment details:') take the 'Contact:' column on the right (x 345-526, 181 pt), with the signature below; the Note sits bottom left when it is at most 100 pt tall, else flows below the table. Vendor phone and e-mail are in the From block, so 'Contact:' is not repeated.
- **Margins.** Top 55, sides 59.55, bottom floor 24 plus an 86 pt reserve (110 in all).
- **Letter.** The 49.9 pt deficit comes out of the brand-to-title gap (26.2 to 14), the gaps to the date row, parties, table and totals (58 pt of slack); rows, bands and the art keep their size.
+189
View File
@@ -0,0 +1,189 @@
# Template 11: Tangerine Ledger (`tangerine-ledger`)
Source: `templates/reference/11.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
A crisp business ledger with Poppins Bold display text and Inter body. The header row and total cell are filled #FF5F2F, items and totals sit in a fully ruled black 0.75pt grid, and orange pennant tabs bleed off the top-right and bottom-left edges.
## Fonts
- **Poppins Bold 700:** brand 'Rimberio' 18pt; 'INVOICE' 33pt; letterSpacing ≈0.
- **Inter SemiBold 600:**
- Section labels 12pt, caps as typed: 'STUDIO SHODWE', 'BILL TO', 'NOTES / TERMS:', 'PAYMENT METHOD:', 'PREPARED BY:'.
- Table header 11pt, white.
- **Inter Regular 400:**
- Meta and party lines 12pt, line pitch 16.5 (lineHeight 1.375). The two meta lines use a pitch of 18.7.
- Table body, totals labels and totals values 11pt.
- **Inter Bold 700:** Total row 12pt, white.
Inter text everywhere carries +0.01em tracking (0.11–0.12pt), which can be omitted.
Bundle Inter 400/600/700 and Poppins 700 as static TTFs (OFL). For ₹, Poppins is Devanagari-capable and has U+20B9; check the Inter build, and use the fallback ['Inter','IBM Plex Sans'] anyway.
## Colours
- Page: #FFFFFF. Text: #000000.
- Accent #FF5F2F: table header fill and its 0.75pt outline, Total row fill, both edge tabs.
- On accent: text #FFFFFF; column separators inside the header and Total row are #FFFFFF 0.75pt.
- Grid lines: #000000 0.75pt, for item rows and the totals box.
- A white-on-white leaf shape at 60–90 × 69–111 is an invisible reference logo placeholder. Ignore it.
## Layout
Units are pt with the origin at the top-left. For text, y is the **baseline**.
Margins: left 59.5, right 58.8, so content runs x 59.5–536.7 (477.2 wide). Top ≈60, bottom ≈62.
| # | Region | Position / content |
|---|---|---|
| 1 | Header | 'Rimberio' Poppins Bold 18 at x 59.5, baseline 84.3. 'INVOICE' Poppins Bold 33, right-aligned to 536.7 (starts x 399.4), baseline 80.6 |
| 2 | Meta | 'Invoice Number: #123456' Inter 12 at x 59.5, baseline 168.7. 'Invoice Date: 24/05/2030' at baseline 187.4 |
| 3 | Parties | Left column x 59.5–272: vendor name as label 'STUDIO SHODWE', SemiBold 12, baseline 224.7, then lines at 247.9 / 264.5 / 281.0 / 297.5. Right column x 284.1–536.7: 'BILL TO' plus lines on the same baselines |
| 4 | Items table | x 59.5–536.7. Header y 327.8–365.7 (37.9 tall). 3 rows of 37.5 (365.8 to 478.0) |
| 5 | Notes (left) | 'NOTES / TERMS:' SemiBold 12 at x 59.5, baseline 511.8. Text 12pt at 536.2 / 552.7, about 180–280 wide |
| 6 | Totals box (right) | x 353.6–536.7 (183 wide), split at x 447.6. Rows 501.3–538.9 and 538.9–576.4; Total row 576.6–615.9 |
| 7 | Bottom | 'PAYMENT METHOD:' SemiBold 12 at x 60.1, baseline 722.3, with 3 lines at 746.3 / 762.8 / 779.4. 'PREPARED BY:' at x 316.8, baseline 722.3, with 2 lines at 746.3 / 762.8 |
| 8 | Edge tabs | Top-right x 560.4–595.5 (bleeds), y 0–230.5. Bottom-left x 0–35.3, y 623.7–842.25 |
Gaps:
- Header to meta: 84.
- Parties to table: 30.
- Table to notes/totals: 23–34.
- Totals to bottom block: about 106. This is generous and can absorb the extra tax rows.
## Items table
A grid table with every cell ruled.
**Columns (A4):**
- 'Item & Description': x 59.5–308.6 (249.1 wide). Left-aligned with 9.8 padding, so text starts at x 69.3.
- 'Unit Price': 308.6–406.1 (97.5 wide). Right-aligned, text ends at 397.4.
- 'Qty': 406.1–448.2 (42.1 wide). Right-aligned, text ends at 438.5.
- 'Amount': 448.2–536.7 (88.5 wide). Right-aligned, text ends at 526.9.
**Header row:** 37.9 tall, fill #FF5F2F, Inter SemiBold 11 white, baseline vertically centred at 350.1. Vertical separators are white 0.75pt and the outline is #FF5F2F.
**Body rows:** minimum height 37.5. Inter Regular 11 #000, baseline about 21.8 below the row top (vertically centred). Black 0.75pt lines on every row and column boundary plus the outer frame. No zebra striping.
**Voiced adaptation:**
- Widen the money columns: Unit Price to at least 100, with the unit on a second line ('₹2,000.00' over 'per hour' at 8.5pt); Qty to 45; Amount to at least 100. The description column shrinks to about 232.
- Optional 'HSN/SAC' column, 50 wide.
- Use paddingVertical 11–12 instead of a fixed 37.5 height so rows grow with wrapped descriptions.
## Totals and payment
**Source:**
- The totals box is a bordered 2-column grid. 'Sub-Total' and 'Tax (6%)' rows use Inter Regular 11, with labels at x 363.3, values right-aligned to 526.9, and rows 37.5 tall.
- The Total row is filled #FF5F2F, with Inter Bold 12 white text and a white separator (39.3 tall).
- Notes sit to the left of the totals. The bottom block has 'PAYMENT METHOD:' (Bank, Account Name, Account Number) and 'PREPARED BY:' (name and title).
- There is no signature image and no footer.
**Voiced:**
- Totals rows: Sub-Total, optional Discount, CGST @ 9% plus SGST @ 9% (or IGST @ 18%), then Total in orange.
- Widen the box to about x 316–536.7, with label and value columns of 110 each, to fit ₹ strings. Use 26–30pt rows so 5 rows fit.
- Amount in words: below the box, either the box's width or across x 59.5–536.7, Inter Regular 10.
- PAYMENT METHOD: Bank, Account Name, Account Number, IFSC, Branch, UPI ID. That is 6 lines at 10.5pt with 14.5–15 pitch.
- PREPARED BY becomes the signature: an image (at most 140×48), then the vendor name and 'Authorised Signatory'. Without an image, show the vendor name and 'Computer-generated invoice; no signature required.' in Inter 9, #555.
## Decoration and images
There are no raster images; everything is vector.
**Top-right pennant tab, #FF5F2F.** The full source path is mostly off-page:
`M560.4,-6.7 L560.4,172.8 C560.4,177.7 561.9,182.5 564.7,186.5 L605.5,244.8 C606.5,246.2 608.1,247.0 609.8,247.0 C611.5,247.0 613.1,246.2 614.1,244.8 L655.0,186.5 C657.8,182.5 659.3,177.7 659.3,172.8 L659.3,-6.7 C659.3,-10.8 655.9,-14.2 651.8,-14.2 L567.9,-14.2 C563.8,-14.2 560.4,-10.8 560.4,-6.7 Z`
The visible part is a 35.1-wide strip from the top down to y≈172.8, ending in a diagonal edge that meets the page edge at y≈230.5. Simplified visible path:
`M560.4,0 H595.5 V230.5 L564.7,186.5 Q560.4,180.2 560.4,172.8 Z`
**Bottom-left pennant tab, #FF5F2F.** It points up, with its apex off-page at (−11.9, 610.9):
`M35.2,846.4 L34.5,680.1 C34.5,675.2 33.0,670.5 30.2,666.5 L-7.5,613.1 C-8.5,611.7 -10.1,610.9 -11.9,610.9 C-13.6,610.9 -15.2,611.7 -16.1,613.1 L-53.4,666.8 C-56.2,670.9 -57.7,675.6 -57.7,680.5 L-56.9,846.8 C-56.9,850.9 -53.6,854.3 -49.4,854.3 L27.8,853.9 C31.9,853.9 35.3,850.5 35.2,846.4 Z`
Simplified visible path:
`M0,623.7 L30.2,666.5 Q34.5,672.6 34.5,680.1 L35.3,842.25 H0 Z`
**Other elements:**
- Grid lines and fills are plain rectangles, drawn as View borders and backgrounds.
- The white leaf placeholder is not reproduced.
## Mapping to Voiced fields
- **'Rimberio'** → vendor logo (maxHeight 36, at x 59.5, y ≈62) with vendorName beside it in Poppins Bold 18. Use the name alone when there is no logo.
- **'INVOICE'** → INVOICE or TAX INVOICE. At 33pt, 'TAX INVOICE' is about 216 wide and spans x 320–536.7. That works as long as the brand block is capped at 240 wide.
- **'Invoice Number:'** → number. **'Invoice Date:'** → invoiceDate.
- **Due date (no slot):** a third meta line 'Due Date:' at baseline ≈206.1.
- **PO number (no slot):** a fourth line 'PO No.:' when present. Shift the parties down accordingly.
- **Left party column** (its label is the vendor name in caps) → vendorName, vendorAddress, vendorPhone, vendorEmail. Add 'PAN: …' and 'GSTIN: …' (no slot) as extra lines.
- **'BILL TO'** → clientName and clientAddress. Add 'GSTIN: …' and 'Place of supply: 19 – West Bengal' (no slot) as extra lines.
- **Table** → items: description / rate plus unit / qty / amount. Fixed-price lines show qty 1 and rate '—'.
- **HSN/SAC (no slot):** optional column.
- **Totals** → subtotal, discount, CGST/SGST/IGST and total. The tax split has no slot, so it becomes extra rows.
- **Amount in words (no slot):** under the totals box.
- **'NOTES / TERMS:'** → notes, plus 'Payment due within N days' from paymentTermsDays.
- **'PAYMENT METHOD:'** → bank details. IFSC, branch and UPI have no slot and become extra lines.
- **'PREPARED BY:'** → signature image, or the computer-generated note.
## Overflow risks
- **Header.** A logo plus a long vendor name can collide with 'INVOICE' (starts x 399) or 'TAX INVOICE' (starts x 320). Cap the brand block at about 240 and let it wrap.
- **Party columns.** The left column is only about 212 wide (59.5 to 272); the right is about 252. Long addresses and emails wrap, and 6–7 vendor lines push the table down, so this must be flow layout rather than fixed y.
- **Description cell.** 249 wide, or about 232 after widening the money columns. Rows with wrapped text grow, so use paddingVertical instead of a fixed 37.5 height.
- **Amount cells.** '₹12,34,567.00' at Inter 11 is about 78 wide; with 19.6 of padding it exceeds 88.5. Widen to at least 100. The totals value cell has the same problem: widen from 89 to at least 105.
- **Totals box.** 5 rows at 37.5 would be 188 tall; keep rows at 30 or less.
- **Bottom block.** Six bank lines at 16.5 pitch from baseline 746 end around 829, past the A4 safe area of about 806. Use 10.5pt with 14.5 pitch or a 2-column bank grid, and place the block in flow after the totals with marginTop ≥30 instead of at the fixed 722 baseline.
- **Grid across page breaks.** Each row draws its own bottom, left and right borders, and the header row repeats on continuation pages.
- **Edge tabs** never touch content:
- The right tab is at x ≥ 560.4 while content ends at 536.7 (for y ≤ 230.5).
- The left tab is at x ≤ 35.3 while content starts at 59.5 (for y ≥ 623.7).
- The app footer at left 40 clears the left tab by 4.7pt.
## US Letter adaptation
US Letter is 612 × 792.
- **Width.** Keep margins of 59.5 and 58.8, so content becomes 493.7 wide (16.5 more). Give the extra to the description column, and widen the party columns proportionally (left 220, right 262).
- **Tabs.** Anchor them to the page edges:
- Right tab: Svg with top 0, right 0, width 35.1, height 230.5.
- Left tab: left 0, bottom 0, width 35.3, height 218.6. On Letter it then starts at y ≈573.
- Both keep their size.
- **Height.** To recover the 50pt, shrink the header-to-meta gap from 84 to 60 and the totals-to-bottom gap from 106 to about 40, or let the bottom block flow.
- **Configurable margins.** Map them to Page padding. The tabs ignore margins. Enforce side margins of at least 45 so text never meets the 35pt-wide tabs.
## react-pdf notes
**Page:** padding top 60, left 59.5, right 58.8, bottom 60. fontFamily ['Inter','IBM Plex Sans'], fontSize 12, lineHeight 1.375.
**Tabs:** two `<Svg fixed style={{position:'absolute', top:0, right:0}} width={35.1} height={230.5}>` elements, using the simplified paths translated into local coordinates.
**Header:** a row with justifyContent space-between and alignItems flex-end. The two baselines (84.3 and 80.6) differ by 3.7.
**Parties:** a row of two Views, 212 and 252.6 wide, with a 12.6 gap. Labels use Inter 600.
**Table:**
- Container View: borderTopWidth 0.75, borderLeftWidth 0.75, borderColor '#000'.
- Header row: backgroundColor '#FF5F2F'; each cell has borderRightWidth 0.75 with borderColor '#FFF'.
- Body rows: wrap={false}. Each cell has borderRightWidth 0.75 and each row borderBottomWidth 0.75. Numeric cells use textAlign 'right', paddingHorizontal 9.8, paddingVertical 12.
- Continuation pages: repeat the header with `<View fixed render={({pageNumber}) => pageNumber > 1 ? <TableHeader/> : null}/>`.
**Notes and totals:** a row with notes at flex 1 and totals 220 wide, wrap={false}.
**Bottom:** a row holding payment and signature, wrap={false}, minPresenceAhead 100.
## Similarity to other templates
Near-duplicate of 7.pdf. Both carry the same 'Studio Shodwe' content and the same geometry: big INVOICE title, meta lines, vendor and BILL TO columns, a 4-column ruled grid with a filled header, a 2-column totals box, and PAYMENT METHOD / PREPARED BY.
Differences: 7.pdf uses Poppins for body text, a dark-red accent, a flame logo at top-right, a red rule under the header and a full-width red contact footer band. 11.pdf uses Inter body, #FF5F2F, brand text at left and edge tabs.
Build one 'Ledger Grid' component with an accent colour and a decor variant (tabs or footer band). Of 10–13, this is structurally closest to the current in-app template. Unrelated to format.pdf.
## Implementation notes
Built as `src/pdf/templates/ledger/` (the family component shared with Crimson Grid; tokens in `tangerine.tsx`) on the decor primitives in `src/pdf/decor/` (`Pennant`). Measured against `11.pdf` with `npm run templates:compare` (`/tmp/invoice-c4/compare-tangerine-ledger.png`, left reference, right ours).
**Matches the design (measured, A4):** INVOICE baseline 80.6 pt, right edge on the content edge (536.7), Poppins Bold 33; 'Invoice Number' / 'Invoice Date' baselines 168.6 / 187.3 (reference 168.7 / 187.4); party headings at x 59.5 and 284.1, 23.9 pt above the first line; table header and row pitch (header 41.5 pt single-line incl. rules, rows 37.5); accent #FF5F2F header, filled Total row with white separators; both pennant tabs at the design's coordinates.
**Intentional deviations, and why:**
- **Margins.** Top 46 pt: Poppins's tall ascent puts the 33 pt title's box top 11.5 pt above the cap top, so the box (not the ink) starts at the margin. Sides 59.55 with a floor of 45 on every preset, so Narrow (36) never puts text under the 35 pt tabs. Bottom 60 + 16 footer reserve.
- **Header.** The brand zone is the measured 220 x 36 allowance at y 62-98. A wordmark logo replaces the 'Rimberio' text; a mark logo sits left of the vendor name in Poppins Bold 18; without a logo the name stands in. The brand block is at most half the content width, the title shrinks to at most 60 % before it would meet it.
- **Tabs.** Two static Svg paths anchored to the page corners (top right 35.1 x 230.5, bottom left 35.3 x 218.6), fixed on every page; the reference paths are simplified to their visible part with the same curves. They keep their size on Letter.
- **Meta, parties, table, totals, notes, closing, elastic gaps:** as for Crimson Grid (docs/templates/07-crimson-grid.md), with Tangerine's own numbers: meta gap 59 to 32, party 20.8 to 12, table 25.4 to 14, totals 23.3 to 10 (64 pt of slack on Letter), cell padX 9.8, padY 10.8, totals box at least 205 pt. The Total row is filled with the accent (white 12 pt Inter Bold, white separator).
- **Type.** Poppins 700 for brand and title, Inter 400/600/700 for the rest (static TTFs; Inter and Poppins both carry U+20B9, Plex still ends every stack). Inter's strongly kerned pairs ('7,', '7.') make pdf.js report an amount as two text items; the text is unchanged and the harness joins such fragments before checking the rupee sign.
- **Footer.** The standard footer (supplier name, 'Generated with Voiced', Page n of N) at 7 pt in grey; the design has none. The left tab (x 0-35.3) clears the footer at x 59.5 by 24 pt.
+237
View File
@@ -0,0 +1,237 @@
# Template 12: Purple Pop (`purple-pop`)
Source: `templates/reference/12.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
A playful Memphis style on cream: an oversized purple 'Invoice' wordmark; purple, yellow and grey pill-shaped rows and labels; 8-arm asterisk motifs (solid purple, plus huge pale-pink watermarks); and a yellow dome shape.
## Fonts
- **Open Sauce One Bold:** the 'Invoice' wordmark only, 94pt (94.07), letterSpacing −7.44pt (−0.079em), #530895.
- **DM Sans Bold:**
- Vendor name 17.77pt.
- Amount due 32.74pt.
- Dates 14.05pt, uppercase, right-aligned.
- Table header labels 14pt, uppercase, white.
- 'PAYMENT TERMS' 13pt.
- **DM Sans Regular:** table body 11.83pt; terms bullets 11pt with 14.2 pitch.
- **Gotham Bold (commercial):**
- 'BILLED TO:' 12pt.
- Client name 14.39pt, caps.
- 'INVOICE NO : 012345' 13.39pt, letterSpacing 1.75pt (0.13em).
- 'AMOUNT DUE:' 12pt.
- SUBTOTAL, TAX and TOTAL labels and the total value, 13pt.
- 'PAYMENT METHOD' 10.52pt.
- **Gotham Book:** client lines 10.16pt (17.1 pitch); subtotal and tax values 13pt; bank lines 12pt (17.9 pitch); footer contacts 10.14pt (18.4 pitch).
**Bundling:**
- Gotham cannot be redistributed. Substitute Montserrat 400/700 (about 5% wider, so drop about 0.5pt), or use DM Sans 400/700 for all Gotham text, which needs the fewest files.
- Open Sauce One is used for one word. Its licence is reportedly OFL but needs checking; otherwise use Poppins ExtraBold or Montserrat ExtraBold at −0.08em.
- ₹: fallback ['DM Sans','IBM Plex Sans'].
## Colours
- Page background: #FEFFEE (cream).
- Primary purple #530895: wordmark, amount due, header pill, total pill, asterisks, client name text, invoice-number text.
- Yellow #FFD80B: client-name pill, every second item row, dome shape.
- Grey #E5E5E5: invoice-number pill (solid). Item rows use it at 68% opacity, which is flat #EDEDE8 on cream.
- Watermark asterisks: #FFF7F7 (pale pink).
- Text: #060605, #231F20 and #000000. On purple: #FFFFFF in the header, #FFF4F4 in the total.
- Rules #060605, 1.5pt. Icons #231F20 and #060605. Bullets #231F20.
- Reference shadow images use black, coral #FF524B and brown #413731 at 30%. Do not reproduce them.
## Layout
Units are pt with the origin at the top-left. For text, y is the **baseline**. Content spans x ≈54.8–542.8.
| # | Region | Position / content |
|---|---|---|
| 1 | Header | Vendor name DM Sans Bold 17.77 at x 59.5, baseline 98.5. INVOICE NO pill at x 312.8–530.9, y 77.0–110.2 (radius 16.6), text centred at ≈421.9, baseline 96.7 |
| 2 | Client (left, x 54.8 to ≈232) | 'BILLED TO:' at x 67.1, baseline 157.8. Yellow name pill x 54.8–201.4, y 173.8–207.0 (radius 16.6), sized to its text with 12.3 left padding; name baseline 195.2. Three lines at x 67.1, baselines 223.9 / 241.0 / 258.2 |
| 3 | Wordmark (right) | 'Invoice' 94pt, x 238.2–530.4, baseline 238.4. Glyph tops at ≈172 |
| 4 | Rule 1 | x 64.8–533.4, y 279.6, 1.5pt |
| 5 | Amount and dates | 'AMOUNT DUE:' at x 71.8, baseline 310.5. Amount 32.74pt at x 73.7, baseline 350.0. Dates right-aligned to 519.7, baselines 315.7 / 335.2 |
| 6 | Items | Block x 176.9–539.4. Header pill y 378.2–424.8 (radius 23.3). Rows 424.3–457.0, 457.0–489.7, 489.7–522.5, each a pill (radius ≈16.4). Purple asterisk 71.5 at (57.0, 397.7), to the left |
| 7 | Subtotal / Tax | Labels at x 192, baselines 545.9 / 576.7. Values right-aligned to 522.0; source baselines are 550.8 / 576.7, so normalise both to the label baseline. Yellow dome 65.3×62.3 at (57.0, 565.3), to the left |
| 8 | Total pill | x 173.4–542.8, y 594.9–635.9 (radius 20.5). 'TOTAL' at x 192, baseline 621.3. Value right-aligned to 529.1, baseline 619.6 |
| 9 | Terms row | 'PAYMENT TERMS' DM Sans Bold 13 at x 71.8, baseline 677.2. Bullets are 3.8pt dots at x 208.1–211.9 with centres at y 661.4 / 675.7; text at x 220.8, baselines 665.6 / 679.8. Purple asterisk 29.3 at (495.1, 653.2) |
| 10 | Rule 2 | x 67.4–536.0, y 703.3, 1.5pt |
| 11 | Footer (3 columns) | A at x 67.4: 'PAYMENT METHOD' baseline 732.1, bank lines at 750.8 / 768.7. B: icons at x 218.5–229.7, text at x 239.0, baselines 734.6 / 753.0. C: icons at x 380.7–392.2, text at x 414.2, baselines 733.6 / 752.0 |
| 12 | Watermarks | Pale-pink asterisks 352.4 wide at (255.5, −51.4) and (−58.0, 511.4), clipped by the page |
## Items table
**Columns (A4):**
- SERVICE: left-aligned. Header text at x 192.0, body at x 196.0, i.e. 15–19 inside the pill.
- QTY: centred at ≈354.7.
- RATE: centred at ≈421.3.
- AMOUNT: centred at ≈495.1.
- The block's right edge is 539.4.
**Header:** a #530895 pill, 46.6 tall (radius 23.3). DM Sans Bold 14, white, uppercase, baseline centred at 405.6.
**Rows:**
- Each row is its own full pill, 32.7 tall (radius ≈16.35), stacked flush.
- Fills: row 1 grey #EDEDE8 (flat), row 2 yellow #FFD80B, row 3 grey.
- Body text: DM Sans Regular 11.83 #060605, baseline about 18.1 below the row top.
- No borders.
- For N rows, alternate grey and yellow, with yellow on even rows.
**Voiced adaptation:**
- Right-align RATE and AMOUNT; the source centres them.
- Widths: qty 40, rate 90, amount 100. That leaves only about 125 for the description when the block starts at x 176.9.
- Recommend a full-width variant (x 59.5–539.4, dropping the 71.5pt asterisk) when there are more than 3 items or any description wraps.
- Multi-line rows become rounded rectangles: keep radius 16.35 and use paddingVertical 9.
## Totals and payment
**Source:**
- Totals (x 192–522, no fill): 'SUBTOTAL' and 'TAX' with values. Labels Gotham Bold 13; values Gotham Book 13, right-aligned at 522; 30.8 pitch.
- Then a purple TOTAL pill, 41 tall (radius 20.5), with #FFF4F4 text: label left at 192, value right-aligned to 529.
- AMOUNT DUE is a big purple DM Sans Bold 32.74 number at left (y 300–355).
- A terms row with bullets, and a footer with PAYMENT METHOD (bank name, account number) and contact rows with icons.
- There is no signature.
**Voiced:**
- Rows SUBTOTAL, optional DISCOUNT, then CGST x% plus SGST x% (or IGST x%), in the same label/value style with 25 pitch, then the TOTAL pill.
- Amount in words: below the pill, DM Sans Regular 10 #231F20, across x 192–529, at most 2 lines.
- AMOUNT DUE shows the total.
- PAYMENT TERMS bullets come from notes, one bullet per line, plus 'Payment due within N days.'
- PAYMENT METHOD: bank name, A/c name, A/c no., IFSC, branch and UPI, at 10–11pt with 14 pitch. Footer column A grows to about 90 tall.
- Signature (no slot): a right-aligned block above rule 2, replacing the 29pt asterisk at 495–524 × 653–682. Image up to 120×40 plus 'Authorised Signatory' in DM Sans 9, or the computer-generated note.
## Decoration and images
All vector shapes can be reproduced with react-pdf Svg.
**8-arm asterisk.** One path, used at 4 sizes, normalised to a 0–100 box:
`M100 41.7H69.9L91.1 20.6 79.7 8.9 58.3 30.1V0H41.7V30.1L20.6 8.9 8.9 20.6 30.1 41.7H0V58.3H30.1L8.9 79.4 20.6 91.1 41.7 69.9V100H58.3V69.9L79.4 91.1 91.1 79.4 69.9 58.3H100Z`
Each arm is 16.5% of the size; reference rounds the corners by about 0.2%. Instances:
- #530895, 71.5pt, at (57.0, 397.7).
- #530895, 29.3pt, at (495.1, 653.2).
- #FFF7F7, 352.4pt, at (255.5, −51.4) and at (−58.0, 511.4).
**Yellow dome/bell, #FFD80B, even-odd fill:**
`M89.7,565.3 C107.7,565.3 122.3,579.9 122.3,597.9 L103.3,597.9 C114.5,603.1 122.3,614.4 122.3,627.6 L57.0,627.6 C57.0,614.4 64.8,603.1 76.1,597.9 L57.0,597.9 C57.0,579.9 71.7,565.3 89.7,565.3 Z`
**Pills** are plain rounded rectangles with radius = height/2:
- Invoice-number pill: #E5E5E5, 218.1×33.2.
- Client-name pill: #FFD80B, 146.6×33.2.
- Header: 362.5×46.6.
- Rows: 362.5×32.7.
- Total: 369.4×41.
**Icons, about 11pt:** phone and mail are filled #231F20 circles with knocked-out glyphs; globe and map-pin are #060605 outline glyphs. Use @carbon/icons path data (Phone, Email, Earth, Location) inside Svg.
**Bullets:** 3.8pt #231F20 circles.
**Raster images.** 17 RGB images with soft masks, about 300 dpi, sizes from 125×69 to 756×69 plus 572×196 and 399×137 px. They are the reference design's 'shadow' effect (30% opacity, about 0 offset, about 1–2pt blur) under:
- BILLED TO, the client name and client lines (black).
- INVOICE NO and AMOUNT DUE (black).
- Table header labels (brown).
- Subtotal, tax and total (coral).
- Payment method and bank lines (black).
Each of these strings is also drawn a second time at opacity 0, which is why this PDF's text extracts garbled. Do not reproduce the shadows; react-pdf has no text-shadow. If wanted, fake them with a duplicate Text at opacity .3, offset 0.6pt.
## Mapping to Voiced fields
- **'Northline Group'** → vendorName, DM Sans Bold 17.77.
- **Logo (no slot):** an Image with maxHeight 34 to the left of the name at x 59.5 (the name shifts right), or replace the name when the logo is a wordmark.
- **Vendor address, phone, email, PAN, GSTIN** (no header slot):
- GSTIN and PAN as 2 small lines (DM Sans Regular 9) under the vendor name, x 59.5–300, y ≈108–135.
- Phone and email in footer column B.
- Address (at most 2 lines) and UPI in footer column C, in place of the website.
- **INVOICE NO pill** → 'INVOICE NO : {number}'. The pill sizes to its text, with its right edge fixed at 530.9.
- **'Invoice' wordmark** → keep 'Invoice'. When taxType ≠ none, add 'TAX', either in the pill ('TAX INVOICE NO : …') or as a DM Sans Bold 14 caption above the wordmark. 'Tax Invoice' at 94pt would be about 450 wide and cannot fit; auto-fit it to about 61pt if one line is required.
- **'BILLED TO:' and the yellow pill** → clientName.
- **Client lines** → clientAddress. Add 'GSTIN: …' and 'Place of supply: …' (no slot) as extra lines.
- **AMOUNT DUE** → total.
- **Dates** → 'INVOICE DATE : {invoiceDate}' and 'DUE DATE : {dueDate}' as two explicit lines. PO number (no slot): a third line 'PO : …'.
- **Table** → items. HSN/SAC (no slot): a 9pt grey second line under the description.
- **SUBTOTAL / TAX** → subtotal, CGST/SGST/IGST and discount.
- **TOTAL pill** → total. Amount in words (no slot): under the pill.
- **PAYMENT TERMS** → notes.
- **PAYMENT METHOD** → bank details. IFSC, branch and UPI have no slot and become extra lines.
- **Signature (no slot):** see totals_and_payment.
## Overflow risks
- **Vendor name vs INVOICE NO pill.** The name runs into the pill at x 312.8 (y 77–110). Cap it at about 240 wide and let it wrap; 2 lines end around y 125, still above BILLED TO at 148.
- **INVOICE NO pill.** Long series numbers grow it leftward. Keep at least 12 between it and the vendor name.
- **Client block vs wordmark.**
- The client name pill can be at most about 170 wide, because the wordmark starts at x 238.2; longer names wrap to 2 lines.
- Client lines (10.16pt) must be capped at about 165 wide beside the 94pt wordmark.
- A long address extends below the wordmark, so rule 1 must follow the taller of the client block and the wordmark in the flow.
- **AMOUNT DUE.** At 32.74pt, '₹12,34,567.00' is about 230 wide, but only about 210 is free before the right-aligned dates at x 291. Shrink it automatically (for example to 26pt when longer than 9 characters) or stack the dates under it.
- **Dates.** The source's first date line already wraps badly ('… 2030 DUE'); render it as two fixed lines.
- **Items.**
- The description column is only about 125–140 wide, so wrapping is frequent.
- More than 3–5 items push totals, terms and footer onto page 2, and the header pill must repeat there.
- **Footer.** 6 bank lines plus contacts can pass the bottom margin; the source footer ends at 772. Use 10pt text and a 2-column bank grid.
## US Letter adaptation
US Letter is 612 × 792.
- **Width.** Keep the x margins (≈55–59.5 left, ≈53 right) and widen the rules, pills and table by 16.5. Right-anchored elements (INVOICE NO pill, wordmark, dates, total pill) move right by 16.5.
- **Watermarks.** Anchor them to the corners and let the page clip them:
- Top-right: right −12.4, top −51.4.
- Bottom-left: left −58, bottom −21.5.
- **Height (−50pt).**
- Compress the 98pt gap between rule 1 and the table (the amount-due band) to about 75.
- Keep row pills at 32.7.
- Shrink the totals-to-terms gap from 40 to 24.
- Use a bottom padding of 30.
- **Configurable margins.** Map them to Page padding; pills and rows are in the flow, so they follow the margins. The asterisk and dome stay in the left gutter only when the left margin leaves at least about 115 beside the table. Otherwise switch to the full-width table variant and hide them.
## react-pdf notes
**Page:** style {backgroundColor:'#FEFFEE', paddingTop:70, paddingHorizontal:55, paddingBottom:40}. Watermarks are the first children: fixed, absolutely positioned Svgs.
**Header:** a row with space-between. The vendor block has maxWidth 240. The pill is a View with backgroundColor '#E5E5E5', borderRadius 16.6, height 33.2, paddingHorizontal 20, justifyContent center.
**Client and wordmark:** a row.
- Left: a View 175 wide holding the label, the pill (alignSelf 'flex-start', borderRadius 16.6, paddingHorizontal 12) and the lines.
- Right: Text with fontSize 94, letterSpacing −7.44, lineHeight 1, textAlign right, flex 1.
**Rules:** a View with height 1.5 and backgroundColor '#060605'.
**Items:**
- Container View with marginLeft 122, or 0 for the full-width variant.
- Header: a View with backgroundColor '#530895', borderRadius 23.3, height 46.6, flexDirection row, alignItems center, paddingHorizontal 15.
- Rows: Views with wrap={false}, borderRadius 16.35, minHeight 32.7, and backgroundColor '#FFD80B' for even rows or '#EDEDE8' for odd rows.
- Use flat colours, not opacity, because opacity also fades the text.
**Asterisks and dome:** `<Svg viewBox='0 0 100 100' width={71.5} height={71.5}><Path d=… fill='#530895'/></Svg>`, absolutely positioned inside the items/totals section wrappers, which are position relative.
**Totals:** the label/value rows, then the total pill, with wrap={false} and minPresenceAhead.
**Footer:** a row of 3 Views (A 150 wide, B 160 wide, C flex 1). Icons are 11×11 Svgs.
**Fonts:** DM Sans 400/700, Montserrat 400/700 (the Gotham substitute) and the display font at 700. Use fontFamily arrays with 'IBM Plex Sans' for ₹.
## Similarity to other templates
Sibling of 13.pdf. They are from the same reference family, with the same placeholder data ('Northline Group', 'Amy Fletcher', 'Intelligent Bank'), the same DM Sans plus Gotham type system, the same raster 30% shadow effects, and the same SERVICE/QTY/RATE/AMOUNT table with SUBTOTAL/TAX/TOTAL.
The composition is different: pills and asterisks on cream here, versus a split grey column on blush in 13. Keep both and share the data-mapping code. Not similar to 1–11, format.pdf or the in-app template.
## Implementation notes
Built as `src/pdf/templates/northline/` (the family component shared with Citrus Split: `Layout.tsx` dispatches to `PurpleLayout.tsx`, the plans live in `plan.ts`, tokens in `purple.tsx` and `variant.ts`) on the decor primitives in `src/pdf/decor/` (`Asterisk`, `Underlay`, `CircledIcon`). Measured against `12.pdf` with `npm run templates:compare -- purple-pop` (`/tmp/invoice-c5/compare-purple-pop.png`, left reference, right ours; `--numbers` prints both sets of baselines).
**Matches the design (measured, A4):** wordmark x 238.8 and 291.6 pt wide (reference 238.2 / 292.2), right edge on the design's 530.4; INVOICE NO pill right edge 530.9, grey, text tracked 0.13 em; client label, pill and lines at x 67.1; AMOUNT DUE at 71.8; table pills, 46.6 pt header (radius 23.3), 32.7 pt rows (radius 16.35), alternating #EDEDE8 / #FFD80B, flush; SUBTOTAL / TOTAL labels at x 192 and values ending at 522 / 529; the TOTAL pill 369.4 x 41 at x 173.4; rule 1 at 64.8..533.4; footer columns at x 67.4 / 218.5 / 380.7.
**Intentional deviations, and why:**
- **Fonts.** Open Sauce One is not bundled: the wordmark is Poppins 800 at -0.08 em. Gotham is Montserrat with every size multiplied by 0.96 at token definition (`gotham()` in `variant.ts`; a real Gotham imported over the licensed name would need the tokens at factor 1). DM Sans is bundled 400/700 and has the rupee sign; Montserrat and Poppins carry it too, so no amount depends on the Plex fallback.
- **Wordmark and 'Tax'.** The wordmark always reads "Invoice". A tax invoice adds a tracked 14 pt "TAX" above it (right-aligned), not a 61 pt "Tax Invoice". Negative tracking leaves the last glyph's ink 7.5 pt past the line end, so the wordmark Text carries that much right padding; the ink then ends on the design's edge and the overflow audit stays quiet.
- **Vertical rhythm.** The design is generous and a Voiced invoice has more lines (GSTIN, place of supply, tax rows, amount in words, signature, bank lines), so the gaps between blocks are elastic (client 35 to 10, rules 18 to 6, amount band 18 to 6, table 20 to 8, totals 14 to 6; Letter gives back its 49.9 pt from them first). A one-page invoice of two or three plain items fits A4; a tax invoice with a signature and notes puts items and totals on page 1 and the closing block (terms, signature, footer) alone at the foot of page 2, which is how the other families behave too. When the gaps can give back the whole overflow only to keep the totals with the items, the plan does exactly that.
- **Table width.** The narrow table beside the asterisk (x 176.9, the design) is used only for up to three items whose descriptions take at most two lines in its ~100 pt column; everything else uses the full width (x 59.5..539.4) on every page and drops the asterisk, as the spec recommends. The 71.5 pt asterisk is page 1 only and a child of `FirstPageHeader`, positioned from the plan; the dome sits in the left gutter of the totals block and travels with it.
- **Pills.** Header and rows are Views with a flat fill and a radius, laid out through `TableRows`'s `pill` option (cells inset 15 pt inside the pill, the pill indented from the container's left edge). No opacity, so text is never faded.
- **Watermarks.** The two pale asterisks are `decor.underlay` items (fixed, painted before the flow, anchored to the page corners; the off-page part is cut away by the Svg's viewBox so the box always lies on the page, Letter included). No keep-outs: nothing printed over them needs a clear ground. the reference design's drop-shadow rasters are dropped.
- **Dates.** "INVOICE DATE : ..." and "DUE DATE : ..." are explicit right-aligned lines (a PO line is added when present); the size shrinks to 10 pt before the amount (32.74 pt, shrinking to 20) loses its 150 pt.
- **Supplier lines.** GSTIN and PAN sit under the vendor name (9 pt, through `Slot.Gstin`); phone, e-mail and address are footer columns B and C with icons (solid discs for phone and e-mail, a ring for the address). The website column has no Voiced data and is dropped; UPI is a bank line.
- **Signature and terms.** The signature takes the 29 pt asterisk's place at the right of the terms row (190 pt wide, so the computer-generated line fits in one line). Terms are one bullet per note line; with more than the signature's height they flow as their own block before the closing block, each bullet unbreakable and the label held with the first.
- **Logo.** No reference placeholder: opt-in (picker toggle, `prefs.logo.show`), off by default, in the area left of the vendor name (at most 200 x 34 from `slots.optin.ts`). A mark shifts the name right; a wordmark replaces it. The no-logo fallback is the vendor name in DM Sans Bold.
- **Margins.** Top 70 (the design's pill starts at 77; 7 pt go to the page-2+ continuation band), bottom 40 plus the footer reserve, 54.8 left, 52.48 right; floors 36 / 36 / 24 / 36.
+202
View File
@@ -0,0 +1,202 @@
# Template 13: Citrus Split (`citrus-split`)
Source: `templates/reference/13.pdf`. All coordinates are in points from the top-left of the reference page (595.5 x 842.25). Measured from the PDF content streams during design analysis.
## Style
A modern split layout on blush pink: a heavy 'INVOICE' wordmark, a light-grey right column, an orange SERVICE header cell and TOTAL bar, orange labels, an icon contact list and faint orange halftone-dot chevrons.
## Fonts
- **Now Bold:** 'INVOICE' at 48pt (47.94), letterSpacing −3.89pt (−0.081em), #060605.
- **DM Sans Bold:**
- Vendor name 17.13pt in 2 lines, lineHeight 1.0.
- Meta labels 12pt #F96300: 'Invoice No :', 'Invoice Date :', 'Due Date :', 'Amount Due:'.
- Amount-due value 12.36pt.
- 'BILLED TO:' 12.4pt.
- Client name 14.87pt #F96300, caps.
- 'PAYMENT METHOD' and 'PAYMENT TERMS' 12pt #F96300.
- Tagline 13.23pt, right-aligned.
- **DM Sans Regular:** meta values 12pt.
- **Gotham Bold:** 'SERVICE' 15.17pt, white, letterSpacing 2.25pt (0.15em). 'QTY RATE AMOUNT' 14.17pt. 'SUBTOTAL', 'TAX', 'TOTAL' and the total value 14.7pt #231F20.
- **Gotham Book:**
- Item rows 12pt.
- Subtotal value 12.7pt, tax value 12.07pt.
- Client lines 10.5pt (17.75 pitch).
- Bank lines 13.77pt (21 pitch).
- Terms 12pt: 16.5 pitch within a bullet, 20.8 between bullets.
- Contact list 12.77pt, about 23–27 pitch.
**Bundling:** DM Sans 400/700 (OFL). Gotham becomes Montserrat 400/700 or DM Sans. Check the licence for 'Now' (Alfredo Marco Pradil) before shipping; otherwise substitute Montserrat ExtraBold or Black at −0.08em. ₹ falls back to IBM Plex Sans.
## Colours
- Page: #FFF7F7 (blush). Right column panel: #F1F1F1. Zebra row: #F1F1F1. Numeric header band: #FFFFFF.
- Orange fill #FF9220: SERVICE header cell, TOTAL bar, phone icon.
- Orange text #F96300: labels and client name. Icon orange: #F39322.
- Text: #060605, #231F20, and #000000 for client lines. 'SERVICE' is white on orange.
- Separator: #FFF7F7, 1.5pt, drawn over the grey panel.
- Logo placeholder: #231F20, #E63631, #FF9220.
- Halftone dots: gradient #FF9220 to #F2F2F2 (switching at 14%), at 9% opacity. On the blush page that is ≈#FFEEE4 fading to invisible.
- Reference shadow images (not reproduced): coral #FF524B and black at 30%.
## Layout
Units are pt with the origin at the top-left. For text, y is the **baseline**.
Two zones: a left column at x ≈73–298.8, and a grey right column at x 298.8–535.6 (236.8 wide, right margin 59.9).
| # | Region | Position / content |
|---|---|---|
| 1 | Grey panel | #F1F1F1 at x 298.8–535.6, from y 0 to 547.0. A second grey block at x 297.7–534.5, y 773.2–842.25 |
| 2 | Header | 'INVOICE' Now Bold 48 at x 73.3, baseline 109.7 (cap top ≈75.6). Logo 35×33 at (337.4, 74.5). Vendor name DM Sans Bold 17.13 at x 405.8, baselines 85.8 / 102.9 |
| 3 | Meta row (4 cells) | Labels at baseline ≈197: x 87.9 'Invoice No :', x 174.1 'Invoice Date :', x 315.5 'Due Date :', x 423.7 'Amount Due:'. Values at baseline ≈219 (source 217.5–222.3) at the same x |
| 4 | Table header | y 240.3–287.5 (47.2 tall). Orange cell x 84.2–298.6 with 'SERVICE' at x 115.9, baseline 269.1. White band x 298.6–536.0 with 'QTY' / 'RATE' / 'AMOUNT' centred at 347.5 / 405.8 / 479.2, baseline 269.9 |
| 5 | Rows | Pitch ≈39.3–40. Baselines ≈321, 360, 400.5. Row 2 band #F1F1F1 at x 84.0–536.0, y 337.9–377.2. Descriptions at x ≈100 |
| 6 | Separator | x 293.4–533.9, y 420.4, 1.5pt #FFF7F7 |
| 7 | Totals (in panel) | 'SUBTOTAL' at x 320.9, baseline 447.7, value right-aligned at ≈505. 'TAX' at baseline 477.7. TOTAL bar #FF9220 at x 299.8–534.4, y 496.5–543.7; 'TOTAL' at x 316.5, baseline 524.4; value right-aligned at 505.0 |
| 8 | Left column under table | 'BILLED TO:' at x 85.6, baseline 459.7. Name at baseline 483.1. Lines at 508.6 / 526.3 / 544.1. 'PAYMENT METHOD' at x 81.7, baseline 585.6. Bank lines at x 83.2, baselines 618.2 / 639.2. 'PAYMENT TERMS' at baseline 684.3. Bullet dots at x 89.2–93.0, text at x 102.1, baselines 711.2 / 732.0 / 748.5 |
| 9 | Right column under panel | Tagline right-aligned to 522.5, baselines 576.6 / 594.6. Contact list text right-aligned to 495.4, icons at x 507–524, baselines 655.7, 678.9, 705.7, 728.9 |
| 10 | Halftone chevrons | Bottom-left (−126 to 172.5 × 516 to 842), bottom-centre (142.5 to 412 × 572 to 842), top-right (477 to 595.5 × 0 to 178) |
## Items table
**Header (split):**
- The 'SERVICE' cell is filled #FF9220 (x 84.2–298.6, 47.2 tall). Text is Gotham Bold 15.17, white, letterSpacing 2.25, left at x 115.9 (inset ≈31.7).
- The numeric headers sit on a white band (x 298.6–536) in Gotham Bold 14.17 #060605.
**Body:**
- The description column (x 84–298.6) sits on the blush page, text at x ≈100 in Gotham Book 12 #060605.
- QTY, RATE and AMOUNT sit inside the grey panel, centred at 346.4 / 404.7 / 478.7.
- Rows are about 40 pitch with no borders.
- Zebra: every second row gets a #F1F1F1 band across x 84–536. It only shows on the left part, since the panel is already grey.
- After the last row, a 1.5pt #FFF7F7 separator runs across the panel.
**Voiced adaptation:**
- Right-align RATE and AMOUNT at ≈522; the source centres both the values and the AMOUNT header.
- The panel's 236.8 width splits as qty 45, rate 90, amount 100.
- Put the unit under the rate in 8.5pt.
- Descriptions wrap within 214.
- HSN/SAC goes on a 9pt grey line under the description.
## Totals and payment
**Source:**
- Totals are in the grey panel: SUBTOTAL and TAX label/value pairs. Labels Gotham Bold 14.7 at x 320.9; values Gotham Book ≈12.7, right-aligned at ≈505; 30 pitch.
- Then the orange TOTAL bar (234.6 × 47.2) with dark #231F20 text: 'TOTAL' at left (316.5) and the amount at right (505).
- Below the panel: a decorative tagline 'PROFESSIONAL SERVICES / BILLING SUMMARY.' and the icon contact list (phone, email, web, address).
- Left column: BILLED TO, PAYMENT METHOD (bank and account number), PAYMENT TERMS bullets.
- The bottom-right grey block (297.7–534.5 × 773.2–842.25) is empty.
**Voiced:**
- Rows SUBTOTAL, optional DISCOUNT, then CGST x% plus SGST x% (or IGST x%). Use 12.5pt labels and values so 4–5 rows fit. The TOTAL bar is unchanged.
- Amount in words replaces the tagline: DM Sans Bold 11, right-aligned, max width 222.
- PAYMENT METHOD: bank name, A/c name, A/c no., IFSC, branch, UPI. Use 11pt with 15 pitch instead of 13.77 with 21.
- PAYMENT TERMS come from notes.
- Signature goes in the bottom-right grey block: image up to 150×40 plus 'Authorised Signatory' in DM Sans 9, or the computer-generated note.
## Decoration and images
**Plain rectangles:** the grey column panel and the bottom grey block.
**Logo placeholder:** 3 arcs (a #231F20 crescent and #E63631 and #FF9220 arcs), 35×33 at (337.4, 74.5). Replace with the vendor logo.
**Contact icons** (orange, about 14–17pt, right of the text at x 507–524), drawn with Carbon icon paths in Svg:
- Phone: solid #FF9220 disc with a knocked-out handset, 15.5pt.
- Email: ring plus envelope, #F39322, 17pt.
- Web: globe glyph, 14.3pt.
- Address: ring plus pin, 13.9pt.
**Bullets:** 3.8pt #060605 dots.
**Halftone chevrons:**
- Each field is 76 circles on a staggered lattice: 19 rows of 4 dots, with rows shifting diagonally to form a '>' chevron.
- Diameters grow from 9.8 to 22.5 local units towards the middle row, then shrink again.
- Scale per field:
- Bottom-left: 0.842 pt per unit; row pitch 19, in-row spacing 38, diameters 8.3–19.
- Bottom-centre: 0.761 pt per unit; diameters 7.5–17.
- Top-right: 0.478 pt per unit, rotated 180°; diameters 4.7–10.8.
- Fill is a linear gradient #FF9220 to #F2F2F2 at opacity 0.09.
- Optional decoration: draw 228 Svg Circles, or omit.
**Raster images:** 15 RGB images with soft masks (141×77 to 590×202 px). They are the reference design's shadow effect: coral under the header labels and totals, black under the client and bank text. Do not reproduce them; the same strings also have an invisible duplicate text copy underneath.
## Mapping to Voiced fields
- **Logo slot** (35×33; allow up to 60×40) → vendor logo.
- **Vendor name** → 2-line DM Sans Bold 17.13 at x 405.8, at most 130 wide.
- **'INVOICE'** → INVOICE. For TAX INVOICE, 48pt would need about 300, but only 225 is free before the panel. Either stack a DM Sans Bold 14 #F96300 'TAX' above it, or auto-fit to about 34pt.
- **Meta row** → number, invoiceDate, dueDate, and total as Amount Due.
- **PO (no slot):** under the meta row on the left, or under BILLED TO.
- **BILLED TO block** → clientName and clientAddress. Add 'GSTIN' and 'Place of supply' (no slot) as extra lines. Recommend moving the block above the table, as a band at y ≈230, so it stays on page 1.
- **Contact list** (right, with icons) → vendorPhone and vendorEmail. Add vendorAddress, right-aligned and multi-line. GSTIN/PAN (otherwise no slot) replace the web line, or go under the vendor name in 9pt.
- **Table** → items. HSN/SAC (no slot): 9pt under the description.
- **Totals** → subtotal, discount, CGST/SGST/IGST as extra rows, and total. Amount in words takes the tagline slot.
- **PAYMENT METHOD** → bank details. IFSC, branch and UPI become extra lines.
- **PAYMENT TERMS** → notes.
- **Signature** → bottom-right grey block.
## Overflow risks
- **BILLED TO below the table.** Structurally, BILLED TO sits below the items table (baseline 459.7). With more than about 3 items it drifts down or onto page 2. Move it above the table for Voiced.
- **Grey panel height.** In the source the panel stops at y 547, just under TOTAL. With a variable number of items it must become a full-height stripe (x 298.8–535.6) fixed on every page, instead of a fixed-height rectangle. The separate bottom block can then go, since the stripe covers it.
- **Vendor name area.** Only about 130 wide (405.8–535.6). Long names run to 3 or more lines; shrink to 14pt. The logo and name share x 337–535.
- **Meta cells.** Each cell is 86–112 wide. A series number like 'BB/2025-26/0012' at 12pt is about 95 wide and overflows cell 1 (87.9–174.1). Use 10.5pt or widen cell 1.
- **Numeric columns.** They are confined to the 236.8-wide panel. ₹ amounts of '₹12,34,567.00' or more at 12pt (≈85 wide) are close to the 100 limit.
- **Left column under the table.** Six bank lines at 13.77pt with 21 pitch take 126, plus notes. The left stack (billed-to, payment, terms) can exceed the page; reduce to 11pt.
- **Contact list.** Text is right-aligned with icons, so long email or address lines grow leftward (fine to about x 300). A multi-line address needs textAlign right with the icon on the first line.
- **App footer.** It would sit over the bottom-left dot chevron (visually fine) and under the grey block on the right.
## US Letter adaptation
US Letter is 612 × 792.
- **Width.** Keep the grey column anchored to the right margin (59.9), which puts it at x 315.3–552.1 on Letter. Give the extra 16.5 to the left column (description, BILLED TO).
- **Halftone chevrons:** anchor them to their corners and edges.
- **Height (−50pt).**
- Shrink the header-to-meta gap (≈88) to 65.
- Shrink rows from 40 to 34.
- Move the signature from the bottom grey block into the flow above the bottom margin; on Letter that block would be only 19 tall.
- **Configurable margins.** Page padding drives the flow. Derive the stripe's x from the right margin (left = page width − right margin − 236.8), never from fixed A4 numbers.
## react-pdf notes
**Page:** style {backgroundColor:'#FFF7F7', paddingTop:70, paddingLeft:73, paddingRight:59.9, paddingBottom:40}.
**Stripe:** `<View fixed style={{position:'absolute', top:0, bottom:0, right:59.9, width:236.8, backgroundColor:'#F1F1F1'}}/>` as the first child. Check how absolute offsets interact with page padding.
**Table rows:**
- Each row is flexDirection 'row'. The first column's width is stripe-left minus content-left (≈214.6 including 16 of padding). The three numeric columns total 236.8, so numbers always sit inside the stripe.
- Zebra rows get backgroundColor '#F1F1F1' across the whole row.
- Header row: the SERVICE View has backgroundColor '#FF9220', the numeric part '#FFFFFF', height 47.2.
**After the table:** a two-column row, each block with wrap={false}.
- Left: BILLED TO (if not moved above), PAYMENT METHOD, PAYMENT TERMS.
- Right, 236.8 wide: totals; the TOTAL bar (a View 47.2 tall, backgroundColor '#FF9220'); amount in words; contact list; signature.
**Icons:** a 16×16 Svg with a Circle of radius 8, fill '#FF9220', plus a white Carbon glyph path.
**Halftone (optional):** a fixed Svg of Circles filled '#FFEEE4' (pre-blended). Skip it for flattened exports to keep files small.
**Fonts:** DM Sans 400/700, Montserrat 400/700 (the Gotham substitute), and Now Bold or its substitute. Use fontFamily arrays with IBM Plex Sans as the ₹ fallback.
## Similarity to other templates
Sibling of 12.pdf: the same reference family, data and type system with a different layout. It is distinct from everything else in the set and from format.pdf and the in-app template; the split grey column is unique to it. It can share the 12/13 data-mapping code, though its pill and label components differ.
## Implementation notes
Built as `src/pdf/templates/northline/` (the family component shared with Purple Pop: `Layout.tsx` dispatches to `CitrusLayout.tsx`, the plans live in `plan.ts`, tokens in `citrus.tsx` and `variant.ts`) on the decor primitives in `src/pdf/decor/` (`Underlay`, `Halftone`, `CircledIcon`). Measured against `13.pdf` with `npm run templates:compare -- citrus-split` (`/tmp/invoice-c5/compare-citrus-split.png`, left reference, right ours).
**Matches the design (measured, A4):** INVOICE at x 73.3 with its baseline at 109.3 (reference 109.7); the stripe at x 298.8..535.6; meta labels at x 87.9 / 174.1 / 315.5 / 423.7; table header 47.2 pt, orange SERVICE cell x 84.2..298.6 with the text at 115.9, numeric headings on white; description text at x 99.9 (reference 100.1); SUBTOTAL / TAX labels at x 320.9 with values ending at 505; TOTAL bar 235 x 47.2 with TOTAL at 316.5 and its value ending at 505; BILLED TO block at x 85.6 beside the totals; PAYMENT METHOD / TERMS at x 81.7, bullets at 89.2 / 102.1.
**Intentional deviations, and why:**
- **Fonts.** Now Bold is drawn in Jost 700 at -0.081 em (Jost has no rupee sign and is used for that one word only; 'INVOICE' comes out 176 pt wide against the reference design's 189.7). Gotham is Montserrat at 0.96 of the design size (`gotham()`); DM Sans for labels, names and the words line.
- **The stripe.** A full-height fixed underlay on every page (not the reference design's 547 pt rectangle plus a bottom block), anchored to the right margin: `x = page width - right margin - 236.8`. On Letter the extra 16.5 pt go to the left column; Narrow and Normal margins move it with the margin. The three halftone fields are anchored to their page corners and edges, drawn as Svg circles over the stripe, each dot already in the gradient's colour at its x (no gradient object), at 9% opacity.
- **Table.** The three numeric columns add up to exactly 236.8 pt (66.8 / 80 / 90), so every number is printed on the grey whatever the data; the description column takes the rest (214.6 on A4, 231 on Letter). RATE and AMOUNT are right-aligned (the design centres them), the rate's unit goes under it when the short form does not fit, HSN/SAC is a 9 pt line under the description. Zebra rows span the full row width and only show on the left.
- **BILLED TO.** Beside the totals (the design) when the whole invoice fits one page; above the table, under the title, when it does not, so page 1 always names the client. `planCitrus` decides from the nominal height against the page and the gaps' slack.
- **Totals.** 12.5 pt labels (Gotham 700, drawn 0.96) and values; each row is one line (the value column takes only the width the amounts need, so 'TAXABLE VALUE' and 'UTGST @ 14%' never wrap). Amount in words sits under the TOTAL bar in the design's tagline slot, right-aligned; the reverse-charge line follows it.
- **Contacts.** Phone, e-mail and address with icons (a solid orange disc for the phone, rings for e-mail and address), right-aligned with the icons at the stripe's right edge; GSTIN and PAN replace the web line, without an icon. The signature goes under them in the panel column (the design's empty bottom block).
- **Row pitch.** 12.15 pt padding gives the design's 39.3 pt row; on Letter (and when an invoice is a little over a page) the row padding is the elastic part, down to 8 pt, together with the gaps.
- **Header.** Logo in the measured slot (a mark keeps the name, a wordmark replaces the lockup), the name 2+ lines at 17.13 pt; with no logo the name stands at the logo's x, vertically centred on the title. A tax invoice puts a tracked orange "TAX" above the wordmark.
- **Margins.** Top 58, bottom 40 plus the footer reserve, 73.3 left, 59.68 right; floors 36 / 36 / 24 / 36. The standard footer sits over the bottom-left halftone and the stripe.
- **Terms.** Beside the bank lines in the closing block; more than 110 pt of them flow as their own block across the width, each bullet unbreakable.
+170
View File
@@ -0,0 +1,170 @@
# Reference template analysis
Per-template specifications measured from the 13 reference PDFs in `templates/reference/`. They drive the template implementations in `src/pdf/templates/`.
| # | id | Name | Style |
|---|---|---|---|
| 1 | `marble` | Marble | Airy editorial invoice |
| 2 | `highlighter` | Highlighter | Bold form-style invoice |
| 3 | `monolith` | Monolith | Stark monochrome Poppins |
| 4 | `linea` | Linea (could also be labelled 'Original': this is the vendor's own format.pdf design) | Airy, all-charcoal minimalism |
| 5 | `monogram` | Monogram | The Linea grid restyled in Open Sans |
| 6 | `serenity` | Serenity | A boutique-stationery look |
| 7 | `crimson-grid` | Crimson Grid | Bold corporate boxed grid |
| 8 | `cobalt-stripe` | Cobalt Stripe | Friendly cobalt-blue business style |
| 9 | `slate-band` | Slate Band | Modern geometric |
| 10 | `teal-swoosh` | Teal Swoosh | A clean corporate Montserrat layout |
| 11 | `tangerine-ledger` | Tangerine Ledger | A crisp business ledger with Poppins Bold display text and Inter body |
| 12 | `purple-pop` | Purple Pop | A playful Memphis style on cream: an oversized purple 'Invoice' wordmark; purple, yellow and grey pill-shaped rows and labels; 8-arm asterisk motifs (solid purple, plus huge pale-pink watermarks); and a yellow dome shape |
| 13 | `citrus-split` | Citrus Split | A modern split layout on blush pink: a heavy 'INVOICE' wordmark, a light-grey right column, an orange SERVICE header cell and TOTAL bar, orange labels, an icon contact list and faint orange halftone-dot chevrons |
## Group notes
CONVENTIONS
- All coordinates are pt from the top-left of the reference design's 595.5 x 842.25 visible page. The MediaBox is [0 7.83 595.5 850.08]; its y-offset is already subtracted.
- Reference lays out in 794 x 1123 CSS px, so pt = px x 0.75.
- react-pdf 'A4' is 595.28 x 841.89 (at most 0.4pt smaller), so use size 'A4' directly. 'LETTER' is 612 x 792.
- To get the top of a line box from a baseline, subtract ascent x size: Poppins 1.05em, Lustria 0.955em, Now 0.92em, Montserrat 0.968em.
EXTRACTION FACTS
- All text is real Type0/Identity-H text with ToUnicode maps, so it is copy-pasteable.
- Letter-spacing is done by per-glyph positioning (Tc=0), so the em values given are measured gaps.
- Colours come from the rg/RG operators, sizes from Tf x CTM, and positions from the full form-XObject CTM chain.
- Nothing in 1-3 uses blend modes or soft masks. The alpha-0 rectangles are tagged-PDF artefacts. Both selectable-text and rasterised ('flattened') export are therefore straightforward.
FONTS AND LICENSING (bundling all fonts in the xbps)
- Templates 1-3 need Lustria, Poppins 400/500/700 and Montserrat 400. All are OFL; the name tables confirm it. Ship static TTF files plus OFL.txt.
- The react-pdf v4 docs say only TTF and WOFF are supported and variable fonts do not work. Google ships Montserrat as a variable font, so take the static instances. The existing code already registers .woff.
- 'Now' (template 1) is proprietary ('All rights reserved', fsType 8). Substitute Poppins Regular, or Jost as an optional extra.
- The same caution applies across the set. Gotham (12/13) is commercial (H&Co). Check the name tables of TheBigmaker, BuongiornoRastellino, SimpleSerenityScript and BDScript before bundling. DM Sans, Inter, Open Sans and Open Sauce One are OFL.
- Rupee sign: verified in the local IBM Plex Sans and Montserrat TTF cmaps. Always render money with fontFamily [templateFont, 'IBM Plex Sans'].
- Reference stock rasters (template 1's marble, 6's paper texture) must not be extracted and bundled.
SET-LEVEL DUPLICATES
- 4.pdf and format.pdf are the same design: 9 of 10 inflated streams are byte-identical, and the odd one out is metadata. It is not a new template.
- 7 and 11 share one layout (red vs orange accent).
- 9 and 10 share one body (slate header band vs teal swoosh corners).
- 12 and 13 share content and fonts but have different layouts.
- 4, 5 and 6 are one family: tracked-caps title, ISSUED TO / PAY TO, ruled table, right-hand totals, script signature.
CROSS-CUTTING IMPLEMENTATION RULES
(1) Only decorations are positioned absolutely; data always flows.
(2) Every text container inside a flexDirection 'row' needs flexShrink 1 (or flex 1, or an explicit width). Yoga's default flexShrink is 0. The vendor text View in the existing InvoiceDocument header (inside the row next to the logo, parent maxWidth 58%) has none of these, which is the likely cause of the reported vendor-address/INVOICE overlap.
(3) Headers are a row: a title with auto-fitted size, plus a meta block with a fixed max width.
(4) Item rows use wrap={false}; table heads use minPresenceAhead; closing blocks (totals, words, signature) use wrap={false}.
(5) Margins come from settings as Page padding; decorations ignore margins. Absolute positions are relative to the page edge, as the existing footer already assumes.
(6) types.ts has no client phone/email, vendor website or HSN/SAC. Drop the reference slots for those or add the fields. dueDate already exists.
SUGGESTED BUILD ORDER (by effort): 3 (closest to the existing component), then 2, then 1.
**4.pdf vs format.pdf: the same design, not just a similar one.** The two files differ only in metadata:
- Info/XMP title: 'a sample title' (format.pdf) vs 'reference' (4.pdf).
- Export timestamps: 15:51:57 vs 19:01:44 UTC on 2026-10-03.
- Reference design ID in /Keywords: ID vs ID (both share account/brand ID ID).
- xref offsets and the trailer /ID.
Page content streams, font subsets, coordinates and the structure tree are byte-identical: 1500 lines each, 10 differing blocks, all of them metadata. So format.pdf is the same reference page exported from a separate design, and it still carries the reference design's placeholder data (Daniel Gallego / Richard Sanchez / Borcele Bank). The vendor's 'original invoice' is this reference template with nothing customised. All 13 template PDFs are pages of a single reference design (ID, exported 19:01:43-46 UTC).
**The current in-app src/pdf/InvoiceDocument.tsx loosely adapts that design; it is not a copy.** Differences:
1. **Type and colour:** the app uses IBM Plex Sans 9.5pt in several greys (#161616, #393939, #525252, #6f6f6f, #e0e0e0). The design uses Montserrat 10pt in one colour, #3D3B3A, with 0.05em tracking.
2. **Header:** the app puts logo plus vendor block (18pt name, address, contact, PAN, GSTIN; maxWidth 58%) on the left, and an 18pt semibold right-aligned 'INVOICE'/'TAX INVOICE' with meta rows (maxWidth 40%) on the right. That shared row is where the reported address-vs-INVOICE overlap happens. 4.pdf has no vendor block at all. It has a 38pt Montserrat Regular 'INVOICE' (0.2em tracking) top-left with a 0.75pt rule running to the right edge, and its meta block sits much lower, opposite ISSUED TO.
3. **Client block:** the app adds a 1pt #e0e0e0 divider and a grey 7.5pt 'Billed To' label. The design uses a 10pt bold 'ISSUED TO:'.
4. **Bank block:** the app's 'Pay To' sits bottom-left beside the signature. In 4.pdf, 'PAY TO:' sits directly under ISSUED TO.
5. **Table:** the app has a 1.4pt top border, a 7.5pt grey header, a 0.6pt divider under every row, and flex 5:2:2:2 columns with right-aligned numbers. The design has no top border, a 10pt caps header, one 0.75pt rule, borderless 21.46pt rows, a closing rule, and centred unit price and qty.
6. **Totals:** the app uses a 250pt box on the right with a 13pt semibold total over a 1.4pt rule. The design uses a full-width SUBTOTAL row and a right-anchored Tax/TOTAL pair, all 10pt, with the total set apart only by bold weight.
7. **Extra fields in the app:** amount in words (italic), notes, PAN/GSTIN, place of supply, PO, discount, CGST/SGST/IGST, the signature image or 'computer-generated' note, and a fixed 'Generated with Voiced' footer.
8. **Signature:** 4.pdf's signature is a 60pt script-font name.
**Recommendation.** A faithful build of 4.pdf gives the vendor the look of the format he actually chose, so it is not redundant with the in-app template. Keep the existing template as 'Voiced Standard' (IBM Plex), since the request was for the reference templates to be added alongside it. Add 4.pdf as 'Linea' (or label it 'Original') and consider making it the vendor's default. Whether to keep both is a product decision for the parent.
**4, 5 and 6 are one template family.** They share the grid, rules and spacing. 5 swaps Montserrat for Open Sans and adds a monogram; 6 adds the texture, script and stamp and shifts the grid right. Implement one NeutralTemplate driven by a theme object: fonts, title style, letterSpacing (0.5 or 1), row padding (4 or 5.7), header slot, background and column offset. Share the InfoRow, ItemsTable, Totals and BottomRow components across the three.
**Applies to all three:**
- **Vendor identity:** none of the three has a supplier block, yet supplier name, address and GSTIN are mandatory on a GST invoice. Each entry names a slot in its own row, so long addresses push content down instead of overlapping the title.
- **Tax rows:** each design shows 'Tax 10%' (the rate only). Voiced needs CGST+SGST or IGST rows with amounts, plus the amount in words.
- **Fonts to bundle** in public/fonts (and so in the xbps):
- Montserrat 400/700 (the local v9.000 has ₹), Open Sans 400/500/700, and optionally Poppins 400. All are OFL; ship OFL.txt with them.
- BuongiornoRastellinoCyr-Script, TheBigmaker and SimpleSerenityScript are reference-licensed and must not be extracted. Use the vendor's logo and signature images, SVG outlines for 6's decorative word (after confirming the reference licence allows that reuse), or OFL scripts.
- Keep IBM Plex Sans as the second entry in fontFamily arrays so ₹ always renders.
- **Signatures:** use the image or the computer-generated note. Never a typed name in a script font.
- **Page size:** the reference design's 595.5x842.25 is A4 within 0.2-0.4pt, so the coordinates map 1:1 onto react-pdf A4.
- **How these numbers were measured:**
- I decoded the content streams with a small standard-library PDF interpreter (transform tracking, ToUnicode, CID widths).
- All coordinates are in pt from the top-left and accurate to about ±0.1pt.
- Letter-spacing comes from the gaps between glyphs, since reference positions every glyph separately.
- 6.pdf's background colour comes from decoding the JPEG's DC coefficients as YCbCr.
Method: I used a read-only content-stream interpreter of my own (tracks CTM and text matrices, nested form XObjects, ToUnicode maps, /W widths and ExtGState alpha) and checked against the rendered pages. Coordinates are accurate to about ±0.3pt. Every y value is measured from the page top: top = 850.08 - y_pdf. the reference design's unit chain: the page cm is 0.24 with a y-flip, and inner forms scale by 3.126178, so 1 reference px = 0.7503pt. That makes Tf 16 = 12pt and Tf 14.66 = 11pt; one form also applies a 3.888 scale (Tf 13.33 -> 12.44pt).
Shared geometry: 7, 8 and 9 all use a 59.55pt side margin (exactly 10% of the width, about 21mm), giving 476.4pt of content width. That is the natural default for a margin setting.
Transparency: only 9 uses alpha. Its transparency-group XObjects are drawn at ca 0.5 and 0.53, and the inner /G3 (ca 1) does not cancel that, so flatten to #EEEFF1 and #C1C5CE. All three contain invisible ca=0 rectangles tagged /Image for accessibility; they are not decorations.
Raster content: 7, 8 and 9 have none (vector shapes and live text only). Selectable-text export is react-pdf's native output. A flattened export means rasterising each rendered page (for example pdf.js to a 300 dpi canvas, then an image-only PDF), which works the same whichever template is chosen.
Fonts these three need: Poppins 400/600/700, Open Sans 400/700, Montserrat 400/500/700/800. All are SIL OFL. Bundle them as static TTFs in the app assets and in the xbps; react-pdf loads font files by URL, never through fontconfig, and cannot use WOFF2 or variable fonts. BD Script (template 8) is reference-licensed, so do not ship it; the signature slot becomes an image.
₹ (U+20B9) coverage:
- Montserrat 9.0, installed locally: confirmed with fc-list ':charset=20b9'.
- Poppins and Open Sans are not installed here, so check them after downloading.
- Fallback: render the ₹ glyph in IBM Plex Sans (already registered) through a nested Text with that fontFamily.
react-pdf 4.9.0 behaviour, checked in node_modules (layout 5.2.0, textkit 7.0.1, render):
1. splitNodes puts fixed children into both the current and the next page fragment. Page-level fixed bands and footers therefore repeat, and a fixed header row inside a table View should repeat on continuation pages. Confirm the latter in one render.
2. renderLine places the baseline at line top + ascent, with no half-leading, and any extra lineHeight goes below the line. So Text top = baseline - ascent x size (Poppins 1.05, Open Sans 1.069, Montserrat 0.968).
3. The style props maxLines and textOverflow: 'ellipsis' are supported.
4. Page sizes: 'A4' = [595.28, 841.89], 'LETTER' = [612, 792].
5. Font.registerHyphenationCallback exists; the default inserts hyphens.
6. textkit bug in scalePositions: the isLast check (i === positions.length) is never true, so letterSpacing is also added after the last glyph. Right-aligned tracked text shifts left by the letterSpacing, and centred text by half of it. Compensate with a negative marginRight.
7. wrap={false}, minPresenceAhead, and widows/orphans (default 2) are available.
Guarding against the header-overlap bug in every template: build the header as a flex row. The title gets flexShrink 0; the vendor or brand block gets flex 1 with minWidth 0 so it wraps; nothing in the header is absolutely positioned. None of 7/8/9 puts the vendor address in the header (7 has it in a body column, 8 in the footer, 9 in the From block), which is worth keeping.
Voiced fields missing from all three: due date, PAN, both GSTINs, place of supply, HSN/SAC, discount, the CGST/SGST/IGST split (9 has no tax row at all), amount in words, IFSC/branch/UPI, and a real signature. Build shared sub-components (PartyBlock, TaxSummaryRows, AmountInWords, BankBlock, SignatureBlock, and a ContactFooter using Carbon icon paths) and have each template only theme them.
How the full set groups (from a quick visual look at all 13 plus format.pdf, to avoid duplicate work across workers):
- 7 ≈ 11: ruled grid; accent, font and header mirroring differ.
- 9 ≈ 10: same body; header artwork differs.
- 4 = format.pdf: visually identical but different bytes, i.e. a re-export.
- 5 and 6 are siblings of 4/format.pdf: ISSUED TO plus INVOICE NO/DATE/DUE DATE, a minimal ruled table and a script signature. 6 adds a raster paper texture, a vertical script 'invoice' and a round stamp.
- 12 and 13 share the Northline Group content model (DM Sans/Gotham) with different looks.
- 1 (letter-spaced serif, raster marble bands), 2 (yellow ruled blank form with an icon list), 3 (monochrome Poppins with rules; the closest to the in-app InvoiceDocument.tsx) and 8 have no near-duplicate in the set.
About 9 parametric components would cover all 13.
**Method.** I ran 10–13 through a stdlib-only PDF content-stream interpreter: CTM 0.24/−0.24 (reference px to pt), nested form XObjects, ExtGState alpha and /LW. All numbers are page pt with a top-left origin on the 595.5 × 842.25 reference page (≈A4; the MediaBox y-offset of 7.83 is removed). For text, y is the baseline. Font sizes come from Tf × CTM and are rounded. Letter-spacing is the measured gap between glyphs, which equals the reference design's letter-spacing setting. Colours drawn with opacity are also given as flat pre-blended values.
**reference quirks.**
- Every glyph is positioned individually with no kerning data, so expect width differences of about ±2% under react-pdf's kerning.
- 12 and 13 draw every 'effect' string twice (an invisible copy at opacity 0 plus the visible one) and add 30% raster shadow images, which garbles their text extraction. A vector react-pdf re-implementation gives clean selectable text, which suits the copy-pasteable export. A flattened export should rasterise the rendered page rather than reuse reference effects.
**Template families in the set:**
- 9 ≈ 10: Montserrat band layout.
- 7 ≈ 11: grid ledger.
- 12 ~ 13: Northline family (same data, different layouts).
Build one component per family with accent and decor props. None of 10–13 duplicates format.pdf or the current IBM Plex in-app template; 11 is the closest structurally.
**Fonts.** Per fc-list, none of the template fonts are installed on this machine, so all must be bundled in the app (and therefore in the .xbps and the NSIS installer).
- Needed: Montserrat 400/700/800, Inter 400/600/700, Poppins 700, DM Sans 400/700.
- Display faces Open Sauce One Bold (12) and Now Bold (13): check the licences or substitute.
- Gotham Book/Bold (12, 13) is commercial: substitute Montserrat 400/700 or DM Sans.
- Use static TTF instances, not variable fonts or WOFF2.
**₹ glyph.** react-pdf supports fontFamily arrays as a per-glyph fallback (confirmed in the diegomura/react-pdf docs, font-family-fallback example). Use, for example, fontFamily: ['Montserrat','IBM Plex Sans']. IBM Plex Sans is already bundled and contains U+20B9 (confirmed locally with fc-list :charset=20b9).
**Fields Voiced needs that these templates lack.** No template has slots for GSTIN/PAN lines, place of supply, HSN/SAC, the CGST+SGST vs IGST split, amount in words, IFSC/branch/UPI, a signature or computer-generated note, or a PO number. 10 has no subtotal or tax row at all, and 10 and 11 have no due date. Each template entry says where to put these.
**Money columns.** Right-align them everywhere; the sources centre them in 10, 12 and 13. Give amounts at least 100pt at 11–12pt type, and put the rate unit on a second small line.
**Multi-page.**
- Decorations: fixed, absolute, anchored to page edges or corners.
- Everything else is flow.
- Item rows: wrap={false}.
- Totals and payment blocks: wrap={false} plus minPresenceAhead.
- The table header repeats on pages after the first via a fixed element whose render function checks pageNumber > 1.
**Header overlap.** Every new template keeps the vendor address out of the title's row, or gives it a fixed-width wrapping column. Apply the same rule when fixing the current template's header overlap.
**Page sizes.** True A4 is 595.28 × 841.89 versus the reference design's 595.5 × 842.25, a negligible difference. US Letter is 612 × 792 (16.5 wider, 50.25 shorter), so every template needs about 50pt of vertical compression (row pitch, gaps) or extra pages.
**To check.** Confirm how react-pdf positions absolute children relative to Page padding. The existing footer assumes offsets from the page edge; corner decorations with configurable margins depend on the same behaviour.
+51
View File
@@ -0,0 +1,51 @@
# voiced.invoice.v1
A neutral export of issued invoices for software Voiced has no direct integration with. Choose invoices in the
History list (checkboxes) or open one, then use "Export selected (voiced.invoice.v1 JSON)" or "(line items CSV)".
The code is `src/lib/invoiceExportV1.ts`; `src/lib/__golden__/invoice-v1.golden.json` is a full example.
## Rules
- **Money is exact.** Every amount appears twice: `<name>_paise` (an integer) and `<name>` (a decimal string with two
places, e.g. `"7310.10"`). Nothing is a float. Rates (percent) are decimal strings such as `"9"` or `"6.25"`.
- **Deterministic.** The same invoices give the same bytes: fixed key order, no timestamps, input order kept.
- **JSON file** is an array with one object per invoice; each object carries `"schema": "voiced.invoice.v1"`.
- Cancelled invoices are included with `"cancelled": true` (their figures are kept; filter them out of sums).
- An unregistered supplier's plain "Invoice" has `document_type: "invoice"`, all tax heads zero and no supplier GSTIN.
## Invoice object
| Key | Meaning |
| --- | --- |
| `schema`, `number`, `issue_date`, `due_date` | `YYYY-MM-DD` dates; `due_date` is null when unset |
| `document_type` | `tax_invoice` or `invoice` |
| `status`, `cancelled`, `cancelled_at`, `cancel_reason` | `status` is `issued` or `cancelled` |
| `currency`, `po_number` | always `INR` |
| `supplier` | `name`, `address`, `gstin` (null if none), `pan`, `gst_registration`, `state {code, name}`; frozen at issue |
| `client` | `name`, `gstin`, `gst_category`, `address {text, line1, line2, city, pincode, state}`, `place_of_supply {code, name}` |
| `items[]` | `line`, `description`, `hsn_sac`, `quantity` (decimal string), `unit`, `rate`, `taxable_value` (each with `_paise`) |
| `tax` | `cgst`, `sgst`, `utgst`, `igst`, each `{rate, amount_paise, amount}`; heads that do not apply are zero |
| `totals` | `subtotal`, `discount`, `taxable_value`, `tax`, `total` (each with `_paise`), `amount_in_words` |
| `reverse_charge`, `notes` | |
| `payments[]` | `date`, `amount` (cash), `tds`, `mode`, `reference` (amounts with `_paise`) |
Notes on the fields:
- **Items.** `taxable_value` of a line is the line amount before the invoice-level discount. `totals.taxable_value` is
`subtotal - discount`, the base the tax is charged on. A fixed-amount line has `quantity "1"` and `unit null`.
- **UTGST.** When the supplier is in a union territory without a legislature (state codes 04, 26, 31, 35, 38) the second
head is reported under `utgst` and `sgst` is zero.
- **Client details.** The name, GSTIN, printed address and place of supply are frozen on the invoice. The structured
address parts and `gst_category` come from the client's saved record at export time (null or derived from the GSTIN
when the invoice has no saved client).
- **Supplier snapshot.** Only name, address, GSTIN, PAN, registration and state are exported; logo paths, e-mail and
phone are not.
## Line-items CSV
One row per line item, UTF-8 with a byte order mark and CRLF line ends; text cells that a spreadsheet would read as a
formula get a leading apostrophe. Columns: `invoice_number, issue_date, due_date, document_type, status, cancelled,
currency, supplier_name, supplier_gstin, client_name, client_gstin, client_gst_category, place_of_supply_code,
place_of_supply, reverse_charge, line, description, hsn_sac, quantity, unit, rate, taxable_value, invoice_discount,
invoice_cgst, invoice_sgst, invoice_utgst, invoice_igst, invoice_total`. The `invoice_*` amounts are filled on the first
row of each invoice only, so summing a column never counts an invoice twice.
+9 -1
View File
@@ -12,11 +12,11 @@
"@carbon/react": "^1.117.0", "@carbon/react": "^1.117.0",
"@carbon/styles": "^1.116.0", "@carbon/styles": "^1.116.0",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@napi-rs/canvas": "*",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@tauri-apps/api": "^2.12.1", "@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-dialog": "^2.8.1",
"@tauri-apps/plugin-opener": "^2.7.0", "@tauri-apps/plugin-opener": "^2.7.0",
"fontkit": "2.0.4",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-is": "^19.3.0" "react-is": "^19.3.0"
@@ -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",
+12 -1
View File
@@ -13,7 +13,16 @@
"tauri": "tauri", "tauri": "tauri",
"app:dev": "tauri dev", "app:dev": "tauri dev",
"app:build": "tauri build --features custom-protocol", "app:build": "tauri build --features custom-protocol",
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol" "app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'",
"fonts:verify": "node scripts/fonts/verify.mjs",
"fonts:manifest": "node scripts/fonts/build-manifest.mjs",
"logo:measure": "node scripts/logo/measure-placeholders.mjs",
"logo:sheet": "node scripts/logo/contact-sheet.mjs",
"decor:script-word": "node scripts/decor/script-word.mjs",
"decor:icons": "node scripts/decor/icons.mjs",
"thumbnails": "node scripts/thumbnails.mjs",
"templates:compare": "node scripts/templates/compare.mjs"
}, },
"dependencies": { "dependencies": {
"@carbon/icons-react": "^11.89.0", "@carbon/icons-react": "^11.89.0",
@@ -24,6 +33,7 @@
"@tauri-apps/api": "^2.12.1", "@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-dialog": "^2.8.1",
"@tauri-apps/plugin-opener": "^2.7.0", "@tauri-apps/plugin-opener": "^2.7.0",
"fontkit": "2.0.4",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-is": "^19.3.0" "react-is": "^19.3.0"
@@ -34,6 +44,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",
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
#
# Rebuilds the fontconfig cache for the bundled fonts after install and upgrade.
# The stock x11-fonts trigger (enabled by font_dirs) only runs fc-cache when
# mkfontdir and mkfontscale are installed, so it is not relied on here.
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
case "$1" in
post)
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
fi
;;
esac
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# xbps action script for 'voiced': $1 = action, $2 = pkgname, $3 = version.
#
# Refreshes the fontconfig cache after the bundled fonts are removed, so stale
# entries for /usr/share/fonts/voiced do not linger.
# Never fails the transaction: a missing fc-cache or a cache error is ignored.
case "$1" in
post)
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f /usr/share/fonts/voiced >/dev/null 2>&1 || :
fi
;;
esac
exit 0
+22 -1
View File
@@ -54,6 +54,27 @@ install_icon src-tauri/icons/icon.png 512x512
install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md" install -Dm644 README.md "$STAGE/usr/share/doc/voiced/README.md"
# Bundled fonts: one subdirectory per family under /usr/share/fonts/voiced, plus
# each family's OFL text. Only *.ttf files are installed (not manifest.json, and
# not the legacy woff/woff2 files that sit directly in public/fonts).
for dir in public/fonts/*/; do
family="$(basename "$dir")"
for ttf in "$dir"*.ttf; do
install -Dm644 "$ttf" "$STAGE/usr/share/fonts/voiced/$family/$(basename "$ttf")"
done
install -Dm644 "${dir}OFL.txt" "$STAGE/usr/share/licenses/voiced/${family}-OFL.txt"
done
# xbps action scripts. xbps-create packs INSTALL and REMOVE found at the root of
# the destdir into the archive (verified: they appear as ./INSTALL and ./REMOVE
# members and are excluded from files.plist), and xbps runs them with $1 set to
# the action. They refresh the fontconfig cache for /usr/share/fonts/voiced.
# Fallback if a future xbps-create stops carrying them: font cache refresh would
# then rely on fontconfig regenerating its cache on first use, or on the xbps-src
# template (which uses the same scripts).
install -Dm755 packaging/void/INSTALL "$STAGE/INSTALL"
install -Dm755 packaging/void/REMOVE "$STAGE/REMOVE"
echo "==> Creating $PKGVER.$ARCH.xbps" echo "==> Creating $PKGVER.$ARCH.xbps"
# Kept outside Vite's `dist/` output directory, which is wiped on every build. # Kept outside Vite's `dist/` output directory, which is wiped on every build.
OUT="$ROOT/build/void" OUT="$ROOT/build/void"
@@ -71,7 +92,7 @@ mkdir -p "$REPO"
-l "proprietary" \ -l "proprietary" \
-H "https://git.bongbetic.com/xavierk/Voiced" \ -H "https://git.bongbetic.com/xavierk/Voiced" \
-m "Xavier Karma <xavier@bongbetic.com>" \ -m "Xavier Karma <xavier@bongbetic.com>" \
-D "libwebkit2gtk41>=2.40_1" \ -D "libwebkit2gtk41>=2.50_1 fontconfig>=0 xdg-utils>=0 hicolor-icon-theme>=0 desktop-file-utils>=0" \
-t "office finance" \ -t "office finance" \
--compression zstd \ --compression zstd \
"$STAGE" "$STAGE"
+25 -3
View File
@@ -3,7 +3,9 @@
# Canonical Void Linux package definition, to be built with xbps-src from a # Canonical Void Linux package definition, to be built with xbps-src from a
# void-packages checkout: # void-packages checkout:
# #
# cp -r packaging/void/template void-packages/srcpkgs/voiced/template # mkdir -p void-packages/srcpkgs/voiced
# cp packaging/void/template packaging/void/INSTALL packaging/void/REMOVE \
# void-packages/srcpkgs/voiced/
# cd void-packages && ./xbps-src pkg voiced # cd void-packages && ./xbps-src pkg voiced
# #
# Notes: # Notes:
@@ -12,6 +14,13 @@
# tarball, mirror it, or drop the sources into the xbps-src masterdir. # tarball, mirror it, or drop the sources into the xbps-src masterdir.
# * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace # * `checksum=SKIP` avoids pinning a hash that cannot be computed here; replace
# it with the real sha256 once the tarball is published. # it with the real sha256 once the tarball is published.
# * INSTALL and REMOVE (next to this file) are xbps action scripts that run
# `fc-cache -f /usr/share/fonts/voiced` after install and removal. xbps-src
# picks them up automatically from srcpkgs/voiced/INSTALL and
# srcpkgs/voiced/REMOVE and packs them into the package; the template does
# not install them. Copy them next to the template (see the commands above).
# The stock x11-fonts trigger enabled by font_dirs does not run fc-cache
# unless mkfontdir and mkfontscale are installed, hence these scripts.
# * If the build machine is offline, pre-populate the npm cache and Cargo # * If the build machine is offline, pre-populate the npm cache and Cargo
# registry in the masterdir before building. # registry in the masterdir before building.
# #
@@ -20,8 +29,8 @@ version=0.1.0
revision=1 revision=1
archs="x86_64*" archs="x86_64*"
hostmakedepends="rust nodejs pkg-config" hostmakedepends="rust nodejs pkg-config"
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel openssl-devel" makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
depends="libwebkit2gtk41" depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
short_desc="Offline invoicing app by Bongbetic" short_desc="Offline invoicing app by Bongbetic"
maintainer="Xavier Karma <xavier@bongbetic.com>" maintainer="Xavier Karma <xavier@bongbetic.com>"
license="custom:proprietary" license="custom:proprietary"
@@ -29,6 +38,9 @@ homepage="https://git.bongbetic.com/xavierk/Voiced"
distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz" distfiles="https://git.bongbetic.com/xavierk/Voiced/archive/v${version}.tar.gz"
checksum=SKIP checksum=SKIP
compression="zstd" compression="zstd"
# Fonts install under a voiced/ subdirectory so they never conflict with the
# files of Void's own font-ibm-plex-ttf and noto-fonts-ttf packages.
font_dirs="/usr/share/fonts/voiced"
do_build() { do_build() {
npm install --include=dev --no-audit --no-fund npm install --include=dev --no-audit --no-fund
@@ -51,4 +63,14 @@ do_install() {
vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \ vinstall "${wrksrc}/src-tauri/icons/icon.png" 644 \
usr/share/icons/hicolor/512x512/apps voiced.png usr/share/icons/hicolor/512x512/apps voiced.png
vdoc "${wrksrc}/README.md" vdoc "${wrksrc}/README.md"
# Bundled fonts: one subdirectory per family, plus its OFL text.
# manifest.json and any other non-font file are deliberately skipped.
for dir in "${wrksrc}"/public/fonts/*/; do
family="$(basename "${dir}")"
for ttf in "${dir}"*.ttf; do
vinstall "${ttf}" 644 "usr/share/fonts/voiced/${family}"
done
vlicense "${dir}OFL.txt" "${family}-OFL.txt"
done
} }
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
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.
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2011, Matthew Desmond (http://www.madtype.com | mattdesmond@gmail.com),with Reserved Font Name Lustria.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+588
View File
@@ -0,0 +1,588 @@
{
"version": 1,
"families": [
{
"family": "DM Sans",
"folder": "dm-sans",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "DMSans-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "fae5cfcefb70eaa3bb8fe15d04bcc182c3684af679441d09149c4e4b28f8b909",
"fsType": 0,
"upm": 1000,
"ascent": 992,
"descent": -310,
"lineGap": 0,
"capHeight": 700,
"xHeight": 504,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "DMSans-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "f43632dacd8210c8b546cfe7746a4996811c617e6d3d93eea28698016016c1fa",
"fsType": 0,
"upm": 1000,
"ascent": 992,
"descent": -310,
"lineGap": 0,
"capHeight": 700,
"xHeight": 504,
"hasRupee": true,
"hasTabularNums": false
}
]
},
{
"family": "IBM Plex Mono",
"folder": "ibm-plex-mono",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "IBMPlexMono-Italic.ttf",
"weight": 400,
"style": "italic",
"sha256": "8ebe04c8c6cc82f0be19896ddc61d9935cdd0f027b0173c1945b8d247d7dfc2a",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 516,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexMono-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "fe11304a5fe956d5744e9b6a246cc83d90425245e75a62230044966ca96a7f50",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 516,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexMono-SemiBold.ttf",
"weight": 600,
"style": "normal",
"sha256": "c9417148ce13f8fa7d2d5c9180bbc141f72aa0d814ffeb280f6904dc2b1bbd7a",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 516,
"hasRupee": true,
"hasTabularNums": false
}
]
},
{
"family": "IBM Plex Sans",
"folder": "ibm-plex-sans",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "IBMPlexSans-Light.ttf",
"weight": 300,
"style": "normal",
"sha256": "2218b5f3f1fc9d3a793343f45c3be5ee7eae9584a7a52391bb8cf2d37622b3e0",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 512,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexSans-Italic.ttf",
"weight": 400,
"style": "italic",
"sha256": "a9c6ef9942c49e49d11e11a6dacc0b3a087978757e9b22a06b8ac22a6400fb15",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 516,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexSans-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 516,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexSans-Medium.ttf",
"weight": 500,
"style": "normal",
"sha256": "331c8639d7598b2cde62a911a71db195e30cb655cd6bdf2e324a7e984955f907",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 520,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexSans-SemiBold.ttf",
"weight": 600,
"style": "normal",
"sha256": "a20caf8286023a6a7a85e40b1d2a4ae9fc3e3b1f9eda8f4c542dd4986af67bb1",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 522,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "IBMPlexSans-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f",
"fsType": 0,
"upm": 1000,
"ascent": 1025,
"descent": -275,
"lineGap": 0,
"capHeight": 698,
"xHeight": 525,
"hasRupee": true,
"hasTabularNums": false
}
]
},
{
"family": "Inter",
"folder": "inter",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "Inter-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82",
"fsType": 0,
"upm": 2048,
"ascent": 1984,
"descent": -494,
"lineGap": 0,
"capHeight": 1490,
"xHeight": 1118,
"hasRupee": true,
"hasTabularNums": true
},
{
"file": "Inter-SemiBold.ttf",
"weight": 600,
"style": "normal",
"sha256": "78a843fade9d4612a5567302fb595b56976eb5fcebf4fea5a5912d638bafcde3",
"fsType": 0,
"upm": 2048,
"ascent": 1984,
"descent": -494,
"lineGap": 0,
"capHeight": 1490,
"xHeight": 1118,
"hasRupee": true,
"hasTabularNums": true
},
{
"file": "Inter-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "288316099b1e0a47a4716d159098005eef7c0066921f34e3200393dbdb01947f",
"fsType": 0,
"upm": 2048,
"ascent": 1984,
"descent": -494,
"lineGap": 0,
"capHeight": 1490,
"xHeight": 1118,
"hasRupee": true,
"hasTabularNums": true
}
]
},
{
"family": "Jost",
"folder": "jost",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "Jost-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "8b458be71d5236e58ee7a9660ef6197116ea78c4120fae94305674f5029423e6",
"fsType": 0,
"upm": 1000,
"ascent": 1070,
"descent": -375,
"lineGap": 0,
"capHeight": 700,
"xHeight": 460,
"hasRupee": false,
"hasTabularNums": true
},
{
"file": "Jost-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "1c0db6c9f209c5f1f9ebfddee69dcb7c13b682a3f0a21c6ec9797ae1309ddfa1",
"fsType": 0,
"upm": 1000,
"ascent": 1070,
"descent": -375,
"lineGap": 0,
"capHeight": 700,
"xHeight": 460,
"hasRupee": false,
"hasTabularNums": true
}
]
},
{
"family": "Lustria",
"folder": "lustria",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "Lustria-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "8b50753779d151674dcc74bdf9cdde1e788d8fb2b9ace8fb183a0def0f7361ce",
"fsType": 0,
"upm": 1000,
"ascent": 955,
"descent": -327,
"lineGap": 0,
"capHeight": 704,
"xHeight": 504,
"hasRupee": false,
"hasTabularNums": false
}
]
},
{
"family": "Montserrat",
"folder": "montserrat",
"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",
"folder": "noto-sans-devanagari",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "NotoSansDevanagari-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "306b53ecfb182a504dd8a7446093c316387d2fd8dc350d0792ed1753fe0996cd",
"fsType": 0,
"upm": 1000,
"ascent": 896,
"descent": -408,
"lineGap": 0,
"capHeight": 622,
"xHeight": 536,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "NotoSansDevanagari-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "3ad8362a06271814869838dcc3d161b13c9fb97681b627af1f7f283ea9387d56",
"fsType": 0,
"upm": 1000,
"ascent": 896,
"descent": -408,
"lineGap": 0,
"capHeight": 622,
"xHeight": 536,
"hasRupee": true,
"hasTabularNums": false
}
]
},
{
"family": "Noto Sans Kannada",
"folder": "noto-sans-kannada",
"license": "OFL-1.1",
"licenseFile": "OFL.txt",
"fonts": [
{
"file": "NotoSansKannada-Regular.ttf",
"weight": 400,
"style": "normal",
"sha256": "9ad74dc64838c6855b96f671fc08e425a58921b9d0c71712ea79c328a27e6e38",
"fsType": 0,
"upm": 1000,
"ascent": 809,
"descent": -540,
"lineGap": 0,
"capHeight": 690,
"xHeight": 551,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "NotoSansKannada-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "3db1507b3f856185fc7c00dc0addfd4797a8ea6955b86d1d21fa659cc9d5e849",
"fsType": 0,
"upm": 1000,
"ascent": 809,
"descent": -540,
"lineGap": 0,
"capHeight": 690,
"xHeight": 552,
"hasRupee": true,
"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
},
{
"file": "Poppins-Medium.ttf",
"weight": 500,
"style": "normal",
"sha256": "90373e7d838d32468438fc3e152dca0bdb12edcab99ea639f158790b1ba1fd05",
"fsType": 0,
"upm": 1000,
"ascent": 1050,
"descent": -350,
"lineGap": 100,
"capHeight": 697,
"xHeight": 551,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "Poppins-SemiBold.ttf",
"weight": 600,
"style": "normal",
"sha256": "d3bf1bdaf0550e83da9ac0b1d1d9fe6db086835a83aa28578e609a394b9a0286",
"fsType": 0,
"upm": 1000,
"ascent": 1050,
"descent": -350,
"lineGap": 100,
"capHeight": 701,
"xHeight": 554,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "Poppins-Bold.ttf",
"weight": 700,
"style": "normal",
"sha256": "983676516167748b74de6f4771fb384c664fd913acb8b471122ecacf5da5ea6c",
"fsType": 0,
"upm": 1000,
"ascent": 1050,
"descent": -350,
"lineGap": 100,
"capHeight": 705,
"xHeight": 558,
"hasRupee": true,
"hasTabularNums": false
},
{
"file": "Poppins-ExtraBold.ttf",
"weight": 800,
"style": "normal",
"sha256": "f2ab17c1a63a0ecc12c2461848fc8a469395e3cd2d641803e889c643d9f958e1",
"fsType": 0,
"upm": 1000,
"ascent": 1050,
"descent": -350,
"lineGap": 100,
"capHeight": 709,
"xHeight": 561,
"hasRupee": true,
"hasTabularNums": false
}
]
}
]
}
Binary file not shown.
Binary file not shown.
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.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/)
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.
+94
View File
@@ -0,0 +1,94 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/)
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.
+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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
// Extracts the SVG path data of a few Carbon icons into src/pdf/decor/icons.generated.ts, so the PDF code
// carries plain path strings and never imports React icon components (src/pdf stays free of the DOM).
// Run: npm run decor:icons
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const ICONS = { phone: "Phone", email: "Email", location: "Location" };
const out = {};
for (const [key, name] of Object.entries(ICONS)) {
const src = readFileSync(path.join(ROOT, "node_modules/@carbon/icons-react/es", `${name}.js`), "utf8");
const viewBox = /viewBox: "([^"]+)"/.exec(src)?.[1];
const paths = [...src.matchAll(/jsx\("path", \{ d: "([^"]+)" \}\)/g)].map((m) => m[1]);
if (!viewBox || paths.length === 0 || /jsx\("(circle|rect|polygon)"/.test(src)) throw new Error(`${name}: unexpected icon structure`);
out[key] = { viewBox, paths };
}
const body = `// Generated by \`npm run decor:icons\` (scripts/decor/icons.mjs). Do not edit by hand.
// Path data of IBM Carbon icons, Apache-2.0 (see ICONS-NOTICE.md).
export interface IconArt {
/** "minX minY width height", as in the Carbon source. */
viewBox: string;
paths: string[];
}
export const ICONS = ${JSON.stringify(out, null, 2)} as const satisfies Record<string, IconArt>;
export type IconName = keyof typeof ICONS;
`;
writeFileSync(path.join(ROOT, "src/pdf/decor/icons.generated.ts"), body);
console.log(`icons.generated.ts: ${Object.keys(out).join(", ")}`);
+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. the reference design'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 (the reference design's word runs from y 780 to y 410). */
const INK_LENGTH = 362;
/**
* Ms Madi's strokes are heavier than the reference design'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 the reference design'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 the reference design'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.
+5
View File
@@ -0,0 +1,5 @@
# frappe/erpnext v15 plus the india_compliance app (installed per site, not globally)
FROM docker.io/frappe/erpnext:v15.121.6
USER frappe
RUN bench get-app --skip-assets --branch version-15 https://github.com/resilient-tech/india-compliance.git \
&& ls -1 apps > sites/apps.txt || true
+148
View File
@@ -0,0 +1,148 @@
# ERPNext live end-to-end (podman)
Runs Voiced's ERPNext integration against real ERPNext v15 sites: one plain site and one with India Compliance.
Everything is rootless podman (`podman` and `podman-compose`, never docker). All names are prefixed `voiced-erp`.
| File | Purpose |
|---|---|
| `compose.yml` | Lean stack: MariaDB, one Redis, backend (gunicorn on `127.0.0.1:8088`), one worker. Project name `voiced-erp`. |
| `Containerfile.ic` | `frappe/erpnext:v15.121.6` plus the `india_compliance` app files (`version-15` branch). |
| `setup.sh` | Builds the image, starts the stack, creates both sites, prepares them, writes `.env.a` and `.env.b`. |
| `setup_site.py` | Runs inside the container: company, accounts, address, items, integration user and keys. |
| `../../src-tauri/src/integrations/erpnext/live_tests.rs` | The `#[ignore]` Rust tests. |
## Reproduce
```bash
cd scripts/erpnext-e2e
./setup.sh # first run pulls ~2 GB of images and takes about 10 minutes
```
`setup.sh` creates two sites that one backend serves by Host header:
| Site | URL | Contents | Env file |
|---|---|---|---|
| `127.0.0.1` | `http://127.0.0.1:8088` | ERPNext only | `.env.a` |
| `localhost` | `http://localhost:8088` | ERPNext + India Compliance | `.env.b` |
Both get the company "Voiced Test Co" (abbreviation `VTC`, INR, India, fiscal year 2026-27), the output tax accounts
`Output Tax CGST|SGST|UTGST|IGST - VTC` (India Compliance creates its own with the same names), `TDS Receivable - VTC`,
the bank account `HDFC - VTC`, a company address, the UOMs Hour/Minute/Second, the items `VOICED-SERVICE` (stock UOM Nos)
and `VOICED-HOURLY` (stock UOM Hour), and an integration user with a fresh API key and secret.
`.env.*` files hold those keys; they are git-ignored (`.env.*` in the root `.gitignore`) and must never be committed.
Run the Rust tests (they only touch the site named by the sourced env file):
```bash
set -a; . scripts/erpnext-e2e/.env.a; set +a # or .env.b for the India Compliance site
cargo test --manifest-path src-tauri/Cargo.toml live_ -- --ignored --test-threads=1
```
Environment variables the tests read:
| Variable | Meaning |
|---|---|
| `ERPNEXT_URL`, `ERPNEXT_KEY`, `ERPNEXT_SECRET` | Site address and the integration user's API key and secret. |
| `ERPNEXT_COMPANY` (`Voiced Test Co`), `ERPNEXT_ABBR` (`VTC`) | Company; account names derive from the abbreviation. |
| `ERPNEXT_IC` | `1` on the India Compliance site; the `live_ic_*` tests skip themselves otherwise. |
| `ERPNEXT_ROUNDING` | `banker` (ERPNext default) or `commercial`; decides what the half-paise test expects. |
| `ERPNEXT_BANK_ACCOUNT` (`HDFC - VTC`) | Account that receives payments. |
Each test builds its own in-memory Voiced database and a fresh invoice series (`L<time><n>/001`), so reruns never collide
with documents from earlier runs. Switch the rounding method of a site with
`podman exec -i voiced-erp-backend bash -c "cd /home/frappe/frappe-bench/sites && /home/frappe/frappe-bench/env/bin/python - 127.0.0.1 rounding=commercial" < setup_site.py`
(re-run `setup.sh` or copy the printed `ERPNEXT_*` lines if you need the new key; the key is regenerated on every run).
Tear down (touches only the `voiced-erp` project; the cached images stay):
```bash
podman-compose -p voiced-erp -f compose.yml down -v
podman volume rm voiced-erp_voiced-erp-db voiced-erp_voiced-erp-sites voiced-erp_voiced-erp-logs # only if `down -v` left them
```
Notes on rootless podman: the compose file has no healthchecks (podman's healthcheck timers need systemd), and the
backend and worker are started by `setup.sh` after the configurator has exited.
## Integration user (feeds the main README)
Create a dedicated user in ERPNext (User list, type System User) with **the roles "Accounts User" and "Sales User"**.
Then open the user, "Settings" tab, "API Access", "Generate Keys", and copy the secret once.
Why both (verified live on plain ERPNext and with India Compliance; `setup_site.py` creates exactly this user):
- "Accounts User": create and submit Sales Invoices (draft, insert-and-submit, submit-later) and Payment Entries; read
Accounts, Items, UOMs, Cost Centers, Price Lists, Item Groups, Company, the Sales Invoice naming series and (with
India Compliance) GST Settings; attach a private PDF to the invoice.
- "Sales User": look up and create Customers and Addresses, read Customer Groups, Territories and the tax templates.
With "Accounts User" alone a fresh site answers HTTP 403 on Customer.
No tax template is used (Voiced always sends explicit tax rows), so the tax-template list is only a convenience.
Do not use Administrator or System Manager keys. Roles on a user are cached by Frappe: after changing them run
`bench clear-cache` or the next request may still use the old set.
## Results (what was verified live)
Versions: Frappe 15.121.3, ERPNext 15.121.6, India Compliance 15.32.0 (the `version-15` branch; commit `db73b3e` at the time of this run, so a later image build may differ), MariaDB 11.8,
Redis 8.6, API v2 naming available (Frappe 15.73 or newer).
Verified on both sites unless marked IC (India Compliance site only):
- Connection test: user, frappe/erpnext/india_compliance versions, India Compliance detection, no warnings when the
accounts match. Option lists load (companies, addresses, accounts, UOMs, cost centers, groups, series).
- Sales Invoice push in mirror mode (API v2, `name` = Voiced number): CGST+SGST, IGST, discount (`apply_discount_on:
Net Total`), fractional hours, repeated descriptions on code-less rows, item-code rows from a preset, remarks. The
grand total, the tax amounts per head and the posting date equal Voiced's; `disable_rounded_total` works.
- PDF attachment: uploaded through `upload_file`, `is_private` is 1.
- Idempotency: a second push is a no-op; with the local sync row deleted, the 409 path adopts the existing document
(no duplicate, no second PDF).
- Submit: insert then submit in a second push (v2 `method/submit`), and series mode through the v1 `run_method` route.
- Series mode: ERPNext names the document, the Voiced number is in `remarks`, and a lost sync row is recovered by the
remarks lookup.
- Payments: `get_payment_entry` plus overrides. `bank_account` is the Account name and lands in `paid_to`;
`paid_amount = received_amount = cash`; the reference's `allocated_amount = cash + TDS`; TDS is one `deductions` row
`{account, cost_center, amount: +TDS}`; the invoice's outstanding falls by cash + TDS; partial payments leave the rest.
- Customer and Address shape (IC: `gstin`, `gst_category` on both; state exact name; link to the Customer).
- IC: `place_of_supply` as `29-Karnataka`; Voiced's 38 state names equal India Compliance's `gst_state` list; HSN/SAC
must be 6 or 8 digits (GST Settings `min_hsn_digits` was 6) and is enforced at submit; the 16-character name rule;
GST-account warnings (mismatching CGST account, company without GST accounts, a too-long next number).
Defects found and fixed (each has a mock-based test in the normal suite):
1. `load_options`: the company-address list asked for the India Compliance-only `gstin` field and failed with HTTP 417
on a plain site. It now retries without it.
2. Code-less rows with a fractional quantity of hours/minutes failed ("Quantity cannot be a fraction ... UOM Nos"):
ERPNext defaults the row's stock UOM to Nos without an item. Such rows now also send `stock_uom` = the row UOM.
3. Rounding: ERPNext's default Rounding Method is Banker's Rounding; Voiced rounds half-paise up. On ties (for example
9% of 10.50) the totals differ by 1 paise. A created document whose total differs is now a `conflict`: kept as a
draft, never submitted, no PDF, with a message naming the fix (System Settings, Rounding Method, "Commercial
Rounding"). The existing-document conflict message carries the same hint. With Commercial Rounding every case matched.
4. A lost link to a client's address created a new address on every first push. The customer's matching address (same
first line and PIN) is reused. (`creation` in the order-by is ambiguous with a Dynamic Link filter and gives HTTP 500.)
5. A re-adopted document got a second copy of the PDF. A file of the same name is now looked up first.
6. IC refuses `is_reverse_charge = 1` unless the tax rows are negative amounts on separate "RCM" accounts, which would
make ERPNext's total differ from Voiced's. It is sent as 0 and the push warns.
7. IC's 16-character rule is now checked before the POST in mirror mode (a readable, local refusal that points to series
naming); ERPNext's own message is "Transaction Name must be 16 characters or fewer to meet GST requirements".
8. A TDS-only payment (no cash) got ERPNext's bare "Paid Amount is mandatory"; it is refused locally with the reason.
9. A fractional quantity on a whole-number UOM is refused by ERPNext with a readable message; Voiced appends what to
change (unit mapping). No local pre-check: whether "Nos" is whole-number is a site setting.
Limits and notes:
- Rounding setting cannot be read by a restricted user, so it is detected by comparing the totals after creating the
draft, not in the connection test.
- With India Compliance, an invoice with a missing or short HSN/SAC is created as a draft and fails at submit; the
readable ERPNext message ("HSN/SAC must exist and should be 6 or 8 digits long for the following row numbers") is
shown.
- A mirrored item row with an item code whose stock UOM differs from the row UOM works for whole quantities
(`conversion_factor` 1); a fractional quantity needs the item's stock UOM to allow fractions.
- One intermittent failure was seen once in the half-paise test on the India Compliance site (the push of a non-tie
case failed; three later full runs and a single-test run passed). The cause was not captured.
Not verified:
- ERPNext v14 and v16 (the v16 images that exist on some machines were not used), other India Compliance versions.
- Windows and WebView2; the Tauri commands and UI (the tests call the Rust push/payment functions directly).
- Overseas/SEZ customers, UTGST supplier states, multi-currency, GST Settings with "Round Off GST Values" on,
reverse charge booked on RCM accounts, e-invoice/e-waybill (India Compliance API features), a TLS or reverse-proxied
site (only plain `http://` on a loopback address), Frappe Cloud or any site with `allow_cors` or rate limits.
+66
View File
@@ -0,0 +1,66 @@
# Lean ERPNext v15 stack for Voiced's live integration tests (project name: voiced-erp).
# db + redis + backend (gunicorn, published on 127.0.0.1:8088) + one worker. No nginx/websocket/scheduler.
# setup.sh starts it in order (db, redis, configurator, then backend and worker); do not `up` it blindly.
# Image: voiced-erp-ic:v15 = frappe/erpnext v15 + the india_compliance app files (see Containerfile.ic).
name: voiced-erp
services:
db:
image: docker.io/library/mariadb:11.8
container_name: voiced-erp-db
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --skip-character-set-client-handshake
- --innodb-buffer-pool-size=256M
environment:
MARIADB_ROOT_PASSWORD: admin
volumes:
- voiced-erp-db:/var/lib/mysql
redis:
image: docker.io/library/redis:8.6-alpine
container_name: voiced-erp-redis
configurator:
image: localhost/voiced-erp-ic:v15
container_name: voiced-erp-configurator
restart: "no"
entrypoint: ["bash", "-c"]
command:
- >
wait-for-it -t 120 db:3306;
ls -1 apps > sites/apps.txt;
bench set-config -g db_host db;
bench set-config -gp db_port 3306;
bench set-config -g redis_cache redis://redis:6379;
bench set-config -g redis_queue redis://redis:6379;
bench set-config -g redis_socketio redis://redis:6379;
bench set-config -gp socketio_port 9000;
volumes:
- voiced-erp-sites:/home/frappe/frappe-bench/sites
- voiced-erp-logs:/home/frappe/frappe-bench/logs
depends_on:
- db
- redis
backend:
image: localhost/voiced-erp-ic:v15
container_name: voiced-erp-backend
ports:
- "127.0.0.1:8088:8000"
volumes:
- voiced-erp-sites:/home/frappe/frappe-bench/sites
- voiced-erp-logs:/home/frappe/frappe-bench/logs
worker:
image: localhost/voiced-erp-ic:v15
container_name: voiced-erp-worker
command: ["bench", "worker", "--queue", "short,default,long"]
volumes:
- voiced-erp-sites:/home/frappe/frappe-bench/sites
- voiced-erp-logs:/home/frappe/frappe-bench/logs
volumes:
voiced-erp-db:
voiced-erp-sites:
voiced-erp-logs:
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Brings up the voiced-erp podman project and prepares two ERPNext v15 sites for the live tests:
# 127.0.0.1 plain ERPNext -> .env.a (URL http://127.0.0.1:8088)
# localhost ERPNext + India Compliance -> .env.b (URL http://localhost:8088)
# Both are served by the same backend; Frappe picks the site from the Host header.
# Safe to re-run: existing sites are kept and the integration user's key is regenerated.
set -euo pipefail
cd "$(dirname "$0")"
COMPOSE=(podman-compose -p voiced-erp -f compose.yml)
IMAGE=localhost/voiced-erp-ic:v15
BENCH_SITES=/home/frappe/frappe-bench/sites
podman image exists "$IMAGE" || podman build -t "$IMAGE" -f Containerfile.ic .
"${COMPOSE[@]}" up -d db redis configurator
until [ "$(podman inspect -f '{{.State.Status}}' voiced-erp-configurator)" = exited ]; do sleep 2; done
[ "$(podman inspect -f '{{.State.ExitCode}}' voiced-erp-configurator)" = 0 ] || { echo "configurator failed: podman logs voiced-erp-configurator" >&2; exit 1; }
"${COMPOSE[@]}" up -d backend worker
in_backend() { podman exec -i voiced-erp-backend bash -c "$1"; }
for site in 127.0.0.1 localhost; do
default=""; [ "$site" = 127.0.0.1 ] && default="--set-default"
in_backend "test -d $BENCH_SITES/$site || bench new-site $site --mariadb-user-host-login-scope='%' \
--admin-password=admin --db-root-username=root --db-root-password=admin --install-app erpnext $default" >/dev/null
done
until curl -fsS http://127.0.0.1:8088/api/method/ping >/dev/null 2>&1; do sleep 2; done
# setup_site.py prints `ENV KEY=VALUE` lines; turn them into a sourceable env file (untracked: .env.* is ignored).
write_env() { # site, url, file, extra args...
local site=$1 url=$2 file=$3; shift 3
in_backend "cd $BENCH_SITES && /home/frappe/frappe-bench/env/bin/python - $site $*" < setup_site.py \
| sed -n 's/^ENV \([A-Z_]*\)=\(.*\)$/export \1="\2"/p' > "$file"
echo "export ERPNEXT_URL=\"$url\"" >> "$file"
chmod 600 "$file"
}
write_env 127.0.0.1 http://127.0.0.1:8088 .env.a
write_env localhost http://localhost:8088 .env.b ic
echo "Ready. Source .env.a (plain) or .env.b (India Compliance), then run the live tests (see README.md)."
+197
View File
@@ -0,0 +1,197 @@
"""Prepare one ERPNext site for Voiced's live integration tests.
Run inside the backend container (see README.md):
podman exec -i voiced-erp-backend python - <site> [ic] [rounding=commercial] < setup_site.py
Creates (idempotently): the Indian company "Voiced Test Co" (abbr VTC, INR), the fiscal year, GST output tax
accounts, a bank account, a company address, an integration user with API keys, and a few Items/UOMs.
Prints one line `ENV KEY=VALUE` per value the Rust live tests need. Never run against a real site.
"""
import json
import sys
import time
import frappe
SITE = sys.argv[1]
IC = "ic" in sys.argv[2:]
# `rounding=commercial` switches System Settings > Rounding Method (the ERPNext default is Banker's Rounding).
ROUNDING = {"commercial": "Commercial Rounding", "banker": "Banker's Rounding"}.get(
next((a.split("=", 1)[1] for a in sys.argv[2:] if a.startswith("rounding=")), ""), None
)
ABBR = "VTC"
COMPANY = "Voiced Test Co"
frappe.init(site=SITE, sites_path="/home/frappe/frappe-bench/sites")
frappe.connect()
frappe.set_user("Administrator")
def out(key, value):
print(f"ENV {key}={value}")
def exists(doctype, name):
return bool(frappe.db.exists(doctype, name))
def insert(doc):
d = frappe.get_doc(doc)
d.insert(ignore_permissions=True)
frappe.db.commit()
return d
# ---- 1. site, optionally India Compliance (before the wizard so it can add its own accounts) ----
if IC and "india_compliance" not in frappe.get_installed_apps():
from frappe.installer import install_app
install_app("india_compliance", verbose=False)
frappe.db.commit()
# ---- 2. setup wizard ----
if not frappe.db.get_single_value("System Settings", "setup_complete"):
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
setup_complete(
{
"language": "English",
"country": "India",
"timezone": "Asia/Kolkata",
"currency": "INR",
"full_name": "Voiced Admin",
"email": "admin@voiced.test",
"password": "admin",
"company_name": COMPANY,
"company_abbr": ABBR,
"domains": ["Services"],
"chart_of_accounts": "Standard",
"fy_start_date": "2026-04-01",
"fy_end_date": "2027-03-31",
"bank_account": "HDFC",
"setup_demo": 0,
}
)
# setup_complete may run its stages on a worker; wait for it.
for _ in range(120):
frappe.db.rollback()
if frappe.db.get_single_value("System Settings", "setup_complete") and exists("Company", COMPANY):
break
time.sleep(2)
frappe.clear_cache()
frappe.db.commit()
assert exists("Company", COMPANY), "setup wizard did not create the company"
# ---- 3. accounts ----
duties = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Duties and Taxes", "is_group": 1}, "name")
assert duties, "Duties and Taxes group not found"
for head in ("CGST", "SGST", "UTGST", "IGST"):
name = f"Output Tax {head} - {ABBR}"
if not exists("Account", name):
insert(
{
"doctype": "Account",
"account_name": f"Output Tax {head}",
"parent_account": duties,
"company": COMPANY,
"account_type": "Tax",
"root_type": "Liability",
"is_group": 0,
}
)
tds = f"TDS Receivable - {ABBR}"
if not exists("Account", tds):
parent = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Current Assets", "is_group": 1}, "name")
insert(
{
"doctype": "Account",
"account_name": "TDS Receivable",
"parent_account": parent,
"company": COMPANY,
"account_type": "Tax",
"root_type": "Asset",
"is_group": 0,
}
)
# ---- 4. company address (India Compliance needs one with a GSTIN-ish state) ----
addr_name = f"{COMPANY}-Billing"
if not exists("Address", addr_name):
d = {
"doctype": "Address",
"address_title": COMPANY,
"address_type": "Billing",
"address_line1": "1 Test Street",
"city": "Mumbai",
"state": "Maharashtra",
"pincode": "400001",
"country": "India",
"links": [{"link_doctype": "Company", "link_name": COMPANY}],
}
if IC:
d["gst_category"] = "Registered Regular"
d["gstin"] = "27AAPFU0939F1ZV"
insert(d)
# ---- 5. items / UOMs ----
for uom in ("Hour", "Minute", "Second"):
if not exists("UOM", uom):
insert({"doctype": "UOM", "uom_name": uom})
for code, uom in (("VOICED-SERVICE", "Nos"), ("VOICED-HOURLY", "Hour")):
if not exists("Item", code):
d = {
"doctype": "Item",
"item_code": code,
"item_name": code,
"item_group": "Services",
"stock_uom": uom,
"is_stock_item": 0,
}
if IC:
d["gst_hsn_code"] = "998314"
insert(d)
# ---- 5b. rounding method ----
if ROUNDING:
frappe.db.set_single_value("System Settings", "rounding_method", ROUNDING)
frappe.db.commit()
frappe.clear_cache()
# ---- 6. integration user ----
USER = f"voiced-integration@{SITE.replace('.', '-')}.test"
# The role set that was verified live (with and without India Compliance): "Accounts User" creates and submits Sales
# Invoices and Payment Entries and reads the accounting masters; "Sales User" adds the Customer and Address rights
# (look up, create) and the tax-template list. Accounts User alone gets HTTP 403 on Customer.
ROLES = ["Accounts User", "Sales User"]
if not exists("User", USER):
insert(
{
"doctype": "User",
"email": USER,
"first_name": "Voiced",
"last_name": "Integration",
"send_welcome_email": 0,
"user_type": "System User",
"roles": [{"role": r} for r in ROLES],
}
)
user = frappe.get_doc("User", USER)
user.set("roles", [{"role": r} for r in ROLES])
secret = frappe.generate_hash(length=32)
if not user.api_key:
user.api_key = frappe.generate_hash(length=15)
user.api_secret = secret
user.save(ignore_permissions=True)
frappe.db.commit()
frappe.clear_cache(user=USER)
frappe.clear_cache()
out("ERPNEXT_KEY", user.api_key)
out("ERPNEXT_SECRET", secret)
out("ERPNEXT_COMPANY", COMPANY)
out("ERPNEXT_ABBR", ABBR)
out("ERPNEXT_IC", "1" if IC else "0")
out("ERPNEXT_USER", USER)
out("ERPNEXT_ROUNDING", "commercial" if frappe.db.get_single_value("System Settings", "rounding_method") == "Commercial Rounding" else "banker")
print(json.dumps({"roles": ROLES}))
frappe.destroy()
+29
View File
@@ -0,0 +1,29 @@
// Regenerates public/fonts/manifest.json from the TTFs under public/fonts/<folder>/.
// Run: node scripts/fonts/build-manifest.mjs
import { existsSync, writeFileSync } from "node:fs";
import path from "node:path";
import { FONTS_DIR, MANIFEST_PATH, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
const families = [];
for (const folder of familyFolders()) {
const files = ttfFiles(folder);
if (files.length === 0) continue;
const inspected = files.map((file) => inspectFont(folder, file));
const names = new Set(inspected.map((f) => f.familyName));
if (names.size !== 1) {
throw new Error(`${folder}: mixed family names ${[...names].join(", ")}`);
}
const licenseFile = "OFL.txt";
if (!existsSync(path.join(FONTS_DIR, folder, licenseFile))) {
throw new Error(`${folder}: missing ${licenseFile}`);
}
const fonts = inspected
.map((f) => f.info)
.sort((a, b) => a.weight - b.weight || a.style.localeCompare(b.style) || a.file.localeCompare(b.file));
families.push({ family: [...names][0], folder, license: "OFL-1.1", licenseFile, fonts });
}
writeFileSync(MANIFEST_PATH, `${JSON.stringify({ version: 1, families }, null, 2)}\n`);
console.log(
`manifest.json: ${families.length} families, ${families.reduce((n, f) => n + f.fonts.length, 0)} fonts`,
);
+79
View File
@@ -0,0 +1,79 @@
// Shared helpers for the font manifest builder and verifier (Node only).
import { createHash } from "node:crypto";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as fontkit from "fontkit";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const FONTS_DIR = path.join(ROOT, "public/fonts");
export const MANIFEST_PATH = path.join(FONTS_DIR, "manifest.json");
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Folders under public/fonts that hold bundled families (everything that is a directory). */
export function familyFolders() {
return readdirSync(FONTS_DIR)
.filter((name) => statSync(path.join(FONTS_DIR, name)).isDirectory())
.sort();
}
export function ttfFiles(folder) {
return readdirSync(path.join(FONTS_DIR, folder))
.filter((name) => /\.ttf$/i.test(name))
.sort();
}
/** First four bytes as a tag: "\0\1\0\0" is reported as "0x00010000". */
export function sfntTag(bytes) {
const b = bytes.subarray(0, 4);
if (b[0] === 0 && b[1] === 1 && b[2] === 0 && b[3] === 0) return "0x00010000";
return String.fromCharCode(...b);
}
/** Raw OS/2 fsType (fontkit only exposes decoded flags). */
function rawFsType(bytes) {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const numTables = dv.getUint16(4);
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
const tag = String.fromCharCode(bytes[rec], bytes[rec + 1], bytes[rec + 2], bytes[rec + 3]);
if (tag === "OS/2") return dv.getUint16(dv.getUint32(rec + 8) + 8);
}
return null;
}
/** Reads a TTF with fontkit and returns the facts the manifest records. */
export function inspectFont(folder, file) {
const bytes = new Uint8Array(readFileSync(path.join(FONTS_DIR, folder, file)));
const font = fontkit.create(Buffer.from(bytes));
if (font.fonts) throw new Error(`${folder}/${file}: font collections are not supported`);
const os2 = font["OS/2"];
return {
bytes,
tag: sfntTag(bytes),
variable: Boolean(font.directory?.tables?.fvar),
// Name ID 16 (typographic family) groups SemiBold etc. under the base family.
familyName: font.name?.records?.preferredFamily?.en ?? font.familyName,
info: {
file,
weight: os2.usWeightClass,
style: os2.fsSelection.italic || os2.fsSelection.oblique ? "italic" : "normal",
sha256: sha256(bytes),
fsType: rawFsType(bytes),
upm: font.unitsPerEm,
ascent: font.ascent,
descent: font.descent,
lineGap: font.lineGap,
capHeight: font.capHeight,
xHeight: font.xHeight,
hasRupee: font.hasGlyphForCodePoint(0x20b9),
hasTabularNums: font.availableFeatures.includes("tnum"),
},
};
}
export const WEIGHT_WORDS = {
Thin: 100, ExtraLight: 200, Light: 300, Regular: 400, Italic: 400, Text: 450,
Medium: 500, SemiBold: 600, Bold: 700, ExtraBold: 800, Black: 900,
};
+76
View File
@@ -0,0 +1,76 @@
// Checks public/fonts against manifest.json. Exits non-zero with readable messages on any problem.
// Run: npm run fonts:verify
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { FONTS_DIR, MANIFEST_PATH, WEIGHT_WORDS, familyFolders, inspectFont, ttfFiles } from "./lib.mjs";
const problems = [];
const fail = (where, msg) => problems.push(`${where}: ${msg}`);
if (!existsSync(MANIFEST_PATH)) {
console.error("public/fonts/manifest.json is missing; run node scripts/fonts/build-manifest.mjs");
process.exit(1);
}
const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
if (manifest.version !== 1) fail("manifest", `unsupported version ${manifest.version}`);
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const listed = new Set();
let count = 0;
for (const fam of manifest.families) {
const dir = `public/fonts/${fam.folder}`;
if (slug(fam.family) !== fam.folder) fail(dir, `folder does not match family "${fam.family}"`);
if (!existsSync(path.join(FONTS_DIR, fam.folder, fam.licenseFile))) {
fail(dir, `licence file ${fam.licenseFile} is missing`);
}
for (const entry of fam.fonts) {
const where = `${fam.folder}/${entry.file}`;
listed.add(where);
count++;
if (!existsSync(path.join(FONTS_DIR, fam.folder, entry.file))) {
fail(where, "listed in the manifest but not on disk");
continue;
}
let f;
try {
f = inspectFont(fam.folder, entry.file);
} catch (err) {
fail(where, `cannot be parsed: ${err.message}`);
continue;
}
if (f.tag !== "0x00010000" && f.tag !== "true") {
fail(where, `not a TrueType font (magic "${f.tag}"); WOFF, WOFF2 and OpenType/CFF must not be bundled for PDFs`);
}
if (f.variable) fail(where, "variable font (has fvar); react-pdf would embed only the default instance");
if (f.info.sha256 !== entry.sha256) fail(where, "sha256 differs from the manifest; rebuild it if the change is intended");
if (f.info.fsType !== 0 && f.info.fsType !== 8) {
fail(where, `fsType ${f.info.fsType} does not allow embedding (need 0 or 8)`);
}
if (f.familyName !== fam.family) fail(where, `family is "${f.familyName}", manifest says "${fam.family}"`);
if (f.info.weight !== entry.weight || f.info.style !== entry.style) {
fail(where, `weight/style ${f.info.weight}/${f.info.style} differs from the manifest ${entry.weight}/${entry.style}`);
}
const word = entry.file.replace(/\.ttf$/i, "").split("-").pop();
const expected = WEIGHT_WORDS[word];
if (expected !== undefined && word !== "Text" && expected !== f.info.weight) {
fail(where, `file name says ${word} (${expected}) but OS/2 weight is ${f.info.weight}`);
}
if (/Italic$/.test(word) !== (f.info.style === "italic")) {
fail(where, `file name and OS/2 disagree about italic (${f.info.style})`);
}
}
}
for (const folder of familyFolders()) {
for (const file of ttfFiles(folder)) {
if (!listed.has(`${folder}/${file}`)) fail(`${folder}/${file}`, "TTF on disk but not in the manifest");
}
}
if (problems.length) {
console.error(`fonts:verify found ${problems.length} problem(s):`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
console.log(`fonts:verify OK: ${manifest.families.length} families, ${count} fonts`);
+55
View File
@@ -0,0 +1,55 @@
// Child-process scenarios for src/pdf/hang.test.ts. Bundled with esbuild and run with node, because
// react-pdf's paginate() is a synchronous loop: an in-process timer never fires while it spins.
import { createElement as h } from "react";
import { Document, Page, Path, Svg, Text, View } from "@react-pdf/renderer";
import path from "node:path";
import { FirstPageHeader } from "../../src/pdf/blocks/FirstPageHeader";
import { find } from "../../src/pdf/testing/docs";
import { registerBundledFonts, stack } from "../../src/pdf/fonts/register";
import { renderCore } from "../../src/pdf/render/core";
registerBundledFonts(path.resolve(process.cwd(), "public/fonts"));
const [scenario, rowsArg] = process.argv.slice(2);
const rows = Number(rowsArg);
const W = 595.28;
const H = 841.89;
const PAD = 40;
const text = { fontFamily: stack("IBM Plex Sans"), fontSize: 10, lineHeight: "12pt" };
const flow = Array.from({ length: rows }, (_, i) =>
h(View, { key: i, style: { height: 18 } }, h(Text, { style: text }, `Row ${i + 1}`)),
);
const svg = (extra: Record<string, unknown>) =>
h(
Svg,
{ viewBox: "0 0 100 40", style: { width: W, height: 40, ...extra } },
h(Path, { d: "M0 0 L100 0 L100 40 L0 40 Z", fill: "#cccccc" }),
);
function pageWith(children: unknown[]) {
return h(Document, null, h(Page, { size: { width: W, height: H }, style: { padding: PAD } }, ...(children as never[])));
}
const element =
scenario === "page-level-svg"
? // Non-fixed, absolute, full width, straddling the bottom margin line (H - PAD = 801.89).
pageWith([...flow, svg({ position: "absolute", left: 0, top: H - PAD - 20 })])
: pageWith([
h(
FirstPageHeader,
{
key: "head",
pad: { top: PAD, right: PAD, bottom: PAD, left: PAD },
decor: [{ x: 0, y: 10, width: W, height: 40, children: svg({}) }],
style: { height: 60 },
},
h(Text, { style: text }, "Header"),
),
...flow,
]);
const r = await renderCore(element);
const svgPages = r.layout.map((p) => find(p, (n) => n.type === "svg").length);
console.log(JSON.stringify({ pages: r.pages, svgPages }));
+263
View File
@@ -0,0 +1,263 @@
// Draws the sign-off sheet: for every template with a logo slot, the reference placeholder next to four test logos
// placed by computeLogoPlacement. Run: npm run logo:sheet. Output: /tmp/invoice-c2/contact-sheet.png
import { build } from "esbuild";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { OUT_DIR, ROOT, SCALE, TEMPLATE_PDF, loadCanvas, renderPage } from "./lib.mjs";
const PX = 3; // sheet pixels per pt
const PAD = 14; // context around the slot, pt
const LABEL_W = 170;
const HEAD_H = 54;
const GUTTER = 8;
/** Bundles the TypeScript placement code (type-only imports and all) so plain node can run it. */
async function loadPlacement() {
const out = await build({
stdin: {
contents: `export { computeLogoPlacement } from "./src/pdf/engine/logo.ts";
export { slotFor } from "./src/pdf/templates/slots.optin.ts";`,
resolveDir: ROOT,
loader: "ts",
},
bundle: true,
write: false,
format: "esm",
platform: "node",
logLevel: "error",
});
const code = out.outputFiles[0].text;
return import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
}
const INK_ALPHA = 32;
/** Trim to the ink box (alpha > 32, ignoring lines with under 2 ink pixels) and measure like the Rust pipeline. */
function inkInfo(img) {
const { width: w, height: h } = img;
const d = img.getContext("2d").getImageData(0, 0, w, h).data;
const rows = new Uint32Array(h), cols = new Uint32Array(w);
let total = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (d[(y * w + x) * 4 + 3] > INK_ALPHA) { rows[y]++; cols[x]++; total++; }
const first = (a) => a.findIndex((v) => v >= 2);
const last = (a) => a.length - 1 - [...a].reverse().findIndex((v) => v >= 2);
const box = { x: first(cols), y: first(rows), w: 0, h: 0 };
box.w = last(cols) - box.x + 1;
box.h = last(rows) - box.y + 1;
return { box, aspect: box.w / box.h, density: total / (box.w * box.h) };
}
function trimmed(createCanvas, img) {
const { box, aspect, density } = inkInfo(img);
const c = createCanvas(box.w, box.h);
c.getContext("2d").drawImage(img, box.x, box.y, box.w, box.h, 0, 0, box.w, box.h);
return { canvas: c, aspect, density };
}
/** Same pixels in white, for dark surfaces. */
function knockout(createCanvas, c) {
const k = createCanvas(c.width, c.height);
const ctx = k.getContext("2d");
ctx.drawImage(c, 0, 0);
ctx.globalCompositeOperation = "source-in";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, c.width, c.height);
return k;
}
function syntheticLogos(createCanvas) {
const S = 400;
const mk = (w, h, draw) => {
const c = createCanvas(w, h);
draw(c.getContext("2d"), w, h);
return c;
};
const roundRect = (ctx, x, y, w, h, r) => {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
};
const square = mk(S, S, (ctx) => {
ctx.fillStyle = "#0b5f8a";
roundRect(ctx, 0, 0, S, S, 70);
ctx.fill();
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(S / 2, S / 2, 95, 0, Math.PI * 2);
ctx.fill();
});
const emblem = mk(S, S, (ctx) => {
ctx.strokeStyle = "#8a2b0b";
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(S / 2, S / 2, S / 2 - 11, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "#8a2b0b";
ctx.beginPath();
ctx.arc(S / 2, S / 2, 46, 0, Math.PI * 2);
ctx.fill();
});
const tall = mk(S / 2, S, (ctx, w, h) => {
ctx.fillStyle = "#1f6b3a";
for (let i = 0; i < 4; i++) {
roundRect(ctx, 14, 14 + i * (h / 4), w - 28, h / 4 - 40, 18);
ctx.fill();
}
});
return { square, emblem, tall };
}
async function main() {
const { createCanvas, loadImage } = await loadCanvas();
const { computeLogoPlacement, slotFor } = await loadPlacement();
mkdirSync(OUT_DIR, { recursive: true });
const sampleRaw = await loadImage(readFileSync(path.join(ROOT, "public/brand/sample-wordmark-logo.png")));
const sampleCanvas = createCanvas(sampleRaw.width, sampleRaw.height);
sampleCanvas.getContext("2d").drawImage(sampleRaw, 0, 0);
const syn = syntheticLogos(createCanvas);
const logos = [
{ label: "Test Vendor wordmark", ...trimmed(createCanvas, sampleCanvas), kind: "wordmark", includesName: true },
{ label: "Square mark", ...trimmed(createCanvas, syn.square), kind: "mark", includesName: false },
{ label: "Round emblem", ...trimmed(createCanvas, syn.emblem), kind: "mark", includesName: false },
{ label: "Tall logo", ...trimmed(createCanvas, syn.tall), kind: "tall", includesName: false },
].map((l) => ({ ...l, knockout: knockout(createCanvas, l.canvas) }));
// Reference placeholders first, then the opt-in areas.
const ids = Object.keys(TEMPLATE_PDF)
.filter((id) => slotFor(id))
.sort((a, b) => Number(!!slotFor(a).optIn) - Number(!!slotFor(b).optIn) || TEMPLATE_PDF[a] - TEMPLATE_PDF[b]);
const rows = [];
for (const id of ids) {
const slot = slotFor(id);
const page = await renderPage(TEMPLATE_PDF[id]);
// 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 = {
x: Math.max(0, Math.floor(L.x - PAD)),
y: Math.max(0, Math.floor(L.y - PAD)),
};
region.w = Math.ceil(L.x + L.w + PAD) - region.x;
region.h = Math.ceil(L.y + L.h + PAD) - region.y;
rows.push({ id, slot, page, region });
}
const cellW = Math.max(...rows.map((r) => r.region.w)) * PX;
const sheetW = LABEL_W + (cellW + GUTTER) * 5 + GUTTER;
const rowH = (r) => r.region.h * PX + GUTTER;
const sheetH = HEAD_H + rows.reduce((s, r) => s + rowH(r), 0);
const sheet = createCanvas(sheetW, sheetH);
const ctx = sheet.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, sheetW, sheetH);
ctx.fillStyle = "#111111";
ctx.font = "bold 16px sans-serif";
ctx.fillText("Logo placement sign-off sheet (reference placeholder, then four test logos)", 8, 22);
ctx.font = "12px sans-serif";
ctx.fillText("Red = measured placeholder box, blue = mark box, orange dashed = allowance of a text-only placeholder, red dashed = opt-in area (no reference placeholder). Green = logo box as placed.", 8, 40);
const heads = ["reference placeholder", ...logos.map((l) => `${l.label} (${l.aspect.toFixed(2)}:1, d ${l.density.toFixed(2)})`)];
ctx.font = "bold 11px sans-serif";
heads.forEach((t, i) => ctx.fillText(t, LABEL_W + GUTTER + i * (cellW + GUTTER), HEAD_H - 6));
const box = (c, b, rx, ry, color, dash) => {
c.strokeStyle = color;
c.lineWidth = 1.5;
c.setLineDash(dash ? [5, 4] : []);
c.strokeRect((b.x - rx.x) * PX, (b.y - rx.y) * PX, b.w * PX, b.h * PX);
c.setLineDash([]);
};
let y = HEAD_H;
for (const { id, slot, page, region } of rows) {
const h = region.h * PX;
ctx.fillStyle = "#111111";
ctx.font = "bold 13px sans-serif";
ctx.fillText(id, 8, y + 18);
ctx.font = "11px sans-serif";
const f = (b) => (b ? `${b.w.toFixed(1)} x ${b.h.toFixed(1)} pt` : "none");
const lines = [
slot.optIn ? "opt-in, off by default" : "reference placeholder",
`lockup ${f(slot.lockup)}`,
...(slot.textOnly ? [`text-only: allowance ${f(slot.allowance)}`] : []),
`mark ${f(slot.mark)}`,
`density ${slot.lockupDensity.toFixed(2)} / ${slot.markDensity.toFixed(2)}`,
`${slot.anchor}, ${slot.vAlign}${slot.darkSurface ? ", dark surface" : ""}`,
];
lines.forEach((t, i) => ctx.fillText(t, 8, y + 36 + i * 15));
const cell = (col) => {
const cx = LABEL_W + GUTTER + col * (cellW + GUTTER);
ctx.save();
ctx.beginPath();
ctx.rect(cx, y, cellW, h);
ctx.clip();
ctx.translate(cx, y);
ctx.drawImage(page, region.x * SCALE, region.y * SCALE, region.w * SCALE, region.h * SCALE, 0, 0, region.w * PX, h);
return { cx, restore: () => ctx.restore() };
};
// Background colour at the region's corner, for painting the placeholder out of the rendering cells.
const bgOf = () => {
const c = createCanvas(region.w * SCALE, region.h * SCALE);
c.getContext("2d").drawImage(page, region.x * SCALE, region.y * SCALE, region.w * SCALE, region.h * SCALE, 0, 0, region.w * SCALE, region.h * SCALE);
const d = c.getContext("2d").getImageData(0, 0, 1, 1).data;
return `rgb(${d[0]},${d[1]},${d[2]})`;
};
const bg = bgOf();
// Reference placeholder cell.
{
const c = cell(0);
if (slot.optIn) box(ctx, slot.lockup, region, "#dd0000", true);
else {
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");
}
c.restore();
}
logos.forEach((logo, i) => {
const c = cell(i + 1);
if (!slot.optIn) {
// Paint the placeholder out, then place the logo where computeLogoPlacement says.
ctx.fillStyle = bg;
const L = slot.lockup;
ctx.fillRect((L.x - region.x - 1) * PX, (L.y - region.y - 1) * PX, (L.w + 2) * PX, (L.h + 2) * PX);
} else box(ctx, slot.lockup, region, "#dd0000", true);
const p = computeLogoPlacement({
logo: { aspect: logo.aspect, density: logo.density, kind: logo.kind, includesName: logo.includesName, hasKnockout: true },
slot,
});
const px = (p.x - region.x) * PX, py = (p.y - region.y) * PX;
if (p.variant === "chip") {
ctx.fillStyle = "#ffffff";
ctx.fillRect((p.chip.x - region.x) * PX, (p.chip.y - region.y) * PX, p.chip.w * PX, p.chip.h * PX);
}
ctx.drawImage(p.variant === "knockout" ? logo.knockout : logo.canvas, px, py, p.w * PX, p.h * PX);
box(ctx, p.box, region, "#00aa00");
ctx.fillStyle = "#00aa00";
ctx.font = "10px sans-serif";
ctx.fillText(`${p.w.toFixed(1)} x ${p.h.toFixed(1)} ${p.slotKind}`, 3, h - 4);
c.restore();
});
y += rowH({ region });
}
const outPath = path.join(OUT_DIR, "contact-sheet.png");
writeFileSync(outPath, sheet.toBuffer("image/png"));
console.log(`${ids.length} rows -> ${outPath} (${sheetW} x ${sheetH})`);
}
await main();
+50
View File
@@ -0,0 +1,50 @@
// Shared helpers for the logo scripts: rasterise a reference PDF page with pdf.js and @napi-rs/canvas.
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const PDF_DIR = path.join(ROOT, "templates", "reference");
export const SCALE = 4;
export const PAGE = { w: 595.5, h: 842.25 };
export const OUT_DIR = "/tmp/invoice-c2";
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);
}
}
/** Renders page 1 of the reference PDF `n` at SCALE x. Returns the canvas. */
export async function renderPage(n, scale = SCALE) {
const { createCanvas } = await loadCanvas();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const data = new Uint8Array(readFileSync(path.join(PDF_DIR, `${n}.pdf`)));
const doc = await pdfjs.getDocument({ data, isEvalSupported: false, verbosity: 0 }).promise;
const page = await doc.getPage(1);
const vp = page.getViewport({ scale });
const canvas = createCanvas(Math.round(vp.width), Math.round(vp.height));
await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp, canvas }).promise;
return canvas;
}
/** Copies a pt rectangle of a page canvas into a new canvas (same scale). */
export async function cropCanvas(canvas, rect, scale = SCALE) {
const { createCanvas } = await loadCanvas();
const x = Math.round(rect.x * scale);
const y = Math.round(rect.y * scale);
const w = Math.round(rect.w * scale);
const h = Math.round(rect.h * scale);
const out = createCanvas(w, h);
out.getContext("2d").drawImage(canvas, x, y, w, h, 0, 0, w, h);
return out;
}
/** reference PDF number of each template (docs/templates/README.md). */
export const TEMPLATE_PDF = {
marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6, "crimson-grid": 7,
"cobalt-stripe": 8, "slate-band": 9, "teal-swoosh": 10, "tangerine-ledger": 11, "purple-pop": 12, "citrus-split": 13,
};
+250
View File
@@ -0,0 +1,250 @@
// Measures the logo placeholder of each reference template PDF and writes src/pdf/templates/slots.generated.ts.
// Run: npm run logo:measure. Crops with the measured boxes go to /tmp/invoice-c2/crops for eyeballing.
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { OUT_DIR, PAGE, ROOT, SCALE, cropCanvas, loadCanvas, renderPage } from "./lib.mjs";
/** Colour distance (euclidean, 0-255 RGB) from the local background above which a pixel is ink. */
const INK_DISTANCE = 40;
/** Columns of empty pixels at least this wide (pt) separate the icon from the name in a lockup. */
const MARK_GAP_PT = 5;
/** Connected ink blobs smaller than this many pixels (at 4x) are paper texture or stray specks, not logo. */
const MIN_BLOB_PX = 30;
/*
* Search rectangles in page points (top-left origin). They come from the "Layout" / "Mapping to Voiced
* fields" sections of docs/templates/NN-*.md and were then tightened by looking at the crops, so that
* they hold the placeholder and nothing else:
* 3 Monolith 'CAVEIN' is the first line of a right-aligned stack; the date line starts at y 74, so the rect stops at y 70.
* 5 Monogram the ring (docs: x 387-531, y 58-198); the ring really starts at y 80.
* 6 Serenity the stamp (docs: x 441-531, y 128-218).
* 7 Crimson Grid flame (x 420-452, y 53-99) plus the 2-line name.
* 8 Cobalt swoosh (x 59.5-94, y 59.5-94) plus name and tagline; the grey rule starts at y 112, below the rect.
* 9 Slate Band white mark + name inside the dark band (the band ends at y 103).
* 10 Teal Swoosh mark + name + tagline; the corner swoosh art is far right (x > 350), outside the rect.
* 11 Tangerine the 'Rimberio' text. Reference leaves a 1 px tick at about (62, 95), so the rect stops at y 92.
* 13 Citrus Split mark + 2-line name on the grey column; the halftone chevrons start at x 487, outside the rect.
* kind: 'lockup' = icon + name (split into mark and lockup), 'seal' = one round placeholder (mark = lockup),
* 'text' = a text-only placeholder (no mark).
*/
const SEARCH_RECTS = {
3: { id: "monolith", rect: { x: 480, y: 46, w: 95, h: 24 }, kind: "text", anchor: "right", vAlign: "middle", dark: false },
5: { id: "monogram", rect: { x: 370, y: 60, w: 178, h: 152 }, kind: "seal", anchor: "right", vAlign: "middle", dark: false },
6: { id: "serenity", rect: { x: 430, y: 118, w: 115, h: 108 }, kind: "seal", anchor: "right", vAlign: "middle", dark: false },
7: { id: "crimson-grid", rect: { x: 405, y: 45, w: 140, h: 60 }, kind: "lockup", anchor: "right", vAlign: "bottom", dark: false },
8: { id: "cobalt-stripe", rect: { x: 50, y: 50, w: 215, h: 52 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
9: { id: "slate-band", rect: { x: 50, y: 46, w: 130, h: 50 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: true },
10: { id: "teal-swoosh", rect: { x: 50, y: 52, w: 160, h: 46 }, kind: "lockup", anchor: "left", vAlign: "middle", dark: false },
11: { id: "tangerine-ledger", rect: { x: 50, y: 55, w: 120, h: 37 }, kind: "text", 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 = [
"classic", "marble", "highlighter", "monolith", "linea", "monogram", "serenity", "crimson-grid",
"cobalt-stripe", "slate-band", "teal-swoosh", "tangerine-ledger", "purple-pop", "citrus-split",
];
const median = (arr) => {
const s = Uint8Array.from(arr).sort();
return s[s.length >> 1];
};
/** Ink mask of an RGBA crop: pixels far from the median colour of the crop's border. */
function inkMask(img, w, h) {
const border = [[], [], []];
const push = (x, y) => {
const i = (y * w + x) * 4;
for (let c = 0; c < 3; c++) border[c].push(img[i + c]);
};
for (let x = 0; x < w; x++) { push(x, 0); push(x, h - 1); }
for (let y = 1; y < h - 1; y++) { push(0, y); push(w - 1, y); }
const bg = border.map(median);
const mask = new Uint8Array(w * h);
for (let p = 0; p < w * h; p++) {
const i = p * 4;
const d = Math.hypot(img[i] - bg[0], img[i + 1] - bg[1], img[i + 2] - bg[2]);
mask[p] = d > INK_DISTANCE ? 1 : 0;
}
dropSpecks(mask, w, h);
return { mask, bg };
}
/** Clears 8-connected blobs under MIN_BLOB_PX in place (Serenity's paper has dark flecks). */
function dropSpecks(mask, w, h) {
const seen = new Uint8Array(w * h);
const stack = [];
for (let start = 0; start < w * h; start++) {
if (!mask[start] || seen[start]) continue;
const blob = [];
seen[start] = 1;
stack.push(start);
while (stack.length) {
const p = stack.pop();
blob.push(p);
const x = p % w, y = (p - x) / w;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const q = ny * w + nx;
if (mask[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
}
}
}
if (blob.length < MIN_BLOB_PX) for (const p of blob) mask[p] = 0;
}
}
/** Bounding box (px) and ink count of the mask within columns [x0, x1). */
function inkBox(mask, w, h, x0 = 0, x1 = w) {
let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = x0; x < x1; x++) {
if (!mask[y * w + x]) continue;
count++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (count === 0) return null;
return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, count };
}
/** First run of ink columns, ending at the first empty gap of MARK_GAP_PT or more. */
function firstColumnGroup(mask, w, h, box) {
const gap = MARK_GAP_PT * SCALE;
let empty = 0;
for (let x = box.minX; x < box.maxX; x++) {
let any = false;
for (let y = 0; y < h && !any; y++) any = mask[y * w + x] === 1;
empty = any ? 0 : empty + 1;
if (empty >= gap) return x - empty + 1;
}
return null;
}
function toPt(rect, box) {
return {
x: r2(rect.x + box.minX / SCALE),
y: r2(rect.y + box.minY / SCALE),
w: r2((box.maxX - box.minX) / SCALE),
h: r2((box.maxY - box.minY) / SCALE),
};
}
async function main() {
const { createCanvas } = await loadCanvas();
const cropsDir = path.join(OUT_DIR, "crops");
mkdirSync(cropsDir, { recursive: true });
const slots = {};
const rows = [];
for (const [n, spec] of Object.entries(SEARCH_RECTS)) {
const page = await renderPage(Number(n));
const crop = await cropCanvas(page, spec.rect);
const w = crop.width, h = crop.height;
const img = crop.getContext("2d").getImageData(0, 0, w, h).data;
const { mask, bg } = inkMask(img, w, h);
const all = inkBox(mask, w, h);
if (!all) throw new Error(`template ${n} (${spec.id}): no ink in the search rect`);
const touches = all.minX === 0 || all.minY === 0 || all.maxX === w || all.maxY === h;
if (touches) console.warn(`WARNING ${n} ${spec.id}: ink reaches the search rect edge; the rect may cut or include other art`);
const lockupBox = toPt(spec.rect, all);
const lockupDensity = all.count / ((all.maxX - all.minX) * (all.maxY - all.minY));
let markBox = null;
let markDensity = 0;
const boxes = [{ box: all, color: "red" }];
if (spec.kind === "seal") {
markBox = lockupBox;
markDensity = lockupDensity;
} else if (spec.kind === "lockup") {
const split = firstColumnGroup(mask, w, h, all);
if (split === null) throw new Error(`template ${n} (${spec.id}): no gap between mark and name`);
const m = inkBox(mask, w, h, all.minX, split);
markBox = toPt(spec.rect, m);
markDensity = m.count / ((m.maxX - m.minX) * (m.maxY - m.minY));
boxes.push({ box: m, color: "blue" });
}
const rounded = (v) => Math.round(v * 1000) / 1000;
const textOnly = spec.kind === "text";
slots[spec.id] = {
page: { w: PAGE.w, h: PAGE.h },
mark: markBox,
lockup: lockupBox,
markDensity: rounded(markDensity),
lockupDensity: rounded(lockupDensity),
anchor: spec.anchor,
vAlign: textOnly ? ALLOWANCES[spec.id].vAlign : spec.vAlign,
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 });
// Crop for review: the search rect with the measured boxes (red = lockup, blue = mark).
const out = createCanvas(w, h);
const ctx = out.getContext("2d");
ctx.drawImage(crop, 0, 0);
ctx.lineWidth = 2;
for (const { box, color } of boxes) {
ctx.strokeStyle = color;
ctx.strokeRect(box.minX - 1, box.minY - 1, box.maxX - box.minX + 2, box.maxY - box.minY + 2);
}
writeFileSync(path.join(cropsDir, `${n}-${spec.id}.png`), out.toBuffer("image/png"));
}
const body = TEMPLATE_IDS.map((id) => {
const s = slots[id];
return ` ${JSON.stringify(id)}: ${s ? JSON.stringify(s) : "null"},`;
}).join("\n");
const ts = `// Generated by \`npm run logo:measure\` from the reference template PDFs (scripts/logo/measure-placeholders.mjs).
// Do not edit by hand: change the search rectangles in the script and run it again.
// Boxes are the ink boxes of each reference logo placeholder, in points from the top-left of the 595.5 x 842.25 page.
// Templates without a placeholder are null here; their opt-in slots are in slots.optin.ts.
import type { TemplateId, TemplateSlot } from "./slots.types";
export const SLOTS: Record<TemplateId, TemplateSlot | null> = {
${body}
};
`;
writeFileSync(path.join(ROOT, "src/pdf/templates/slots.generated.ts"), ts);
const f = (b) => (b ? `${b.x},${b.y} ${b.w}x${b.h}` : "-");
console.log("n id mark lockup markD lockD anchor dark bg");
for (const r of rows) {
console.log(
`${String(r.n).padEnd(2)} ${r.id.padEnd(17)} ${f(r.mark).padEnd(23)} ${f(r.lockup).padEnd(23)} ${r.markDensity.toFixed(3)} ${r.lockupDensity.toFixed(3)} ${r.anchor.padEnd(6)} ${r.dark ? "yes " : "no "} ${r.bg.join(",")}`,
);
}
console.log(`crops: ${cropsDir}`);
}
await main();
+115
View File
@@ -0,0 +1,115 @@
// Side-by-side review of the reference-based templates: the reference original (left) against our render (right), plus
// the multi-page, Letter, long-text and logo-variant samples. Run: npm run templates:compare [-- monolith cobalt-stripe]
// Option --rows=N overrides the sample's item count (fewer rows keep every gap at its nominal size). Output: /tmp/invoice-c6b/ (override with --out=DIR). With --numbers it also prints text baselines (reference vs ours) for tuning.
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { CANVA_DIR, loadCanvas, loadRenderer, rasterize, textItems } from "./lib.mjs";
const argv = process.argv.slice(2);
const OUT = argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/invoice-c6b";
const SCALE = 1.6;
const PDF_NO = { marble: 1, highlighter: 2, monolith: 3, linea: 4, monogram: 5, serenity: 6, "crimson-grid": 7, "cobalt-stripe": 8, "slate-band": 9, "teal-swoosh": 10, "tangerine-ledger": 11, "purple-pop": 12, "citrus-split": 13 };
const ROWS = { linea: 7, "crimson-grid": 3, "tangerine-ledger": 3, "slate-band": 5, "teal-swoosh": 5, "purple-pop": 3, "citrus-split": 3, monolith: 3, "cobalt-stripe": 8, marble: 7, highlighter: 3 };
const DARK = ["slate-band"];
const args = process.argv.slice(2);
const numbers = args.includes("--numbers");
const ids = args.filter((a) => !a.startsWith("--"));
const targets = ids.length ? ids : ["monolith", "cobalt-stripe", "marble", "highlighter"];
// Templates with no reference logo placeholder: the logo shows only when the user turns it on.
const OPT_IN = ["linea", "purple-pop", "marble", "highlighter"];
// Of those, the ones whose compare images show the opt-in logo (Linea stays logo-free, like format.pdf).
const LOGO_ON = ["purple-pop"];
const LOGO_SAMPLES_ON = ["purple-pop", "marble", "highlighter"];
const LONG = "Unit 4, Third Floor, The Grand Heritage Business Centre, Plot 1187-B, Old Airport Road Extension, Opposite the Municipal Corporation Ward Office, Domlur Second Stage, Bengaluru Urban District, Karnataka State, India - 560071; Registered office: Level 12, Tower C, Global Technology Park, Outer Ring Road, Marathahalli, Bengaluru - 560103";
const TOKEN = "Supercalifragilisticexpialidocious-Pneumonoultramicroscopic-1234567890";
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 reference = await rasterize(canvaBytes, 1, SCALE);
const rows = Number(argv.find((a) => a.startsWith("--rows="))?.slice(7)) || (ROWS[id] ?? 5);
const NAMES = ["Brand consultation", "Logo design", "Website design", "Social media templates", "Brand photography", "Brand guide", "Social media templates", "Name card"];
// Short one-line descriptions like the reference sample, so positions can be compared row by row.
const items = (hsn) => NAMES.slice(0, rows).map((description, i) => ({ id: i + 1, description, mode: "rate", rate: 100, unit: "hour", quantity: 1, amount: 0, sortOrder: i, ...(hsn ? { hsnSac: "998397" } : {}) }));
const sample = (over = {}) => ({ name: `s-${id}`, items: items(over.registered !== false), signature: true, invoice: { poNumber: "", notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest." }, ...over });
const render = async (spec, size = "A4") => {
// Templates with no placeholder turn the opt-in on for their logo samples (Linea's compare stays logo-free, like format.pdf;
// Marble and Highlighter compare logo-free against the reference page and show the logo in the -logo-* samples).
const on = spec.logo && spec.logo !== "none" && (LOGO_ON.includes(id) || (LOGO_SAMPLES_ON.includes(id) && spec.logoSample));
const prefs = r.prefsFor(size, { templateId: id, ...(on ? { logo: { show: true } } : {}) });
return r.renderFixture(spec, prefs);
};
const withLogo = await render(sample({ logo: "real" }));
await side(reference, await rasterize(withLogo.result.bytes, 1, SCALE), `compare-${id}.png`);
const noLogo = await render(sample({ registered: false, logo: "none" }));
await side(reference, await rasterize(noLogo.result.bytes, 1, SCALE), `compare-${id}-nologo.png`);
if (numbers) {
// Tracked text comes out of pdf.js one glyph run at a time: join runs on the same baseline that touch.
const merge = (items) => {
const out = [];
for (const it of items) {
const prev = out[out.length - 1];
if (prev && Math.abs(prev.baseline - it.baseline) < 0.3 && it.x - (prev.x + prev.w) < 4 && it.x >= prev.x) {
prev.str += it.str.length > 1 || prev.str.length > 1 ? ` ${it.str}` : it.str;
prev.w = Math.round((it.x + it.w - prev.x) * 10) / 10;
} else out.push({ ...it });
}
return out;
};
// The sample without GST data (noLogo), like the reference page: one row per item, no sub-lines.
const a = merge(await textItems(canvaBytes));
const b = merge(await textItems(noLogo.result.bytes));
console.log(`--- ${id}: reference (${a.length}) vs ours (${b.length}); baseline y, x`);
for (const it of a) console.log(` reference ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`);
for (const it of b) console.log(` ours ${String(it.baseline).padStart(6)} ${String(it.x).padStart(6)} w${String(it.w).padStart(6)} s${it.size} ${it.str.slice(0, 40)}`);
}
if (OPT_IN.includes(id)) {
// The opt-in logo (off by default: Linea keeps matching format.pdf, Purple Pop has no placeholder).
const shown = await r.renderFixture(sample({ logo: "real" }), r.prefsFor("A4", { templateId: id, logo: { show: true } }));
writeFileSync(path.join(OUT, `${id}-A4-logo-optin.png`), (await rasterize(shown.result.bytes, 1, SCALE)).toBuffer("image/png"));
}
// Dark-surface templates: the white knockout variant (a logo with alpha) next to the chip fallback (no alpha) above.
const logoKinds = DARK.includes(id) ? ["square", "wide", "knockout"] : ["square", "wide"];
for (const logo of logoKinds) {
const x = await render(sample({ logo, logoSample: true }));
writeFileSync(path.join(OUT, `${id}-A4-logo-${logo}.png`), (await rasterize(x.result.bytes, 1, SCALE)).toBuffer("image/png"));
}
const sixty = await render({ name: "s60", items: 60, signature: true });
for (let p = 1; p <= Math.min(2, sixty.result.pages); p++) writeFileSync(path.join(OUT, `${id}-A4-60rows-p${p}.png`), (await rasterize(sixty.result.bytes, p, SCALE)).toBuffer("image/png"));
const letter = await render(sample({ logo: "real", logoSample: true }), "LETTER");
writeFileSync(path.join(OUT, `${id}-Letter-tax.png`), (await rasterize(letter.result.bytes, 1, SCALE)).toBuffer("image/png"));
const long = await render({
name: "long",
items: 4,
signature: true,
logo: "real",
logoSample: true,
vendor: { vendorAddress: LONG, vendorEmail: `${TOKEN}@example-studios-india.co.in`, vendorName: TOKEN },
invoice: { clientName: TOKEN, clientAddress: LONG, notes: `Note: ${TOKEN} ${LONG}` },
});
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", "reference");
/**
* 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)`);
}
+540 -9
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "adler2" name = "adler2"
@@ -340,6 +340,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.1" version = "1.12.1"
@@ -503,6 +509,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -526,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -539,10 +555,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -869,6 +891,20 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]] [[package]]
name = "endi" name = "endi"
version = "1.1.1" version = "1.1.1"
@@ -1238,6 +1274,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1409,6 +1456,25 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.2",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -1525,6 +1591,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1535,6 +1602,21 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.21" version = "0.1.21"
@@ -1554,9 +1636,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1703,6 +1787,32 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1859,6 +1969,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2058,6 +2198,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2089,6 +2239,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.20.0" version = "0.20.0"
@@ -2110,6 +2270,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2397,6 +2563,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2700,6 +2872,18 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.42.0" version = "0.42.0"
@@ -2812,21 +2996,31 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"bytes", "bytes",
"encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -2862,6 +3056,20 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rsqlite-vfs" name = "rsqlite-vfs"
version = "0.1.1" version = "0.1.1"
@@ -2887,6 +3095,16 @@ dependencies = [
"sqlite-wasm-rs", "sqlite-wasm-rs",
] ]
[[package]]
name = "rusqlite_migration"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03451d4dc523c0a96d9e412c574670b3f5f184c3988b77a435876be25477bbd3"
dependencies = [
"log",
"rusqlite",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -2915,12 +3133,91 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -2930,6 +3227,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -2987,6 +3293,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.38.0" version = "0.38.0"
@@ -3111,6 +3440,18 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.24.0" version = "3.24.0"
@@ -3208,6 +3549,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.4" version = "1.0.4"
@@ -3332,6 +3689,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.8" version = "1.0.8"
@@ -3395,6 +3758,27 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3416,7 +3800,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3426,7 +3810,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3482,7 +3866,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3658,6 +4042,37 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.21",
"tokio",
"tracing",
"windows-sys 0.61.2",
"zbus",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3"
dependencies = [
"bitflags 2.13.2",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.12.1" version = "2.12.1"
@@ -3668,7 +4083,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3691,7 +4106,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -3877,9 +4292,31 @@ dependencies = [
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -3889,6 +4326,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"libc",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]
@@ -4093,6 +4531,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@@ -4116,6 +4560,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.26"
@@ -4128,6 +4578,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4195,15 +4651,27 @@ version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"flate2",
"image",
"percent-encoding",
"reqwest",
"rusqlite", "rusqlite",
"rusqlite_migration",
"rustls",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-window-state",
"tempfile",
"thiserror 2.0.21", "thiserror 2.0.21",
"tokio",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -4405,6 +4873,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.39.1" version = "0.39.1"
@@ -4569,6 +5046,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -4596,6 +5084,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4885,7 +5382,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -5046,6 +5543,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -5079,6 +5582,19 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.14.2",
"memchr",
"typed-path",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
@@ -5091,6 +5607,21 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]] [[package]]
name = "zvariant" name = "zvariant"
version = "5.15.0" version = "5.15.0"
+21 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
description = "Voiced — offline invoicing desktop app by Bongbetic" description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"] authors = ["Bongbetic"]
edition = "2021" edition = "2021"
rust-version = "1.77" rust-version = "1.95"
# The `_lib` suffix keeps the library name distinct from the binary name on Windows. # The `_lib` suffix keeps the library name distinct from the binary name on Windows.
[lib] [lib]
@@ -21,10 +21,30 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] } rusqlite = { version = "0.40", features = ["bundled"] }
rusqlite_migration = "2.6"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
thiserror = "2" thiserror = "2"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10"
flate2 = "1"
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
percent-encoding = "2"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
# ERPNext integration: all HTTP runs in Rust. TLS is rustls with the `ring` provider only (no aws-lc,
# which does not cross-compile cleanly under cargo-xwin); roots come from the OS via rustls-platform-verifier.
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "http2", "charset", "system-proxy", "json", "multipart", "query"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
tokio = { version = "1", features = ["time"] }
# Desktop-only plugins (not available on mobile targets).
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
[dev-dependencies]
tempfile = "3"
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time"] }
[features] [features]
# Production builds embed the frontend and serve it over the custom asset # Production builds embed the frontend and serve it over the custom asset
+20 -1
View File
@@ -6,6 +6,25 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"dialog:default", "dialog:default",
"opener:default" "opener:default",
"window-state:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{ "path": "$DOCUMENT/**" },
{ "path": "$DOWNLOAD/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$HOME/**" }
]
},
{
"identifier": "opener:allow-reveal-item-in-dir",
"allow": [
{ "path": "$DOCUMENT/**" },
{ "path": "$DOWNLOAD/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$HOME/**" }
]
}
] ]
} }
+289
View File
@@ -0,0 +1,289 @@
//! Content-addressed archive of the searchable PDF produced when an invoice is issued.
//! Files live at `<local data dir>/archive/<sha256>.pdf`; the invoice row keeps the hash.
use super::raw::{header_map, raw_body, required_header, write_atomic, Headers};
use crate::AppState;
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use tauri::ipc::{Request, Response};
use tauri::State;
pub const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
const MAX_FINGERPRINT_LEN: usize = 256;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveStatus {
pub archived: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
}
fn sha256_hex(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// The hash comes from the database, but it ends up in a file path, so check its shape.
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
if !valid {
return Err("The stored archive hash is malformed".to_string());
}
Ok(local_dir.join("archive").join(format!("{sha256}.pdf")))
}
fn parse_invoice_id(headers: &Headers) -> Result<i64, String> {
required_header(headers, "x-invoice-id")?
.trim()
.parse::<i64>()
.map_err(|_| "x-invoice-id must be a number".to_string())
}
pub fn archive_pdf_impl(
conn: &mut Connection,
local_dir: &Path,
bytes: &[u8],
headers: &Headers,
) -> Result<String, String> {
let id = parse_invoice_id(headers)?;
let fingerprint = required_header(headers, "x-fingerprint")?.trim();
if fingerprint.is_empty() || fingerprint.len() > MAX_FINGERPRINT_LEN {
return Err("x-fingerprint must be 1-256 characters".to_string());
}
if !bytes.starts_with(b"%PDF-") {
return Err("The data is not a PDF".to_string());
}
if bytes.len() > MAX_ARCHIVE_BYTES {
return Err("The PDF is larger than the 64 MB archive limit".to_string());
}
let row: Option<(String, Option<String>)> = conn
.query_row(
"SELECT status, archived_pdf_sha256 FROM invoices WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(|e| e.to_string())?;
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" && status != "cancelled" {
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
}
let sha = sha256_hex(bytes);
if let Some(existing) = &existing {
if *existing != sha {
return Err(
"This invoice already has an archived original; the issued PDF cannot be replaced"
.to_string(),
);
}
}
let path = archive_path(local_dir, &sha)?;
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
// Same content, same name; a matching length means the file is already in place.
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
if !present {
write_atomic(&path, bytes)?;
}
if existing.is_none() {
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"UPDATE invoices SET archived_pdf_sha256 = ?1, archived_fingerprint = ?2, archived_at = ?3
WHERE id = ?4",
params![sha, fingerprint, now, id],
)
.map_err(|e| e.to_string())?;
}
Ok(sha)
}
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
let path = archive_path(local_dir, &sha)?;
let bytes = std::fs::read(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"The archived PDF file is missing".to_string()
} else {
format!("Could not read the archived PDF: {e}")
}
})?;
if sha256_hex(&bytes) != sha {
return Err("The archived PDF is corrupted (its checksum no longer matches)".to_string());
}
Ok(bytes)
}
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
let row: Option<Option<String>> = conn
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
.optional()
.map_err(|e| e.to_string())?;
row.ok_or_else(|| "Invoice not found".to_string())
}
pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus, String> {
let row: Option<(Option<String>, Option<String>, Option<String>)> = conn
.query_row(
"SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()
.map_err(|e| e.to_string())?;
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
}
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
#[tauri::command]
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
let bytes = raw_body(&request)?;
let headers = header_map(&request);
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
archive_pdf_impl(&mut conn, &state.local_data_dir, bytes, &headers)
}
#[tauri::command]
pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result<Response, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
read_archive_impl(&conn, &state.local_data_dir, invoice_id).map(Response::new)
}
#[tauri::command]
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
archive_status_impl(&conn, invoice_id)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn conn_with_invoice(status: &str) -> Connection {
let conn = crate::db::open_in_memory().unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at)
VALUES ('INV/2026-001', '2026-04-01', 'Client', ?1, 'now', 'now')",
params![status],
)
.unwrap();
conn
}
fn headers(id: &str) -> Headers {
Headers::from([
("x-invoice-id".to_string(), id.to_string()),
("x-fingerprint".to_string(), "fp-1".to_string()),
])
}
const PDF: &[u8] = b"%PDF-1.7\nbody";
#[test]
fn archives_once_and_is_idempotent() {
let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued");
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
assert_eq!(sha.len(), 64);
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
let status = archive_status_impl(&conn, 1).unwrap();
assert!(status.archived);
assert_eq!(status.sha256.as_deref(), Some(sha.as_str()));
assert_eq!(status.fingerprint.as_deref(), Some("fp-1"));
let first_at = status.archived_at.clone().unwrap();
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
assert_eq!(again, sha);
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
}
#[test]
fn cancelled_invoices_can_be_archived() {
let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("cancelled");
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).is_ok());
}
#[test]
fn different_bytes_for_an_archived_invoice_are_refused() {
let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued");
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
assert!(err.contains("already has an archived original"), "{err}");
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
}
#[test]
fn non_pdf_and_oversize_bodies_are_refused() {
let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued");
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
.unwrap_err()
.contains("not a PDF"));
let mut big = b"%PDF-".to_vec();
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
.unwrap_err()
.contains("64 MB"));
assert!(!archive_status_impl(&conn, 1).unwrap().archived);
}
#[test]
fn drafts_unknown_invoices_and_bad_headers_are_refused() {
let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("draft");
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1"))
.unwrap_err()
.contains("Only an issued invoice"));
assert_eq!(
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("99")).unwrap_err(),
"Invoice not found"
);
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("abc")).is_err());
let mut no_fp = headers("1");
no_fp.remove("x-fingerprint");
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &no_fp).is_err());
assert!(!dir.path().join("archive").exists());
}
#[test]
fn read_archive_detects_corruption_and_missing_files() {
let dir = tempdir().unwrap();
let mut conn = conn_with_invoice("issued");
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
std::fs::remove_file(&file).unwrap();
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
// Archiving the same bytes again restores the file.
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
}
#[test]
fn a_malformed_stored_hash_never_becomes_a_path() {
let dir = tempdir().unwrap();
let conn = conn_with_invoice("issued");
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
.unwrap();
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
}
}
+213 -64
View File
@@ -1,8 +1,10 @@
use crate::AppState; use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine}; use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use tauri::State; use tauri::State;
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
fn safe_kind(kind: &str) -> String { fn safe_kind(kind: &str) -> String {
kind.chars() kind.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
@@ -10,47 +12,106 @@ fn safe_kind(kind: &str) -> String {
.to_lowercase() .to_lowercase()
} }
fn sanitize_name(name: &str) -> String { /// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
name.chars() /// silently drops everything else, so nothing else is accepted.
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_') /// Returns (extension, MIME type).
.collect() pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> {
} if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
Ok(("png", "image/png"))
fn extension_of(path: &str) -> String { } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Path::new(path) Ok(("jpg", "image/jpeg"))
.extension() } else {
.and_then(|e| e.to_str()) Err(FORMAT_ERROR.to_string())
.map(|e| e.to_lowercase())
.unwrap_or_else(|| "png".to_string())
}
fn mime_for(ext: &str) -> &'static str {
match ext {
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"gif" => "image/gif",
_ => "image/png",
} }
} }
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> { fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
let dir = state.data_dir.join("assets"); let dir = data_dir.join("assets");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir) Ok(dir)
} }
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> { /// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
let dir = assets_dir(state)?; /// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
let file = format!( /// outside the assets directory are rejected.
"{}-{}.{}", pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
safe_kind(kind), let raw = Path::new(path);
uuid::Uuid::new_v4().simple(), if path.trim().is_empty() {
ext return Err("Asset path is empty".into());
); }
let path = dir.join(file); if raw.components().any(|c| matches!(c, Component::ParentDir)) {
std::fs::write(&path, bytes).map_err(|e| e.to_string())?; return Err("Asset path must not contain '..'".into());
Ok(path.to_string_lossy().to_string()) }
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
let root = data_dir
.join("assets")
.canonicalize()
.map_err(|e| format!("Asset store is unavailable: {e}"))?;
let canonical = full
.canonicalize()
.map_err(|e| format!("Could not open asset {path}: {e}"))?;
if canonical.starts_with(&root) && canonical != root {
Ok(canonical)
} else {
Err("Asset path is outside the asset store".into())
}
}
/// The form stored in the database: `assets/<file>` relative to the data dir, with
/// forward slashes on every platform.
pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String> {
let canonical = resolve_asset(data_dir, path)?;
let base = data_dir.canonicalize().map_err(|e| e.to_string())?;
let rel = canonical.strip_prefix(&base).map_err(|e| e.to_string())?;
Ok(rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/"))
}
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
let (ext, _) = sniff_image(bytes)?;
let dir = assets_dir(data_dir)?;
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
Ok(format!("assets/{file}"))
}
/// Write `assets/<file_name>` unless it already exists. For content-addressed derivatives, where the
/// same name always means the same bytes. The name must be a plain `[a-z0-9._-]` file name.
pub fn write_named_asset(data_dir: &Path, file_name: &str, bytes: &[u8]) -> Result<String, String> {
let plain = !file_name.is_empty()
&& !file_name.starts_with('.')
&& file_name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_'));
if !plain {
return Err(format!("Invalid asset name: {file_name}"));
}
let target = assets_dir(data_dir)?.join(file_name);
if !target.exists() {
std::fs::write(&target, bytes).map_err(|e| e.to_string())?;
}
Ok(format!("assets/{file_name}"))
}
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
let file = resolve_asset(data_dir, path)?;
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
let (_, mime) = sniff_image(&bytes)?;
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes)))
}
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
let raw = Path::new(path);
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
// Removing something that is already gone is fine, but an escape attempt is not.
if std::fs::symlink_metadata(&full).is_err() {
return Ok(());
}
let target = resolve_asset(data_dir, path)?;
std::fs::remove_file(target).map_err(|e| e.to_string())
} }
/// Copy a user-picked file into the app's asset store and return the stored path. /// Copy a user-picked file into the app's asset store and return the stored path.
@@ -61,56 +122,144 @@ pub fn import_asset(
kind: String, kind: String,
) -> Result<String, String> { ) -> Result<String, String> {
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?; let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
let ext = extension_of(&source_path); write_asset(&state.data_dir, &kind, &bytes)
write_asset(&state, &kind, &ext, &bytes)
} }
/// Used when the frontend already holds the bytes (base64) instead of a path. /// Used when the frontend already holds the bytes (base64) instead of a path.
/// The file name is ignored: the type comes from the bytes.
#[tauri::command] #[tauri::command]
pub fn save_asset_bytes( pub fn save_asset_bytes(
state: State<AppState>, state: State<AppState>,
kind: String, kind: String,
file_name: String, #[allow(unused_variables)] file_name: String,
data_base64: String, data_base64: String,
) -> Result<String, String> { ) -> Result<String, String> {
let bytes = STANDARD let bytes = STANDARD
.decode(data_base64.as_bytes()) .decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ext = extension_of(&sanitize_name(&file_name)); write_asset(&state.data_dir, &kind, &bytes)
write_asset(&state, &kind, &ext, &bytes)
} }
/// Read a stored asset back as a data URI so it can be embedded in the PDF. /// Read a stored asset back as a data URI so it can be embedded in the PDF.
#[tauri::command] #[tauri::command]
pub fn read_asset_data_uri(path: String) -> Result<String, String> { pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; asset_data_uri(&state.data_dir, &path)
let ext = extension_of(&path);
Ok(format!(
"data:{};base64,{}",
mime_for(&ext),
STANDARD.encode(bytes)
))
} }
#[tauri::command] #[tauri::command]
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> { pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
let dir = assets_dir(&state)?; remove_asset_file(&state.data_dir, &path)
let target = PathBuf::from(&path);
// Only allow deleting files inside the managed assets directory.
if target.starts_with(&dir) && target.exists() {
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
}
Ok(())
} }
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path. #[cfg(test)]
#[tauri::command] mod tests {
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> { use super::*;
let bytes = STANDARD use tempfile::tempdir;
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?; const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
if let Some(parent) = Path::new(&path).parent() { const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
std::fs::create_dir_all(parent).ok();
#[test]
fn sniffing_accepts_only_png_and_jpeg() {
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
assert!(sniff_image(b"").is_err());
}
#[test]
fn write_ignores_the_name_and_stores_a_relative_path() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
assert!(dir.path().join(&stored).is_file());
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
let uri = asset_data_uri(dir.path(), &stored).unwrap();
assert!(uri.starts_with("data:image/jpeg;base64,"));
}
#[test]
fn named_assets_are_plain_names_and_not_overwritten() {
let dir = tempdir().unwrap();
let stored = write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"one").unwrap();
assert_eq!(stored, "assets/logo-ab12cd34-print.png");
write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"two").unwrap();
assert_eq!(std::fs::read(dir.path().join(&stored)).unwrap(), b"one");
for bad in ["", ".hidden", "../x.png", "a/b.png", "A.png", "x y.png"] {
assert!(write_named_asset(dir.path(), bad, b"x").is_err(), "{bad}");
}
}
#[test]
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
let dir = tempdir().unwrap();
let assets = dir.path().join("assets");
std::fs::create_dir_all(&assets).unwrap();
std::fs::write(assets.join("liar.jpg"), PNG).unwrap();
let uri = asset_data_uri(dir.path(), "assets/liar.jpg").unwrap();
assert!(uri.starts_with("data:image/png;base64,"));
}
#[test]
fn resolver_accepts_relative_and_inside_absolute_paths() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
let by_relative = resolve_asset(dir.path(), &stored).unwrap();
let absolute = dir.path().join(&stored);
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
assert_eq!(by_relative, by_absolute);
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
}
#[test]
fn resolver_rejects_escapes() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
std::fs::write(dir.path().join("voiced.db"), b"secret").unwrap();
let outside = tempdir().unwrap();
std::fs::write(outside.path().join("x.png"), PNG).unwrap();
assert!(resolve_asset(dir.path(), "../voiced.db").is_err());
assert!(resolve_asset(dir.path(), "assets/../voiced.db").is_err());
assert!(resolve_asset(dir.path(), "voiced.db").is_err());
assert!(resolve_asset(dir.path(), "assets").is_err());
assert!(resolve_asset(dir.path(), "").is_err());
let abs_outside = outside.path().join("x.png");
assert!(resolve_asset(dir.path(), abs_outside.to_str().unwrap()).is_err());
let abs_db = dir.path().join("voiced.db");
assert!(resolve_asset(dir.path(), abs_db.to_str().unwrap()).is_err());
assert!(resolve_asset(dir.path(), "assets/missing.png").is_err());
assert!(resolve_asset(dir.path(), &stored).is_ok());
}
#[cfg(unix)]
#[test]
fn resolver_rejects_symlink_escapes() {
let dir = tempdir().unwrap();
let _ = write_asset(dir.path(), "logo", PNG).unwrap();
let outside = tempdir().unwrap();
let target = outside.path().join("x.png");
std::fs::write(&target, PNG).unwrap();
std::os::unix::fs::symlink(&target, dir.path().join("assets/link.png")).unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join("assets/dir")).unwrap();
assert!(resolve_asset(dir.path(), "assets/link.png").is_err());
assert!(resolve_asset(dir.path(), "assets/dir/x.png").is_err());
assert!(remove_asset_file(dir.path(), "assets/link.png").is_err());
assert!(target.exists());
}
#[test]
fn remove_deletes_inside_and_tolerates_missing() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
remove_asset_file(dir.path(), &stored).unwrap();
assert!(!dir.path().join(&stored).exists());
remove_asset_file(dir.path(), &stored).unwrap();
std::fs::write(dir.path().join("voiced.db"), b"x").unwrap();
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
assert!(dir.path().join("voiced.db").exists());
} }
std::fs::write(&path, bytes).map_err(|e| e.to_string())
} }
File diff suppressed because it is too large Load Diff
+298 -25
View File
@@ -1,8 +1,16 @@
use crate::gst;
use crate::models::Client; use crate::models::Client;
use crate::AppState; use crate::AppState;
use rusqlite::{params, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; use tauri::State;
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
c.payment_terms_days,
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
fn map_client(row: &Row) -> rusqlite::Result<Client> { fn map_client(row: &Row) -> rusqlite::Result<Client> {
Ok(Client { Ok(Client {
id: Some(row.get(0)?), id: Some(row.get(0)?),
@@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
state_code: row.get(4)?, state_code: row.get(4)?,
po_number: row.get(5)?, po_number: row.get(5)?,
created_at: row.get(6)?, created_at: row.get(6)?,
address_line1: row.get(7)?,
address_line2: row.get(8)?,
city: row.get(9)?,
pincode: row.get(10)?,
gst_category: row.get(11)?,
default_notes: row.get(12)?,
payment_terms_days: row.get(13)?,
invoice_count: row.get(14)?,
}) })
} }
#[tauri::command] /// Composes the stored multi-line address from the structured parts: line 1, line 2, then
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> { /// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
let conn = state.db.lock().map_err(|e| e.to_string())?; pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
let state_name = gst::STATES
.iter()
.find(|(c, _)| *c == state_code)
.map(|(_, n)| *n)
.unwrap_or("");
let place = [city.trim(), state_name]
.iter()
.filter(|p| !p.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(", ");
let pin = pincode.trim();
let last = match (place.is_empty(), pin.is_empty()) {
(false, false) => format!("{place} - {pin}"),
(false, true) => place,
(true, false) => pin.to_string(),
(true, true) => String::new(),
};
[line1.trim(), line2.trim(), last.as_str()]
.iter()
.filter(|l| !l.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("\n")
}
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(&format!(
"SELECT id, name, address, gstin, state_code, po_number, created_at "SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
FROM clients ORDER BY name COLLATE NOCASE ASC", ))
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
.query_map([], map_client) .query_map([], map_client)
@@ -32,29 +74,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
Ok(rows) Ok(rows)
} }
#[tauri::command] pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> { let name = client.name.trim().to_string();
let conn = state.db.lock().map_err(|e| e.to_string())?; if name.is_empty() {
return Err("Client name is required".into());
}
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
"" | "NA" => String::new(),
g => {
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
g.to_string()
}
};
let state_code = client.state_code.trim().to_string();
if !state_code.is_empty() && !gst::state_exists(&state_code) {
return Err(format!("\"{state_code}\" is not a valid GST state code"));
}
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
return Err(format!(
"GSTIN starts with state code {}, but the client's state is {}",
&gstin[0..2],
state_code
));
}
if !CATEGORIES.contains(&client.gst_category.as_str()) {
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
}
let pincode = client.pincode.trim().to_string();
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
return Err("Pincode must be 6 digits".into());
}
if let Some(days) = client.payment_terms_days {
if !(0..=365).contains(&days) {
return Err("Payment terms must be between 0 and 365 days".into());
}
}
let line1 = client.address_line1.trim().to_string();
let line2 = client.address_line2.trim().to_string();
let city = client.city.trim().to_string();
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
client.address.clone()
} else {
compose_address(&line1, &line2, &city, &state_code, &pincode)
};
let id = match client.id { let id = match client.id {
Some(id) => { Some(id) => {
conn.execute( let n = conn
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6", .execute(
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id], "UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
default_notes=?11, payment_terms_days=?12
WHERE id=?13",
params![
name,
address,
gstin,
state_code,
client.po_number,
line1,
line2,
city,
pincode,
client.gst_category,
client.default_notes,
client.payment_terms_days,
id
],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if n == 0 {
return Err("Client not found".into());
}
id id
} }
None => { None => {
conn.execute( conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
VALUES (?1, ?2, ?3, ?4, ?5, ?6)", address_line1, address_line2, city, pincode, gst_category, default_notes,
payment_terms_days)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![ params![
client.name, name,
client.address, address,
client.gstin, gstin,
client.state_code, state_code,
client.po_number, client.po_number,
chrono::Utc::now().to_rfc3339() chrono::Utc::now().to_rfc3339(),
line1,
line2,
city,
pincode,
client.gst_category,
client.default_notes,
client.payment_terms_days
], ],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -62,17 +176,176 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
} }
}; };
conn.query_row( conn.query_row(
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1", &format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
params![id], params![id],
map_client, map_client,
) )
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] /// Deleting is allowed even when invoices reference the client: invoices carry their own
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> { /// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
let conn = state.db.lock().map_err(|e| e.to_string())?; /// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
conn.execute("DELETE FROM clients WHERE id = ?1", params![id]) conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
#[tauri::command]
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_clients_impl(&conn)
}
#[tauri::command]
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
save_client_impl(&conn, client)
}
#[tauri::command]
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
delete_client_impl(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::open_in_memory;
fn blank(name: &str) -> Client {
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
}
#[test]
fn compose_address_orders_parts() {
assert_eq!(
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
);
assert_eq!(compose_address("", "", "", "", ""), "");
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
}
#[test]
fn structured_round_trip_composes_address() {
let conn = open_in_memory().unwrap();
let mut c = blank("Acme");
c.address_line1 = "12 MG Road".into();
c.address_line2 = "Floor 2".into();
c.city = "Bengaluru".into();
c.pincode = "560001".into();
c.state_code = "29".into();
c.gstin = "29abcde1234f1zw".into();
c.gst_category = "registered_regular".into();
c.default_notes = "Net 15".into();
c.payment_terms_days = Some(15);
c.po_number = "PO-9".into();
let saved = save_client_impl(&conn, c).unwrap();
assert!(saved.id.is_some());
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
let listed = list_clients_impl(&conn).unwrap();
assert_eq!(listed.len(), 1);
let l = &listed[0];
assert_eq!(l.address_line1, "12 MG Road");
assert_eq!(l.gst_category, "registered_regular");
assert_eq!(l.default_notes, "Net 15");
assert_eq!(l.payment_terms_days, Some(15));
assert_eq!(l.invoice_count, 0);
}
#[test]
fn legacy_address_is_kept_when_no_structured_parts() {
let conn = open_in_memory().unwrap();
let mut c = blank("Legacy");
c.address = "Old style\nfree text".into();
let saved = save_client_impl(&conn, c).unwrap();
assert_eq!(saved.address, "Old style\nfree text");
assert_eq!(saved.gst_category, "unregistered");
// Editing a field other than the address leaves it alone.
let mut edit = saved.clone();
edit.po_number = "PO-1".into();
let again = save_client_impl(&conn, edit).unwrap();
assert_eq!(again.address, "Old style\nfree text");
assert_eq!(again.id, saved.id);
}
#[test]
fn update_changes_the_row_and_unknown_id_fails() {
let conn = open_in_memory().unwrap();
let saved = save_client_impl(&conn, blank("A")).unwrap();
let mut edit = saved.clone();
edit.name = "B".into();
edit.gst_category = "sez".into();
let again = save_client_impl(&conn, edit).unwrap();
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
let mut ghost = blank("Ghost");
ghost.id = Some(999);
assert!(save_client_impl(&conn, ghost).is_err());
}
#[test]
fn validation_rejects_bad_input() {
let conn = open_in_memory().unwrap();
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
let mut c = blank("X");
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
let mut c = blank("X");
c.gstin = "29ABCDE1234F1ZW".into();
c.state_code = "27".into();
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
let mut c = blank("X");
c.gstin = "NA".into();
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
let mut c = blank("X");
c.state_code = "99".into();
assert!(save_client_impl(&conn, c).is_err());
let mut c = blank("X");
c.gst_category = "weird".into();
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
let mut c = blank("X");
c.pincode = "56001".into();
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
let mut c = blank("X");
c.payment_terms_days = Some(400);
assert!(save_client_impl(&conn, c).is_err());
// Only the valid "NA" client above was stored; every rejected save left no row behind.
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
}
#[test]
fn delete_keeps_referencing_invoices_and_count_is_listed() {
let conn = open_in_memory().unwrap();
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
let id = saved.id.unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
params![id],
)
.unwrap();
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
delete_client_impl(&conn, id).unwrap();
assert!(list_clients_impl(&conn).unwrap().is_empty());
let (name, addr): (String, String) = conn
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Tauri commands for the ERPNext settings: read/save the configuration, test a connection and load the
//! lists the settings dropdowns need. The API secret never leaves this module: `erpnext_get_config` returns
//! `apiSecretSet` only, and every command resolves a blank secret to the stored one on the Rust side.
//! The push commands send issued invoices and payments (see `integrations::erpnext::push`); the database lock is
//! never held across a network call.
use crate::integrations::erpnext::client::ErpClient;
use crate::integrations::erpnext::config::{self, ErpnextConfig, ErpnextConfigInput, ErpnextConfigView};
use crate::integrations::erpnext::discovery::{self, ic_number_ok, ConnectionTest, ErpnextOptions, LocalFacts};
use crate::integrations::erpnext::push::{self, PaymentPushResult, PushResult};
use crate::integrations::SyncStatus;
use crate::AppState;
use rusqlite::Connection;
use tauri::State;
pub fn get_config_impl(conn: &Connection) -> Result<ErpnextConfigView, String> {
Ok(config::load(conn)?.view())
}
/// Merges the form with the stored row (blank secret keeps the stored one) without saving.
pub fn resolve_config(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfig, String> {
let stored = config::load(conn)?;
input.resolve(&stored)
}
pub fn save_config_impl(conn: &Connection, input: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
let cfg = resolve_config(conn, input)?;
config::save(conn, &cfg)?;
Ok(cfg.view())
}
/// What the connection test needs from the local database.
pub fn local_facts(conn: &Connection) -> Result<LocalFacts, String> {
let registration: String = conn
.query_row("SELECT gst_registration FROM app_settings WHERE id = 1", [], |r| r.get(0))
.map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT number FROM invoices ORDER BY id")
.map_err(|e| e.to_string())?;
let invalid_numbers = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?
.into_iter()
.filter(|n| !ic_number_ok(n))
.take(5)
.collect();
let next_number = conn
.query_row(
"SELECT prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
[],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)),
)
.ok()
.map(|(prefix, padding, next)| crate::db::format_number(&prefix, padding, next));
Ok(LocalFacts { vendor_registered: registration != "unregistered", invalid_numbers, next_number })
}
#[tauri::command]
pub fn erpnext_get_config(state: State<AppState>) -> Result<ErpnextConfigView, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
get_config_impl(&conn)
}
/// A blank `apiSecret` keeps the stored secret; a value replaces it; `clearSecret` removes it.
#[tauri::command]
pub fn erpnext_save_config(state: State<AppState>, config: ErpnextConfigInput) -> Result<ErpnextConfigView, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
save_config_impl(&conn, config)
}
/// Tests the form values as typed (saved or not). The database lock is released before any network call.
#[tauri::command]
pub async fn erpnext_test_connection(
state: State<'_, AppState>,
config: ErpnextConfigInput,
) -> Result<ConnectionTest, String> {
let (cfg, local) = {
let conn = state.db.lock().map_err(|e| e.to_string())?;
(resolve_config(&conn, config)?, local_facts(&conn)?)
};
let client = ErpClient::from_config(&cfg)?;
Ok(discovery::test_connection(&client, &cfg, &local).await?)
}
/// Loads the dropdown lists. Company-scoped lists use `config.company`.
#[tauri::command]
pub async fn erpnext_load_options(
state: State<'_, AppState>,
config: ErpnextConfigInput,
) -> Result<ErpnextOptions, String> {
let cfg = {
let conn = state.db.lock().map_err(|e| e.to_string())?;
resolve_config(&conn, config)?
};
let client = ErpClient::from_config(&cfg)?;
Ok(client.load_options(&cfg.company).await?)
}
/// A client for the saved settings, or a readable reason why there is none.
fn saved_client(state: &AppState) -> Result<ErpClient, String> {
let cfg = {
let conn = state.db.lock().map_err(|e| e.to_string())?;
config::load(&conn)?
};
if cfg.base_url.trim().is_empty() || cfg.api_key.trim().is_empty() || !cfg.api_secret.is_set() {
return Err("ERPNext is not set up yet: enter the address, API key and API secret in Settings.".into());
}
Ok(ErpClient::from_config(&cfg)?)
}
/// Sends one issued invoice. `submit` omitted follows the "submit on push" setting. A failure comes back as a
/// result with `ok: false` (and is recorded), not as an `Err`.
#[tauri::command]
pub async fn erpnext_push_invoice(
state: State<'_, AppState>,
id: i64,
submit: Option<bool>,
) -> Result<PushResult, String> {
let http = saved_client(&state)?;
Ok(push::push_invoice(&state.db, &state.local_data_dir, &http, id, submit).await)
}
/// Sends several invoices one after another; one row's failure never stops the others.
#[tauri::command]
pub async fn erpnext_push_invoices(
state: State<'_, AppState>,
ids: Vec<i64>,
submit: Option<bool>,
) -> Result<Vec<PushResult>, String> {
let http = saved_client(&state)?;
Ok(push::push_invoices(&state.db, &state.local_data_dir, &http, &ids, submit).await)
}
#[tauri::command]
pub async fn erpnext_push_payment(state: State<'_, AppState>, payment_id: i64) -> Result<PaymentPushResult, String> {
let http = saved_client(&state)?;
Ok(push::push_payment(&state.db, &http, payment_id).await)
}
#[tauri::command]
pub fn erpnext_sync_status(state: State<AppState>, id: i64) -> Result<SyncStatus, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
push::sync_status(&conn, id)
}
/// The sync state of every invoice that has one, for the History list.
#[tauri::command]
pub fn erpnext_sync_statuses(state: State<AppState>) -> Result<Vec<SyncStatus>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
push::sync_statuses(&conn)
}
/// `<base>/app/sales-invoice/<name>` for an invoice that was sent.
#[tauri::command]
pub fn erpnext_open_url(state: State<AppState>, id: i64) -> Result<String, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
push::open_url(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn input(extra: serde_json::Value) -> ErpnextConfigInput {
let mut base = json!({ "baseUrl": "https://erp.example.com", "apiKey": "key1", "company": "Test Co" });
base.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
serde_json::from_value(base).unwrap()
}
#[test]
fn saved_config_is_returned_without_the_secret() {
let conn = crate::db::open_in_memory().unwrap();
let view = save_config_impl(&conn, input(json!({ "apiSecret": "super-secret-value", "submitOnPush": true }))).unwrap();
assert!(view.api_secret_set);
let shown = serde_json::to_string(&get_config_impl(&conn).unwrap()).unwrap();
assert!(!shown.contains("super-secret-value"));
assert!(shown.contains("\"apiSecretSet\":true"));
assert!(shown.contains("\"company\":\"Test Co\""));
// Saving again with a blank secret keeps it for the connection test too.
save_config_impl(&conn, input(json!({ "company": "Other Co" }))).unwrap();
let resolved = resolve_config(&conn, input(json!({}))).unwrap();
assert_eq!(resolved.api_secret.expose(), "super-secret-value");
assert_eq!(resolved.company, "Test Co");
}
#[test]
fn test_values_are_resolved_without_saving() {
let conn = crate::db::open_in_memory().unwrap();
let resolved = resolve_config(&conn, input(json!({ "apiSecret": "typed-not-saved" }))).unwrap();
assert_eq!(resolved.api_secret.expose(), "typed-not-saved");
assert!(!get_config_impl(&conn).unwrap().api_secret_set);
}
#[test]
fn local_facts_report_registration_numbers_and_the_next_number() {
let conn = crate::db::open_in_memory().unwrap();
let facts = local_facts(&conn).unwrap();
assert!(!facts.vendor_registered);
assert!(facts.invalid_numbers.is_empty());
assert!(facts.next_number.is_some());
conn.execute("UPDATE app_settings SET gst_registration = 'regular'", []).unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
VALUES ('INV/2026-001', '2026-04-01', 'n', 'n'), ('INVOICE/2026/000001', '2026-04-01', 'n', 'n')",
[],
)
.unwrap();
let facts = local_facts(&conn).unwrap();
assert!(facts.vendor_registered);
assert_eq!(facts.invalid_numbers, ["INVOICE/2026/000001"]);
}
}
+211
View File
@@ -0,0 +1,211 @@
//! User-visible files: exports written to a path from the save dialog, and the
//! reveal/open helpers used afterwards.
use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers};
use crate::AppState;
use rusqlite::{params, Connection};
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::ipc::Request;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
pub fn write_export_file_impl(
conn: &Connection,
bytes: &[u8],
headers: &Headers,
) -> Result<String, String> {
let raw = decode_header_path(required_header(headers, "x-path")?)?;
if raw.trim().is_empty() {
return Err("No file path given".to_string());
}
let path = PathBuf::from(&raw);
if !path.is_absolute() {
return Err("The export path must be absolute".to_string());
}
if bytes.is_empty() {
return Err("Nothing to write: the file is empty".to_string());
}
let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("Could not create {}: {e}", parent.display()))?;
write_atomic(&path, bytes)?;
conn.execute(
"UPDATE app_settings SET last_export_dir = ?1 WHERE id = 1",
params![parent.to_string_lossy()],
)
.map_err(|e| e.to_string())?;
Ok(raw)
}
pub fn get_last_export_dir_impl(conn: &Connection) -> Result<String, String> {
conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0))
.map_err(|e| e.to_string())
}
fn require_existing(path: &str) -> Result<PathBuf, String> {
if path.trim().is_empty() {
return Err("No file path given".to_string());
}
let path = PathBuf::from(path);
if !path.exists() {
return Err(format!("{} does not exist", path.display()));
}
Ok(path)
}
/// Last resort when the opener plugin cannot reach a file manager (a minimal Linux
/// install has no D-Bus file-manager service or portal). Arguments are passed
/// individually; nothing goes through a shell.
fn reveal_fallback_command(path: &Path) -> Command {
#[cfg(target_os = "windows")]
{
let mut cmd = Command::new("explorer");
cmd.arg(format!("/select,{}", path.display()));
cmd
}
#[cfg(target_os = "macos")]
{
let mut cmd = Command::new("open");
cmd.arg("-R").arg(path);
cmd
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
let mut cmd = Command::new("xdg-open");
cmd.arg(folder);
cmd
}
}
fn open_fallback_command(path: &Path) -> Command {
#[cfg(target_os = "windows")]
let mut cmd = Command::new("explorer");
#[cfg(target_os = "macos")]
let mut cmd = Command::new("open");
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let mut cmd = Command::new("xdg-open");
cmd.arg(path);
cmd
}
fn spawn_detached(mut cmd: Command) -> Result<(), String> {
let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?;
// Reap the child so it does not linger as a zombie; the result is irrelevant
// (explorer.exe, for one, exits non-zero even on success).
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
#[tauri::command]
pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
let bytes = raw_body(&request)?;
let headers = header_map(&request);
let conn = state.db.lock().map_err(|e| e.to_string())?;
write_export_file_impl(&conn, bytes, &headers)
}
#[tauri::command]
pub fn get_last_export_dir(state: State<AppState>) -> Result<String, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
get_last_export_dir_impl(&conn)
}
#[tauri::command]
pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> {
let path = require_existing(&path)?;
if app.opener().reveal_item_in_dir(&path).is_ok() {
return Ok(());
}
spawn_detached(reveal_fallback_command(&path))
}
#[tauri::command]
pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
let path = require_existing(&path)?;
if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() {
return Ok(());
}
spawn_detached(open_fallback_command(&path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn headers(path: &str) -> Headers {
Headers::from([("x-path".to_string(), path.to_string())])
}
fn encode(path: &Path) -> String {
percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC)
.to_string()
}
#[test]
fn writes_the_file_and_remembers_the_folder() {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
// Non-ASCII, spaces and a folder that does not exist yet.
let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf");
let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap();
assert_eq!(PathBuf::from(&written), target);
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7");
assert_eq!(
get_last_export_dir_impl(&conn).unwrap(),
target.parent().unwrap().to_string_lossy()
);
// Overwrites in place.
write_export_file_impl(&conn, b"%PDF-2", &headers(&encode(&target))).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-2");
}
#[test]
fn rejects_empty_relative_and_missing_paths() {
let conn = crate::db::open_in_memory().unwrap();
for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] {
assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}");
}
assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err());
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
}
#[test]
fn rejects_an_empty_body_without_touching_the_folder_setting() {
let dir = tempdir().unwrap();
let conn = crate::db::open_in_memory().unwrap();
let target = dir.path().join("a.pdf");
assert!(write_export_file_impl(&conn, b"", &headers(&encode(&target))).is_err());
assert!(!target.exists());
assert_eq!(get_last_export_dir_impl(&conn).unwrap(), "");
}
#[test]
fn reveal_and_open_refuse_missing_paths() {
let dir = tempdir().unwrap();
assert!(require_existing("").is_err());
let missing = dir.path().join("nope.pdf");
assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist"));
assert!(require_existing(&dir.path().to_string_lossy()).is_ok());
}
#[cfg(target_os = "linux")]
#[test]
fn linux_fallback_opens_the_parent_folder_with_separate_args() {
let dir = tempdir().unwrap();
let file = dir.path().join("a b; rm -rf.pdf");
std::fs::write(&file, b"x").unwrap();
let cmd = reveal_fallback_command(&file);
assert_eq!(cmd.get_program(), "xdg-open");
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, [dir.path().as_os_str()]);
let open = open_fallback_command(&file);
assert_eq!(open.get_args().collect::<Vec<_>>(), [file.as_os_str()]);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More