diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index 2a19aa455..9167a1a35 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -81,6 +81,7 @@
import com.demcha.examples.templates.cv.v2.CvTimelineMinimalExample;
import com.demcha.examples.templates.invoice.InvoiceCinematicFileExample;
import com.demcha.examples.templates.invoice.InvoiceFileExample;
+import com.demcha.examples.templates.invoice.v2.ModernInvoiceV2Example;
import com.demcha.examples.templates.proposal.CinematicProposalFileExample;
import com.demcha.examples.templates.proposal.ProposalCinematicFileExample;
import com.demcha.examples.templates.proposal.ProposalFileExample;
@@ -138,6 +139,7 @@ public static void main(String[] args) throws Exception {
// Invoices
System.out.println("Generated: " + InvoiceFileExample.generate());
System.out.println("Generated: " + InvoiceCinematicFileExample.generate());
+ System.out.println("Generated: " + ModernInvoiceV2Example.generate());
// Proposals
System.out.println("Generated: " + ProposalFileExample.generate());
diff --git a/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java b/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java
new file mode 100644
index 000000000..b27f82d2f
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/invoice/v2/ModernInvoiceV2Example.java
@@ -0,0 +1,59 @@
+package com.demcha.examples.templates.invoice.v2;
+
+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.data.invoice.InvoiceDocumentSpec;
+import com.demcha.compose.document.templates.invoice.v2.presets.ModernInvoice;
+import com.demcha.examples.support.ExampleDataFactory;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the layered {@code invoice.v2} Modern Invoice preset against
+ * the shared {@code InvoiceDocumentSpec} sample data using the default
+ * {@code BrandTheme.invoiceModern()} theme.
+ *
+ *
Output:
+ * {@code examples/target/generated-pdfs/templates/invoice/invoice-modern-v2.pdf}.
+ *
+ * This is the "hello world" for the v2 invoice pipeline: fetch sample
+ * data, ask the preset for a template, render it — the same shape as the
+ * v2 CV examples, now on the invoice family.
+ */
+public final class ModernInvoiceV2Example {
+
+ private ModernInvoiceV2Example() {
+ }
+
+ /**
+ * @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-modern-v2.pdf");
+ InvoiceDocumentSpec spec = ExampleDataFactory.sampleInvoice();
+ DocumentTemplate template = ModernInvoice.create();
+
+ float m = (float) ModernInvoice.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());
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java b/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java
index 92702e3ec..0f20fd036 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/BrandTheme.java
@@ -318,6 +318,23 @@ public static BrandTheme mintEditorial() {
Spacing.mintEditorial(),
Decoration.classic());
}
+
+ /**
+ * The "Modern Invoice" look — Helvetica, slate ink, a pale-blue
+ * rounded hero panel with a blue accent strip, and light table
+ * rules. The first layered invoice flavour: the invoice
+ * presets read it exactly the way the CV presets read their own
+ * flavours, so the two families share one theme model.
+ *
+ * @return a {@code BrandTheme} for the "Modern Invoice" look
+ */
+ public static BrandTheme invoiceModern() {
+ return new BrandTheme(
+ Palette.invoiceModern(),
+ Typography.invoiceModern(),
+ Spacing.invoiceModern(),
+ Decoration.classic());
+ }
// -- pre-built text-style helpers ------------------------------------
// Renderers ask the theme for an already-composed DocumentTextStyle
// instead of re-assembling font + size + decoration + colour every
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java
index 8cf66c0cd..fd0ad864f 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Palette.java
@@ -298,4 +298,23 @@ public static Palette mintEditorial() {
DocumentColor.rgb(70, 70, 70), // rule (skill-bar track)
DocumentColor.rgb(139, 207, 190)); // banner — reused as mint accent
}
+
+ /**
+ * Modern Invoice palette: slate body ink, grey metadata, light
+ * table-border rules, and a pale-blue fill carried in the
+ * {@code banner} slot for the invoice hero panel and table zebra
+ * rows. The stronger accent blue used for the hero strip / status
+ * is preset-local in {@code ModernInvoice} because no other v2
+ * preset shares it today.
+ *
+ * @return a {@code Palette} for the Modern Invoice flavour
+ */
+ public static Palette invoiceModern() {
+ return new Palette(
+ DocumentColor.rgb(33, 37, 41), // ink — slate body text
+ DocumentColor.rgb(108, 117, 125), // muted — metadata
+ DocumentColor.rgb(206, 212, 218), // rule — table borders / separators
+ DocumentColor.rgb(232, 240, 254), // banner — pale-blue hero panel / zebra fill
+ DocumentColor.WHITE); // mainFill — table surface
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java
index 6c7c1ac9b..6ce6bc70b 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Spacing.java
@@ -501,4 +501,31 @@ public static Spacing mintEditorial() {
0.45, // entryDateWeight
12.0); // entrySeparation (roomy editorial gap)
}
+
+ /**
+ * Spacing for the Modern Invoice preset: a 14pt page-flow rhythm
+ * matching the cinematic invoice, a rounded hero panel (10pt radius,
+ * 14pt inner padding), and a 4pt accent strip down the hero's left
+ * edge. Entry-row tokens are unused by the invoice layout but kept
+ * at neutral defaults for the shared record shape.
+ *
+ * @return a {@code Spacing} for the Modern Invoice preset
+ */
+ public static Spacing invoiceModern() {
+ return new Spacing(
+ 14, // pageFlowSpacing (matches the cinematic invoice)
+ 4, // sectionBodySpacing
+ DocumentInsets.zero(), // sectionBodyPadding
+ DocumentInsets.zero(), // headlinePadding
+ DocumentInsets.zero(), // contactPadding
+ 10.0, // bannerCornerRadius (hero panel radius)
+ 14.0, // bannerInnerPadding (hero panel padding)
+ DocumentInsets.zero(), // bannerMargin
+ 4.0, // accentRuleWidth (hero accent strip width)
+ 2.0, // paragraphMarginTop
+ 8.0, // entryHeaderRowSpacing
+ 1.0, // entryTitleWeight
+ 0.45, // entryDateWeight
+ 3.0); // entrySeparation
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java b/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java
index d42779f01..b7d03ee29 100644
--- a/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java
+++ b/src/main/java/com/demcha/compose/document/templates/core/theme/Typography.java
@@ -362,4 +362,24 @@ public static Typography mintEditorial() {
8.0, // body (profile / experience)
1.3); // line spacing
}
+
+ /**
+ * Helvetica scale for the Modern Invoice preset — a clear invoice
+ * title, compact bold labels (FROM / BILL TO / table header), and a
+ * readable body for party blocks and line-item cells.
+ *
+ * @return a {@code Typography} scale for the Modern Invoice preset
+ */
+ public static Typography invoiceModern() {
+ return new Typography(
+ FontName.HELVETICA_BOLD, FontName.HELVETICA,
+ 22.0, // headline (invoice title)
+ 9.0, // contact (unused by invoice; kept for the record shape)
+ 9.5, // banner (FROM / BILL TO / column labels)
+ 10.0, // entry title
+ 9.5, // entry date
+ 9.0, // entry subtitle (footer note)
+ 9.5, // body (party blocks, table cells)
+ 1.3); // line spacing
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java
new file mode 100644
index 000000000..022a2550c
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoice.java
@@ -0,0 +1,304 @@
+package com.demcha.compose.document.templates.invoice.v2.presets;
+
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.TableBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+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 com.demcha.compose.document.templates.data.invoice.InvoiceLineItem;
+import com.demcha.compose.document.templates.data.invoice.InvoiceParty;
+import com.demcha.compose.document.templates.data.invoice.InvoiceSummaryRow;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Modern Invoice — the first layered invoice preset.
+ *
+ * It composes an invoice on a
+ * {@link com.demcha.compose.document.templates.core.theme.BrandTheme}
+ * plus the canonical DSL, mirroring the {@code cv.v2} preset shape: a
+ * {@code create(BrandTheme)} factory returns a thin
+ * {@link DocumentTemplate} whose {@code compose} sequences a hero panel,
+ * the FROM / BILL TO parties, the line-items table, and a notes /
+ * payment-terms footer. The visual intent is ported from the cinematic
+ * {@code builtins.InvoiceTemplateV2}; this preset reads every colour,
+ * font, size, and spacing value from the theme instead of a
+ * {@code BusinessTheme}.
+ *
+ * Why the parties render inline rather than through
+ * {@code core.identity.PartyIdentity}: an invoice carries two
+ * parties (sender + bill-to) shown as side-by-side blocks, and an
+ * {@link InvoiceParty}'s email / phone are optional, whereas the shared
+ * {@code Contact} / {@code Masthead} widgets model a single mandatory
+ * contact triple for a one-person CV masthead. The two-party inline
+ * layout is the invoice idiom, so the preset composes the party blocks
+ * directly.
+ */
+public final class ModernInvoice {
+
+ /**
+ * Stable template identifier.
+ */
+ public static final String ID = "invoice-modern";
+
+ /**
+ * Human-readable display name.
+ */
+ public static final String DISPLAY_NAME = "Modern Invoice";
+
+ /**
+ * Recommended page margin (in points).
+ */
+ public static final double RECOMMENDED_MARGIN = 28.0;
+
+ private static final double TABLE_PADDING = 7.0;
+
+ /**
+ * Strong accent blue for the hero accent strip and the status read
+ * out. Preset-local because no other v2 preset shares it today; if a
+ * second invoice preset reaches for it, promote it to a
+ * {@link com.demcha.compose.document.templates.core.theme.Palette}
+ * slot.
+ */
+ private static final DocumentColor ACCENT = DocumentColor.rgb(41, 128, 185);
+
+ private ModernInvoice() {
+ }
+
+ /**
+ * Builds the preset with the Modern Invoice theme
+ * ({@link BrandTheme#invoiceModern()}).
+ *
+ * @return ready-to-use template
+ */
+ public static DocumentTemplate create() {
+ return create(BrandTheme.invoiceModern());
+ }
+
+ /**
+ * Builds the preset with a caller-supplied theme, so callers can
+ * vary the invoice flavour (typography scale, palette) without
+ * forking this class.
+ *
+ * @param theme active theme
+ * @return ready-to-use template
+ */
+ public static DocumentTemplate create(BrandTheme theme) {
+ Objects.requireNonNull(theme, "theme");
+ return new Template(theme);
+ }
+
+ private record Template(BrandTheme theme) implements DocumentTemplate {
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public String displayName() {
+ return DISPLAY_NAME;
+ }
+
+ @Override
+ public void compose(DocumentSession document, InvoiceDocumentSpec spec) {
+ Objects.requireNonNull(document, "document");
+ InvoiceData data = Objects.requireNonNull(spec, "spec").invoice();
+
+ DocumentColor panelFill = theme.palette().banner();
+ DocumentColor rule = theme.palette().rule();
+ DocumentColor surface = theme.palette().mainFill();
+
+ DocumentTextStyle titleStyle = theme.headlineStyle();
+ DocumentTextStyle labelStyle = theme.bannerStyle();
+ DocumentTextStyle bodyStyle = theme.bodyStyle();
+
+ DocumentTableStyle bordered = DocumentTableStyle.builder()
+ .stroke(DocumentStroke.of(rule, 0.6))
+ .padding(DocumentInsets.of(TABLE_PADDING))
+ .textStyle(bodyStyle)
+ .build();
+ DocumentTableStyle headerStyle = DocumentTableStyle.builder()
+ .fillColor(theme.palette().ink())
+ .stroke(DocumentStroke.of(rule, 0.6))
+ .padding(DocumentInsets.of(TABLE_PADDING + 1))
+ .textStyle(DocumentTextStyle.builder()
+ .fontName(theme.typography().bodyFont())
+ .decoration(DocumentTextDecoration.BOLD)
+ .size(theme.typography().sizeBanner())
+ .color(surface)
+ .build())
+ .build();
+ DocumentTableStyle totalStyle = DocumentTableStyle.builder()
+ .fillColor(panelFill)
+ .stroke(DocumentStroke.of(rule, 0.6))
+ .padding(DocumentInsets.of(TABLE_PADDING + 1))
+ .textStyle(theme.bodyBoldStyle())
+ .build();
+
+ document.dsl().pageFlow()
+ .name("InvoiceV2ModernRoot")
+ .spacing(theme.spacing().pageFlowSpacing())
+ .addSection("InvoiceHero", section -> section
+ .softPanel(panelFill,
+ DocumentCornerRadius.right(theme.spacing().bannerCornerRadius()),
+ theme.spacing().bannerInnerPadding())
+ .accentLeft(ACCENT, theme.spacing().accentRuleWidth())
+ .spacing(6)
+ .addParagraph(p -> p
+ .text(data.title().isBlank() ? "Invoice" : data.title())
+ .textStyle(titleStyle)
+ .margin(DocumentInsets.zero()))
+ .addRich(rich -> rich
+ .plain("Invoice ").bold(data.invoiceNumber())
+ .plain(" Issued ").bold(data.issueDate())
+ .plain(" Due ").bold(data.dueDate())
+ .plain(" Status ").accent(safeStatus(data.status()), ACCENT)))
+ .addRow("InvoiceParties", row -> row
+ .spacing(18)
+ .weights(1, 1)
+ .addSection("InvoiceFromParty", col -> col
+ .spacing(2)
+ .addParagraph(p -> p
+ .text("FROM")
+ .textStyle(labelStyle)
+ .margin(DocumentInsets.zero()))
+ .addParagraph(p -> p
+ .text(data.fromParty().name())
+ .textStyle(labelStyle)
+ .margin(DocumentInsets.zero()))
+ .addParagraph(p -> p
+ .text(joinAddress(data.fromParty()))
+ .textStyle(bodyStyle)
+ .lineSpacing(1.3)
+ .margin(DocumentInsets.zero())))
+ .addSection("InvoiceBillToParty", col -> col
+ .spacing(2)
+ .addParagraph(p -> p
+ .text("BILL TO")
+ .textStyle(labelStyle)
+ .margin(DocumentInsets.zero()))
+ .addParagraph(p -> p
+ .text(data.billToParty().name())
+ .textStyle(labelStyle)
+ .margin(DocumentInsets.zero()))
+ .addParagraph(p -> p
+ .text(joinAddress(data.billToParty()))
+ .textStyle(bodyStyle)
+ .lineSpacing(1.3)
+ .margin(DocumentInsets.zero()))))
+ .addTable(table -> {
+ TableBuilder configured = table
+ .name("InvoiceLineItems")
+ .columns(
+ DocumentTableColumn.auto(),
+ DocumentTableColumn.fixed(54),
+ DocumentTableColumn.fixed(96),
+ DocumentTableColumn.fixed(96))
+ .defaultCellStyle(bordered)
+ .headerRow("Description", "Qty", "Unit", "Amount")
+ .headerStyle(headerStyle)
+ .repeatHeader()
+ .zebra(panelFill, surface);
+ // Render only item.description() in the cell. The optional
+ // details string can be a long sentence; including it would
+ // force the auto-sized description column to measure against
+ // that width and overflow the inner page on A4 — the cinematic
+ // builtin guards the same way. Do not add item.details() here.
+ for (InvoiceLineItem item : data.lineItems()) {
+ configured.row(item.description(), item.quantity(),
+ item.unitPrice(), item.amount());
+ }
+ // Convention (shared with the cinematic builtin): the LAST
+ // summary row is the grand total — it renders via totalRow
+ // so it picks up the bold total style; earlier rows render
+ // as plain body rows.
+ List summaries = data.summaryRows();
+ for (int i = 0; i < summaries.size(); i++) {
+ InvoiceSummaryRow summary = summaries.get(i);
+ if (i == summaries.size() - 1) {
+ configured.totalRow(totalStyle, "", "", summary.label(), summary.value());
+ } else {
+ configured.row("", "", summary.label(), summary.value());
+ }
+ }
+ })
+ .addRow("InvoiceFooterRow", row -> row
+ .spacing(18)
+ .weights(1, 1)
+ .addSection("InvoiceNotes", col -> col
+ .accentLeft(ACCENT, 3)
+ .padding(0, 0, 0, 8)
+ .spacing(3)
+ .addParagraph(p -> p
+ .text("Notes")
+ .textStyle(labelStyle)
+ .margin(DocumentInsets.zero()))
+ .addList(list -> list.items(data.notes())))
+ .addSection("InvoicePaymentTerms", col -> col
+ .accentLeft(ACCENT, 3)
+ .padding(0, 0, 0, 8)
+ .spacing(3)
+ .addParagraph(p -> p
+ .text("Payment terms")
+ .textStyle(labelStyle)
+ .margin(DocumentInsets.zero()))
+ .addList(list -> list.items(data.paymentTerms()))))
+ .build();
+
+ if (!data.footerNote().isBlank()) {
+ document.dsl().pageFlow()
+ .name("InvoiceV2ModernFooter")
+ .addParagraph(p -> p
+ .text(data.footerNote())
+ .textStyle(theme.entrySubtitleStyle())
+ .margin(new DocumentInsets(14, 0, 0, 0)))
+ .build();
+ }
+ }
+ }
+
+ private static String safeStatus(String status) {
+ if (status == null || status.isBlank()) {
+ return "—";
+ }
+ return status;
+ }
+
+ private static String joinAddress(InvoiceParty party) {
+ StringBuilder builder = new StringBuilder();
+ for (String line : party.addressLines()) {
+ if (line == null || line.isBlank()) {
+ continue;
+ }
+ append(builder, line);
+ }
+ if (!party.email().isBlank()) {
+ append(builder, party.email());
+ }
+ if (!party.phone().isBlank()) {
+ append(builder, party.phone());
+ }
+ if (!party.taxId().isBlank()) {
+ append(builder, "Tax ID " + party.taxId());
+ }
+ return builder.toString();
+ }
+
+ private static void append(StringBuilder builder, String line) {
+ if (builder.length() > 0) {
+ builder.append('\n');
+ }
+ builder.append(line);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/package-info.java b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/package-info.java
new file mode 100644
index 000000000..cc8982c45
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/invoice/v2/presets/package-info.java
@@ -0,0 +1,17 @@
+/**
+ * Layered invoice presets — thin orchestrators that compose an invoice
+ * on a {@link com.demcha.compose.document.templates.core.theme.BrandTheme}
+ * plus the canonical DSL.
+ *
+ * This package mirrors {@code cv.v2.presets}: each preset is a
+ * {@code final} class with a {@code create(BrandTheme)} factory returning
+ * a {@link com.demcha.compose.document.templates.api.DocumentTemplate}
+ * parameterised on
+ * {@link com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec}.
+ * The presets read every visual value from the theme and reuse the shared
+ * {@code templates.core.*} layer; the invoice family does not own a theme
+ * type of its own.
+ *
+ * @since 2.0.0
+ */
+package com.demcha.compose.document.templates.invoice.v2.presets;
diff --git a/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java
new file mode 100644
index 000000000..86050e706
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/ModernInvoiceSmokeTest.java
@@ -0,0 +1,103 @@
+package com.demcha.compose.document.templates.invoice.v2.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.style.DocumentInsets;
+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 {@code invoice.v2} pipeline through
+ * {@link ModernInvoice} — 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 ModernInvoiceSmokeTest {
+
+ private static InvoiceDocumentSpec sampleSpec() {
+ return InvoiceDocumentSpec.from(InvoiceData.builder()
+ .title("Invoice")
+ .invoiceNumber("GC-2026-001")
+ .issueDate("01 May 2026")
+ .dueDate("15 May 2026")
+ .status("Sent")
+ .fromParty(from -> from
+ .name("GraphCompose Studio")
+ .addressLines("18 Layout Street", "London, UK")
+ .email("billing@graphcompose.dev")
+ .phone("+44 20 5555 1000")
+ .taxId("GB-99887766"))
+ .billToParty(to -> to
+ .name("Northwind Systems")
+ .addressLines("Attn: Finance", "410 Market Avenue")
+ .email("ap@northwind.example")
+ .phone("+44 161 555 2200"))
+ .lineItem("Discovery workshop", "Stakeholder interviews",
+ "1", "GBP 1,450", "GBP 1,450")
+ .lineItem("Template architecture", "Reusable document flows",
+ "2", "GBP 980", "GBP 1,960")
+ .summaryRow("Subtotal", "GBP 3,410")
+ .summaryRow("VAT (20%)", "GBP 682")
+ .totalRow("Total", "GBP 4,092")
+ .note("Payment due within 14 days.")
+ .paymentTerm("Bank transfer, NET 14")
+ .footerNote("Thank you for your business.")
+ .build());
+ }
+
+ /** An invoice with no line items, summaries, notes, or footer — 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 template,
+ InvoiceDocumentSpec spec) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(DocumentInsets.of(28))
+ .create()) {
+ template.compose(session, spec);
+ assertThat(session.roots()).isNotEmpty();
+ }
+ }
+
+ @Test
+ void exposesStableIdentity() {
+ DocumentTemplate template = ModernInvoice.create();
+ assertThat(template.id()).isEqualTo(ModernInvoice.ID);
+ assertThat(template.displayName()).isEqualTo(ModernInvoice.DISPLAY_NAME);
+ }
+
+ @Test
+ void defaultFactoryRendersWithInvoiceTheme() throws Exception {
+ // create() wires BrandTheme.invoiceModern() — the variant the example uses.
+ render(ModernInvoice.create(), sampleSpec());
+ }
+
+ @Test
+ void rendersWithExplicitTheme() throws Exception {
+ render(ModernInvoice.create(BrandTheme.invoiceModern()), sampleSpec());
+ }
+
+ @Test
+ void readsAnyTheme() throws Exception {
+ // Proves the preset reads the theme rather than assuming invoiceModern() slots.
+ render(ModernInvoice.create(BrandTheme.boxedClassic()), sampleSpec());
+ }
+
+ @Test
+ void rendersEmptyInvoice() throws Exception {
+ // Exercises the empty-collection + header-only-table + skipped-footer paths.
+ render(ModernInvoice.create(), minimalSpec());
+ }
+}