Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ follow semantic versioning; release dates are ISO 8601.

### Templates

- **A second invoice preset: `ClassicInvoice`.** The letterhead-style invoice — a header
band with the company name and a 28pt INVOICE title, a TOTAL DUE hero strip,
BILL TO / FROM party columns, and a dedicated Summary table composed after the
line items (subtotal / tax / TOTAL, the last row emphasized) — now ships as
`invoice.presets.ClassicInvoice` on the layered stack, with the same
`create()` / `create(BrandTheme)` contract as `ModernInvoice`, porting the rendered
layout of the published standalone `invoice-classic` template. Guarded by a smoke
test, exact layout snapshots (the canonical single page plus a forty-line-item
overflow that freezes the two-page table continuation), and the invoice pixel-parity
gate; the examples showcase gains `invoice-classic-v2`.

- **Monogram Sidebar draws the employer.** Its experience entries rendered the position,
the date and the description, and never `CvEntry.subtitle()` — so every company name
was missing from the rendered CV while the education block, which does render its
Expand Down
Binary file added assets/readme/examples/invoice-classic-v2.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
import com.demcha.examples.templates.cv.v2.CvPanelExample;
import com.demcha.examples.templates.cv.v2.CvSidebarPortraitExample;
import com.demcha.examples.templates.cv.v2.CvTimelineMinimalExample;
import com.demcha.examples.templates.invoice.ClassicInvoiceV2Example;
import com.demcha.examples.templates.invoice.InvoiceCinematicFileExample;
import com.demcha.examples.templates.invoice.ModernInvoiceV2Example;
import com.demcha.examples.templates.proposal.CinematicProposalFileExample;
Expand Down Expand Up @@ -162,6 +163,7 @@ public static void main(String[] args) throws Exception {
// Invoices
System.out.println("Generated: " + InvoiceCinematicFileExample.generate());
System.out.println("Generated: " + ModernInvoiceV2Example.generate());
System.out.println("Generated: " + ClassicInvoiceV2Example.generate());

// Proposals
System.out.println("Generated: " + ProposalCinematicFileExample.generate());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ record Entry(String title, String description, List<String> tags, String codeUrl
// ===== Templates / Invoice =====
invoice("invoice-cinematic", "InvoiceCinematicFileExample", "Cinematic Invoice", "Layered ModernInvoice preset with theme-driven layout, advanced tables, and totals.", "invoice", "cinematic");
invoice("invoice-modern-v2", "v2/ModernInvoiceV2Example", "Modern Invoice", "The ModernInvoice preset composed straight from an InvoiceDocumentSpec — line items, totals and payment block driven by the BrandTheme rather than per-document styling.", "invoice");
invoice("invoice-classic-v2", "v2/ClassicInvoiceV2Example", "Classic Invoice", "The ClassicInvoice preset — letterhead header band, TOTAL DUE hero strip, BILL TO / FROM columns, and a dedicated Summary table after the line items.", "invoice");

// ===== Templates / Proposal =====
proposal("proposal-cinematic", "ProposalCinematicFileExample", "Cinematic Proposal", "Layered ModernProposal layout with cover panel, hero spread, and rich typography.", "proposal", "cinematic");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.demcha.examples.templates.invoice;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
import com.demcha.compose.document.templates.invoice.presets.ClassicInvoice;
import com.demcha.examples.support.ExampleDataFactory;
import com.demcha.examples.support.ExampleOutputPaths;

import java.nio.file.Path;

/**
* Renders the layered {@code invoice.v2} Classic Invoice preset against
* the shared {@code InvoiceDocumentSpec} sample data using the default
* {@code BrandTheme.invoiceModern()} theme.
*
* <p>Output:
* {@code examples/target/generated-pdfs/templates/invoice/invoice-classic-v2.pdf}.</p>
*
* <p>The preset pads the page flow itself, so the session margin stays at
* {@code ClassicInvoice.RECOMMENDED_MARGIN} (zero) and the page keeps its
* plain white background — the letterhead look of the preset.</p>
*/
public final class ClassicInvoiceV2Example {

private ClassicInvoiceV2Example() {
}

/**
* @return absolute path of the rendered PDF
* @throws Exception if rendering fails
*/
public static Path generate() throws Exception {
Path outputFile = ExampleOutputPaths.prepare(
"templates/invoice", "invoice-classic-v2.pdf");
InvoiceDocumentSpec spec = ExampleDataFactory.sampleInvoice();
DocumentTemplate<InvoiceDocumentSpec> template =
ClassicInvoice.create(BrandTheme.invoiceModern());

float m = (float) ClassicInvoice.RECOMMENDED_MARGIN;
try (DocumentSession document = GraphCompose.document(outputFile)
.pageSize(DocumentPageSize.A4)
.margin(m, m, m, m)
.create()) {
template.compose(document, spec);
document.buildPdf();
}
return outputFile;
}

/**
* @param args ignored
* @throws Exception if rendering fails
*/
public static void main(String[] args) throws Exception {
System.out.println("Generated: " + generate());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.demcha.compose.document.templates.invoice.presets;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.TemplateTestSupport;
import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Exact layout snapshot gate for {@link ClassicInvoice} — freezes the
* resolved node geometry the pixel budget cannot see (a small column or
* spacing shift stays under the visual-diff budget but changes the
* snapshot), and the pagination contract of the overflow invoice: the
* forty-line-item fixture flows onto a second page with the repeated
* table header, and the summary + footer follow the table.
*
* <p>The multi-page contract is guarded here at snapshot level only —
* full-page pixel baselines drift across platforms far more than the
* geometry they would guard (see the budget note in
* {@code InvoiceV2VisualParityTest}), while the snapshot is exact on
* every platform.</p>
*
* <p>Refresh with {@code -Dgraphcompose.updateSnapshots=true} after a
* deliberate layout change, and commit the JSON with the change.</p>
*/
class ClassicInvoiceLayoutSnapshotTest {

private static DocumentSession open() {
float m = (float) ClassicInvoice.RECOMMENDED_MARGIN;
return GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(m, m, m, m)
.create();
}

@Test
void canonicalInvoiceMatchesLayoutSnapshot() throws Exception {
try (DocumentSession session = open()) {
ClassicInvoice.create().compose(session, InvoicePresetFixtures.canonicalInvoice());
assertThat(session.layoutSnapshot().totalPages()).isEqualTo(1);
TemplateTestSupport.assertCanonicalSnapshot(
session, "classic_invoice_layout", "invoice");
}
}

@Test
void overflowInvoicePaginatesOntoSecondPage() throws Exception {
try (DocumentSession session = open()) {
ClassicInvoice.create().compose(session, InvoicePresetFixtures.stressInvoice());
assertThat(session.layoutSnapshot().totalPages()).isEqualTo(2);
TemplateTestSupport.assertCanonicalSnapshot(
session, "classic_invoice_stress_layout", "invoice");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.demcha.compose.document.templates.invoice.presets;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
import com.demcha.compose.document.templates.data.invoice.InvoiceData;
import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Smoke test for the layered invoice pipeline through
* {@link ClassicInvoice} — proves the preset renders an
* {@link InvoiceDocumentSpec} end-to-end on a {@link BrandTheme}, via
* both factory variants, with any theme, and on an empty invoice.
*/
class ClassicInvoiceSmokeTest {

/** An invoice with no line items, summaries, notes, footer, or status — the empty paths. */
private static InvoiceDocumentSpec minimalSpec() {
return InvoiceDocumentSpec.from(InvoiceData.builder()
.invoiceNumber("GC-2026-002")
.fromParty(from -> from.name("GraphCompose Studio"))
.billToParty(to -> to.name("Northwind Systems"))
.build());
}

private static void render(DocumentTemplate<InvoiceDocumentSpec> template,
InvoiceDocumentSpec spec) throws Exception {
float m = (float) ClassicInvoice.RECOMMENDED_MARGIN;
try (DocumentSession session = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(m, m, m, m)
.create()) {
template.compose(session, spec);
assertThat(session.roots()).isNotEmpty();
// Drive layout + render, not just composition — the zero-row
// summary table of an empty invoice only exists at layout time.
assertThat(session.toPdfBytes()).isNotEmpty();
}
}

@Test
void exposesStableIdentity() {
DocumentTemplate<InvoiceDocumentSpec> template = ClassicInvoice.create();
assertThat(template.id()).isEqualTo(ClassicInvoice.ID);
assertThat(template.displayName()).isEqualTo(ClassicInvoice.DISPLAY_NAME);
}

@Test
void defaultFactoryRendersWithInvoiceTheme() throws Exception {
// create() wires BrandTheme.invoiceModern() — the variant the example uses.
render(ClassicInvoice.create(), InvoicePresetFixtures.canonicalInvoice());
}

@Test
void rendersWithExplicitTheme() throws Exception {
render(ClassicInvoice.create(BrandTheme.invoiceModern()),
InvoicePresetFixtures.canonicalInvoice());
}

@Test
void readsAnyTheme() throws Exception {
// Renders under a non-invoice theme without crashing: the header,
// hero, labels, and footer follow the theme; the line-item body
// cells inherit the DSL default (as in ModernInvoice).
render(ClassicInvoice.create(BrandTheme.boxedClassic()),
InvoicePresetFixtures.canonicalInvoice());
}

@Test
void rendersEmptyInvoice() throws Exception {
// Exercises the empty-collection paths through full layout and
// render: the skipped Summary section (the engine rejects a
// zero-row table), the empty note / payment lists under their
// always-rendered headings, the skipped footer-note paragraph,
// and the em-dash status fallback.
render(ClassicInvoice.create(), minimalSpec());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.demcha.compose.document.templates.invoice.presets;

import com.demcha.compose.document.templates.data.invoice.InvoiceData;
import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;

/**
* Shared fixture data for the invoice preset gates — the SAME specs feed
* the pixel parity test and the layout snapshot test, so a geometry shift
* the pixel budget absorbs still trips the exact snapshot, and vice versa.
*/
final class InvoicePresetFixtures {

private InvoicePresetFixtures() {
}

/**
* Canonical sample invoice — exercises the hero, both parties, a
* multi-row line-items table, subtotal / tax / total summary, and the
* notes / payment-terms footer. Kept in qa so the gates depend only
* on main + main-test code.
*/
static InvoiceDocumentSpec canonicalInvoice() {
return InvoiceDocumentSpec.from(InvoiceData.builder()
.title("Invoice")
.invoiceNumber("GC-2026-041")
.issueDate("02 Apr 2026")
.dueDate("16 Apr 2026")
.status("Pending")
.fromParty(from -> from
.name("GraphCompose Studio")
.addressLines("18 Layout Street", "London, UK", "EC1A 4GC")
.email("billing@graphcompose.dev")
.phone("+44 20 5555 1000")
.taxId("GB-99887766"))
.billToParty(to -> to
.name("Northwind Systems")
.addressLines("Attn: Finance Team", "410 Market Avenue", "Manchester, UK")
.email("ap@northwind.example")
.phone("+44 161 555 2200")
.taxId("NW-2026-01"))
.lineItem("Discovery workshop", "Stakeholder interviews",
"1", "GBP 1,450", "GBP 1,450")
.lineItem("Template architecture", "Reusable document flows",
"2", "GBP 980", "GBP 1,960")
.lineItem("Render QA", "Cross-platform pixel diffing",
"3", "GBP 320", "GBP 960")
.lineItem("Developer enablement", "Authoring docs + examples",
"1", "GBP 780", "GBP 780")
.summaryRow("Subtotal", "GBP 5,150")
.summaryRow("VAT (20%)", "GBP 1,030")
.totalRow("Total", "GBP 6,180")
.note("Please include the invoice number on your remittance advice.")
.note("All work was delivered as agreed during the April implementation window.")
.paymentTerm("Payment due within 14 calendar days.")
.paymentTerm("Bank transfer preferred; contact billing@graphcompose.dev for remittance details.")
.paymentTerm("Late payments may delay additional template customization work.")
.footerNote("Thank you for choosing GraphCompose for production document rendering.")
.build());
}

/**
* Overflow invoice — forty line items so the table paginates naturally
* and the repeated header + cross-page flow become part of the frozen
* contract. No manual page breaks.
*/
static InvoiceDocumentSpec stressInvoice() {
String[] services = {
"Discovery workshop", "Design system audit", "Template architecture",
"Layout engine tuning", "Render QA pass", "Accessibility review",
"Font pipeline setup", "Chart integration", "Data mapping",
"Pagination hardening", "Visual regression wiring", "Docs authoring",
"Stakeholder demo", "Performance profiling", "Release engineering",
"Support retainer"};
InvoiceData.Builder builder = InvoiceData.builder()
.title("Invoice")
.invoiceNumber("GC-2026-042")
.issueDate("04 May 2026")
.dueDate("18 May 2026")
.status("Pending")
.fromParty(from -> from
.name("GraphCompose Studio")
.addressLines("18 Layout Street", "London, UK", "EC1A 4GC")
.email("billing@graphcompose.dev")
.phone("+44 20 5555 1000"))
.billToParty(to -> to
.name("Northwind Systems")
.addressLines("Attn: Finance Team", "410 Market Avenue", "Manchester, UK")
.email("ap@northwind.example")
.phone("+44 161 555 2200"));
for (int i = 0; i < 40; i++) {
String service = services[i % services.length];
int quantity = 1 + i % 4;
int unit = 180 + (i * 37) % 900;
builder.lineItem(
service + " — sprint " + (1 + i / services.length),
"Delivered under the framework agreement",
String.valueOf(quantity),
"GBP " + unit,
"GBP " + (quantity * unit));
}
return InvoiceDocumentSpec.from(builder
.summaryRow("Subtotal", "GBP 44,890")
.summaryRow("VAT (20%)", "GBP 8,978")
.totalRow("Total", "GBP 53,868")
.note("Sprints 1-3 were delivered under the framework agreement.")
.note("Hardware and third-party licences are billed separately.")
.paymentTerm("Payment due within 14 calendar days.")
.paymentTerm("Bank transfer preferred; contact billing@graphcompose.dev for remittance details.")
.footerNote("Thank you for choosing GraphCompose for production document rendering.")
.build());
}
}
Loading
Loading