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 index b27f82d2f..b043d7772 100644 --- 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 @@ -4,6 +4,7 @@ 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.v2.presets.ModernInvoice; import com.demcha.examples.support.ExampleDataFactory; @@ -36,11 +37,13 @@ public static Path generate() throws Exception { Path outputFile = ExampleOutputPaths.prepare( "templates/invoice", "invoice-modern-v2.pdf"); InvoiceDocumentSpec spec = ExampleDataFactory.sampleInvoice(); - DocumentTemplate template = ModernInvoice.create(); + BrandTheme theme = BrandTheme.invoiceModern(); + DocumentTemplate template = ModernInvoice.create(theme); float m = (float) ModernInvoice.RECOMMENDED_MARGIN; try (DocumentSession document = GraphCompose.document(outputFile) .pageSize(DocumentPageSize.A4) + .pageBackground(theme.palette().mainFill()) .margin(m, m, m, m) .create()) { template.compose(document, spec); 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 0f20fd036..b16768358 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 @@ -320,11 +320,12 @@ public static BrandTheme mintEditorial() { } /** - * 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. + * The "Modern Invoice" look — Helvetica on a cream page, a soft-tan + * rounded hero panel with a gold accent strip, a deep-teal title and + * table header, and light table rules. Mirrors the cinematic business + * "modern" theme. 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 */ 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 fd0ad864f..e2ab093fc 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 @@ -300,21 +300,23 @@ public static Palette mintEditorial() { } /** - * 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. + * Modern Invoice palette mirroring the cinematic business "modern" + * look: slate body ink, grey metadata, light table-border rules, a + * soft-tan fill in the {@code banner} slot (the hero panel + table + * zebra rows), and a cream {@code mainFill} used as both the page + * background and the table surface. The deep-teal title / table + * header and the gold hero accent are preset-local in + * {@code ModernInvoice} because the layered palette has no + * primary / accent slot. * * @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 + DocumentColor.rgb(34, 38, 50), // ink — body text (modern textPrimary) + DocumentColor.rgb(110, 110, 120), // muted — metadata (modern textMuted) + DocumentColor.rgb(212, 200, 178), // rule — table borders (modern rule) + DocumentColor.rgb(244, 238, 228), // banner — soft-tan hero panel / zebra (modern surfaceMuted) + DocumentColor.rgb(252, 248, 240)); // mainFill — cream surface + page background (modern surface) } } 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 b7d03ee29..d75580466 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 @@ -373,13 +373,13 @@ public static Typography mintEditorial() { 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) + 28.0, // headline (invoice title — modern h1) + 10.0, // contact (unused by invoice; kept for the record shape) + 11.0, // banner (FROM / BILL TO / table header labels — modern body-bold) + 11.0, // entry title + 11.0, // entry date + 10.0, // entry subtitle (footer caption — modern caption) + 11.0, // body (party blocks, table cells — modern body) 1.3); // line spacing } } diff --git a/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java b/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java index 2238bc61a..f1be624f0 100644 --- a/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java +++ b/src/main/java/com/demcha/compose/document/templates/data/invoice/package-info.java @@ -1,4 +1,6 @@ /** - * Invoice document specs and supporting data records for canonical templates. + * Shared, render-neutral invoice document specs and supporting data + * records, consumed by both the cinematic builtin {@code InvoiceTemplateV2} + * and the layered {@code invoice.v2} presets. */ package com.demcha.compose.document.templates.data.invoice; 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 index 022a2550c..99442667b 100644 --- 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 @@ -31,9 +31,11 @@ * {@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}.

+ * {@code builtins.InvoiceTemplateV2}; the hero, party labels, table + * header, totals, and footer read their colours / fonts / sizes from the + * theme (replacing the {@code BusinessTheme} the builtin used). The + * line-item body cells intentionally inherit the DSL default table-cell + * text to stay a pixel match for the builtin — see {@code compose}.

* *

Why the parties render inline rather than through * {@code core.identity.PartyIdentity}: an invoice carries two @@ -64,13 +66,19 @@ public final class ModernInvoice { 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. + * Deep teal used for the invoice title and the line-items table header + * fill (the modern business primary). Preset-local — no other v2 preset + * shares it today; promote to a {@link + * com.demcha.compose.document.templates.core.theme.Palette} slot if a + * second invoice preset reaches for it. */ - private static final DocumentColor ACCENT = DocumentColor.rgb(41, 128, 185); + private static final DocumentColor PRIMARY = DocumentColor.rgb(20, 60, 75); + + /** + * Gold accent for the hero accent strip and the status read-out (the + * modern business accent). Preset-local, same rationale as {@link #PRIMARY}. + */ + private static final DocumentColor ACCENT = DocumentColor.rgb(196, 153, 76); private ModernInvoice() { } @@ -115,21 +123,38 @@ public void compose(DocumentSession document, InvoiceDocumentSpec spec) { Objects.requireNonNull(document, "document"); InvoiceData data = Objects.requireNonNull(spec, "spec").invoice(); - DocumentColor panelFill = theme.palette().banner(); + DocumentColor panelFill = theme.palette().banner(); // soft tan DocumentColor rule = theme.palette().rule(); - DocumentColor surface = theme.palette().mainFill(); + DocumentColor surface = theme.palette().mainFill(); // cream - DocumentTextStyle titleStyle = theme.headlineStyle(); - DocumentTextStyle labelStyle = theme.bannerStyle(); + // Title + table header use the deep teal PRIMARY; FROM / BILL TO + // labels + body read from the theme; the footer note is a quiet + // caption. Mirrors the cinematic builtin's modern look. + DocumentTextStyle titleStyle = DocumentTextStyle.builder() + .fontName(theme.typography().headlineFont()) + .size(theme.typography().sizeHeadline()) + .decoration(DocumentTextDecoration.BOLD) + .color(PRIMARY) + .build(); + DocumentTextStyle labelStyle = theme.bodyBoldStyle(); DocumentTextStyle bodyStyle = theme.bodyStyle(); + DocumentTextStyle captionStyle = DocumentTextStyle.builder() + .fontName(theme.typography().bodyFont()) + .size(theme.typography().sizeEntrySubtitle()) + .color(theme.palette().muted()) + .build(); + // Line-item cells intentionally carry NO textStyle — they inherit + // the DSL default table-cell text, exactly as the cinematic builtin's + // defaultCellStyle does. That is what makes this a pixel-for-pixel + // match; do NOT add a textStyle here (it would break parity). The + // theme-driven surfaces are the hero, labels, header, totals, footer. 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()) + .fillColor(PRIMARY) .stroke(DocumentStroke.of(rule, 0.6)) .padding(DocumentInsets.of(TABLE_PADDING + 1)) .textStyle(DocumentTextStyle.builder() @@ -261,7 +286,7 @@ public void compose(DocumentSession document, InvoiceDocumentSpec spec) { .name("InvoiceV2ModernFooter") .addParagraph(p -> p .text(data.footerNote()) - .textStyle(theme.entrySubtitleStyle()) + .textStyle(captionStyle) .margin(new DocumentInsets(14, 0, 0, 0))) .build(); } diff --git a/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/InvoiceV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/InvoiceV2VisualParityTest.java new file mode 100644 index 000000000..61d6faa75 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/invoice/v2/presets/InvoiceV2VisualParityTest.java @@ -0,0 +1,119 @@ +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.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.data.invoice.InvoiceData; +import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; +import com.demcha.compose.testing.visual.PdfVisualRegression; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.nio.file.Path; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Pixel-diff visual parity gate for the v2 layered invoice presets. + * + *

Each preset renders the same canonical {@link InvoiceDocumentSpec} + * on A4 at the preset's {@code RECOMMENDED_MARGIN}; the PDF is rasterised + * page-by-page and compared per-pixel against a checked-in baseline PNG. + * {@code ModernInvoice} reproduces the cinematic {@code InvoiceTemplateV2} + * look on a {@code BrandTheme}, so this gate locks that look against drift.

+ * + *

Re-blessing baselines — after a deliberate visual + * change, re-run with {@code -Dgraphcompose.visual.approve=true} to + * overwrite the baselines, and commit the updated PNGs in the same change. + * Baselines live under + * {@code src/test/resources/visual-baselines/invoice-v2-layered/}.

+ */ +class InvoiceV2VisualParityTest { + + private static final Path BASELINE_ROOT = Path.of( + "src", "test", "resources", "visual-baselines", "invoice-v2-layered"); + + // Mirrors CvV2VisualParityTest: Helvetica is the PDFBox built-in font + // with the widest cross-platform glyph/colour drift, so the budget is + // sized generously for Windows-recorded vs Linux-CI rendering. + private static final long PIXEL_DIFF_BUDGET = 50_000L; + private static final int PER_PIXEL_TOLERANCE = 8; + + @ParameterizedTest(name = "{0}") + @MethodSource("presets") + void rendersWithinPixelDiffBudget(String slug, + double margin, + Supplier> factory) + throws Exception { + DocumentTemplate template = factory.get(); + float m = (float) margin; + byte[] pdfBytes; + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(m, m, m, m) + .create()) { + template.compose(document, canonicalInvoice()); + pdfBytes = document.toPdfBytes(); + } + + PdfVisualRegression.standard() + .baselineRoot(BASELINE_ROOT) + .perPixelTolerance(PER_PIXEL_TOLERANCE) + .mismatchedPixelBudget(PIXEL_DIFF_BUDGET) + .assertMatchesBaseline(slug, pdfBytes); + } + + private static Stream presets() { + return Stream.of( + Arguments.of("modern_invoice", + ModernInvoice.RECOMMENDED_MARGIN, + (Supplier>) ModernInvoice::create)); + } + + /** + * 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 inline so the test depends only + * on main + main-test code. + */ + private 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()); + } +} 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 index 86050e706..f02aafda0 100644 --- 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 @@ -91,7 +91,9 @@ void rendersWithExplicitTheme() throws Exception { @Test void readsAnyTheme() throws Exception { - // Proves the preset reads the theme rather than assuming invoiceModern() slots. + // Renders under a non-invoice theme without crashing: the hero, labels, + // header, totals, and footer follow the theme; the line-item body cells + // inherit the DSL default (as in the cinematic builtin). render(ModernInvoice.create(BrandTheme.boxedClassic()), sampleSpec()); } diff --git a/src/test/resources/visual-baselines/invoice-v2-layered/modern_invoice-page-0.png b/src/test/resources/visual-baselines/invoice-v2-layered/modern_invoice-page-0.png new file mode 100644 index 000000000..65ff32c5b Binary files /dev/null and b/src/test/resources/visual-baselines/invoice-v2-layered/modern_invoice-page-0.png differ