feat(templates): add the ConsultingInvoice preset and the structured invoice model - #611
Merged
DemchaAV merged 3 commits intoAug 30, 2026
Merged
Conversation
The invoice family's data layer knew one shape - an invoice as pre-formatted display strings, with one address block per party, line items whose quantity and money are already rendered, and a flat list of summary rows. That cannot carry the structured business invoice: a brand lockup with the sender's own logo, labelled masthead metadata, a contact block with a business registration, priced service lines carrying BigDecimal figures and the unit they are counted in, a totals stack with its own total band, bank payment fields, and a footer line. templates.data.invoice now carries that second model - StructuredInvoiceData (+ its section records) wrapped by StructuredInvoiceDocumentSpec - alongside the display one; a preset consumes the model whose shape it renders. The brand logo arrives as DocumentImageData: the logo is caller-supplied content, not template chrome, and it stays optional so a wordmark-only lockup composes. Every component normalizes null to its empty form, money and quantities default to zero, and collections are frozen, matching the family's existing records.
The invoice family rendered one look on a display model that carries an invoice as pre-formatted strings. This adds both halves of the structured business invoice: a second data model and the first preset that renders it. The model - StructuredInvoiceData and its section records, wrapped by StructuredInvoiceDocumentSpec - carries a brand lockup with the sender's own logo, labelled masthead metadata, a contact block with a business registration, priced service lines with BigDecimal figures and the unit they are counted in, a totals stack with its own total band, and bank payment fields. The logo arrives as DocumentImageData because it is caller-supplied content rather than template chrome, and it stays optional. Every component normalizes null to its empty form, money and quantities default to zero, and collections are frozen. The preset - invoice/presets/ConsultingInvoice - composes the corporate masthead over the priced lines, with the bank details beside the notes and the due-by chip, and owns its session geometry: A4, the published margin, the page fills, and the legal line and page numbers as footer chrome. That line is composed from the supplier's registered name and registration, so a document states them once. The contact marks, bank badge and calendar ship inside the artifact; a document without a logo falls back to the wordmark lockup, and a channel, currency, registration or attention line the sender omits is left out rather than printed empty. qa: one fixtures class feeds the smoke, the exact layout snapshots (single page and a 26-line overflow) and the pixel gate; the smoke drives full layout and render and asserts the priced figures, the repeated table header and the omission guards on the text layer, where the snapshot cannot see inside the table. Examples gain ConsultingInvoiceV2Example (invoice-consulting-v2) with its preview.
| /** | ||
| * Normalizes optional fields and freezes the address lines. | ||
| */ | ||
| public InvoiceContactBlock { |
Comment on lines
+116
to
+117
| .addRow("Masthead", row -> row | ||
| .gap(HEADER_GAP) |
Comment on lines
+125
to
+126
| .addRow("PartiesAndSummary", row -> row | ||
| .gap(35) |
Comment on lines
+141
to
+142
| .addRow("PaymentAndNotes", row -> row | ||
| .gap(LOWER_GAP) |
… feat/consulting-invoice-preset # Conflicts: # CHANGELOG.md # examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The invoice family renders one look, and its data layer knows one shape:
InvoiceDatacarries an invoice as pre-formatted display strings — one address block per party, line items whose quantity and money are already rendered, a flat list of summary rows. The professional-services invoice cannot be said in it: a brand lockup with the sender's own logo, labelled masthead metadata, priced lines with a service period and a unit each, a tax breakdown over numeric figures, bank payment fields. That look exists today only as a published standalone template pinned tograph-compose:2.2.0with its assets on the filesystem.Commits, in review order
1.
78d0f9b0— the structured invoice document model.templates.data.invoicegains a second model beside the display one:StructuredInvoiceData(fluent builder) wrapped byStructuredInvoiceDocumentSpec, withInvoiceBrand,InvoiceContactBlock,InvoiceMasthead(+Entry, one flag for the emphasized due date),InvoiceRecipient,InvoiceSummaryBlock,InvoiceServiceLines(+Columns, +LinewithBigDecimalquantity/price/amount and the unit),InvoiceTotalsBlock(+Row, and the total as its own labelled band rather than the last row by convention),InvoicePaymentBlock(+Field) andInvoiceNotesBlock. Labels and headings are content, so one composition prints another business's wording without a fork. The brand logo arrives asDocumentImageData— it is caller-supplied content, not template chrome — and stays optional. Normalization matches the family's existing records:null→ empty form, money and quantities → zero, collections frozen. The display model is untouched; each near-duplication with it is justified in Javadoc.2.
92903f74— theConsultingInvoicepreset. ADocumentTemplate<StructuredInvoiceDocumentSpec>viacreate(), split for the 500-LOC rule into package-privateConsultingStyles(measured geometry and the Poppins scale),ConsultingText,ConsultingIcons,ConsultingMasthead,ConsultingBodyandConsultingClosing. It owns its session geometry — A4, the margin published asRECOMMENDED_MARGIN, the page fills — and draws the legal line and page numbers as FOOTER-zone chrome, so they repeat when the table paginates. The legal line is composed from the supplier's registered name and registration rather than restated in a second record. The six contact/bank/calendar marks ship inside the artifact undertemplates/invoice/consulting/icons/.Named for the look, not the sample company: the family already reads
ModernInvoice/ClassicInvoice, and the ported bundle's brand is fixture data.Verification
Full reactor gate (the eight CI modules) → BUILD SUCCESS; consulting qa gates 12/12; examples suite 75/75 including
CommittedAssetDriftTestwith the new preview;javadoc:javadoc→ 0 warnings.Port parity, verified on one machine against the published template itself: the preset fed the template's own fixture is pixel-identical to the standalone render — 0 / 2,173,720 differing pixels — with node-identical geometry (95/95). The bundle also renders identically on its pinned 2.2.0 and on develop, so there is no engine drift to account for: the approved preview is the target.
Notes
graph-compose-fonts, which the templates module does not depend on, so a caller that adds only the templates artifact measures in the platform'sSansSerif— documented on the field, including that such a caller already renders the whole document in a substituted face. The engine does implement a hard break for inline runs (ParagraphWrapping.tokenizeInlineRuns) but sanitizes before splitting, so the newline is stripped first; swapping those two steps would retire this mechanism, and that is a shared-engine change for its own PR.(), an unregistered supplier no dangling separator, a recipient without an attention line no blank line, a brand without a qualifier no bare rules, and the bank band is sized from its field count rather than fixed at five fields. Each is asserted on the text layer.Lane: canonical (templates.data.invoice + templates.invoice.presets + qa + examples) — no engine changes.