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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<InvoiceDocumentSpec> template = ModernInvoice.create();
BrandTheme theme = BrandTheme.invoiceModern();
DocumentTemplate<InvoiceDocumentSpec> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>invoice</em> 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 <em>invoice</em> 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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
* {@link DocumentTemplate} whose {@code compose} sequences a hero panel,
* the FROM / BILL&nbsp;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}.</p>
* {@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}.</p>
*
* <p><strong>Why the parties render inline rather than through
* {@code core.identity.PartyIdentity}:</strong> an invoice carries two
Expand Down Expand Up @@ -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() {
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p><strong>Re-blessing baselines</strong> — 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/}.</p>
*/
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<DocumentTemplate<InvoiceDocumentSpec>> factory)
throws Exception {
DocumentTemplate<InvoiceDocumentSpec> 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<Arguments> presets() {
return Stream.of(
Arguments.of("modern_invoice",
ModernInvoice.RECOMMENDED_MARGIN,
(Supplier<DocumentTemplate<InvoiceDocumentSpec>>) 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading