diff --git a/examples/README.md b/examples/README.md index ef5a56285..b4af78ec2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -91,7 +91,6 @@ are with the canonical DSL, then jump to its detailed section below. | [CV — template gallery](#cv-template-gallery) | The v2 CV presets in one orchestrated run | [Source](src/main/java/com/demcha/examples/templates/cv/CvTemplateGalleryFileExample.java) | | [Cover letter — template gallery](#cover-letter-template-gallery) | All paired v2 cover-letter presets in one orchestrated run | [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterTemplateGalleryFileExample.java) | | [Proposal — cinematic V2](#proposal-cinematic-v2) | `ProposalTemplateV2 + BusinessTheme.modern()` | [PDF](../assets/readme/examples/proposal-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/proposal/ProposalCinematicFileExample.java) | -| [Custom Business Theme](#custom-business-theme) | Hand-built `BusinessTheme` driving `InvoiceTemplateV2` — theme-token customisation entry point | [PDF](../assets/readme/examples/invoice-custom-theme.pdf) · [Source](src/main/java/com/demcha/examples/features/themes/CustomBusinessThemeExample.java) | ### 🔧 Advanced SPI @@ -536,26 +535,6 @@ table.columns(...) [📄 View PDF](../assets/readme/examples/table-advanced.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java) -### Custom Business Theme - -Build a `BusinessTheme` from raw `DocumentPalette` / `SpacingScale` / -`TextScale` / `TablePreset` records — no factory shortcut. Plug it -straight into `InvoiceTemplateV2` to retheme the whole template -without touching any code that uses it. - -```java -BusinessTheme studioEmerald = new BusinessTheme( - new DocumentPalette(/* page, surface, surfaceMuted, ink, accent, … */), - SpacingScale.cinematic(), - new TextScale(/* h1, h2, body, caption fonts … */), - TablePreset.cinematic()); - -new InvoiceTemplateV2(studioEmerald).compose(document, invoice); -``` - -[📄 View PDF](../assets/readme/examples/invoice-custom-theme.pdf) · -[📜 Full source](src/main/java/com/demcha/examples/features/themes/CustomBusinessThemeExample.java) - --- ## Public-API surface diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 32b9a2aa5..e93b9a2e2 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -39,7 +39,6 @@ import com.demcha.examples.features.structure.MultiSectionExample; import com.demcha.examples.features.text.RichTextShowcaseExample; import com.demcha.examples.features.text.SectionPresetsExample; -import com.demcha.examples.features.themes.CustomBusinessThemeExample; import com.demcha.examples.features.title.BookTemplateExample; import com.demcha.examples.features.transforms.TransformsExample; import com.demcha.examples.flagships.BusinessReportExample; @@ -186,7 +185,6 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + MultiSectionExample.generate()); // Theming + chrome - System.out.println("Generated: " + CustomBusinessThemeExample.generate()); System.out.println("Generated: " + PdfChromeExample.generate()); System.out.println("Generated: " + PageNumberingExample.generate()); System.out.println("Generated: " + ViewerPreferencesExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/themes/CustomBusinessThemeExample.java b/examples/src/main/java/com/demcha/examples/features/themes/CustomBusinessThemeExample.java deleted file mode 100644 index 0c4d18a67..000000000 --- a/examples/src/main/java/com/demcha/examples/features/themes/CustomBusinessThemeExample.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.demcha.examples.features.themes; - -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.DocumentColor; -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.DocumentTableStyle; -import com.demcha.compose.document.templates.builtins.InvoiceTemplateV2; -import com.demcha.compose.document.theme.BusinessTheme; -import com.demcha.compose.document.theme.DocumentPalette; -import com.demcha.compose.document.theme.SpacingScale; -import com.demcha.compose.document.theme.TablePreset; -import com.demcha.compose.document.theme.TextScale; -import com.demcha.compose.font.FontName; -import com.demcha.examples.support.ExampleDataFactory; -import com.demcha.examples.support.ExampleOutputPaths; - -import java.awt.Color; -import java.nio.file.Path; - -/** - * Phase E.4 — runnable showcase that hand-builds a {@link BusinessTheme} - * from scratch (rather than picking one of the {@code classic / modern - * / executive} presets) and pipes it through {@link InvoiceTemplateV2}. - * - *

The theme assembled below ("Studio Emerald") is a deliberately - * distinctive identity — emerald primary + copper accent on a warm - * ivory paper — so when you open the generated invoice next to - * {@link InvoiceCinematicFileExample} the difference is unmistakable. - * The point is not the colour scheme itself but the demonstration that - * every visible token on the invoice is driven from the theme - * record: change a colour or font here and the invoice updates - * without touching the template code.

- * - *

Use this example as a starter when you need to brand GraphCompose - * output for your own project: copy the body of - * {@link #studioEmeraldTheme()}, tweak the seven palette colours and - * the seven text-scale styles, and you have a custom theme that any - * {@code *TemplateV2} class can consume.

- * - * @author Artem Demchyshyn - */ -public final class CustomBusinessThemeExample { - - private CustomBusinessThemeExample() { - } - - /** - * Builds a custom business theme called "Studio Emerald". - * - *

The theme overrides every token rather than deriving from - * {@link BusinessTheme#modern()} so the example shows the full - * surface area a brand-customising consumer needs to fill in.

- * - * @return custom business theme - */ - public static BusinessTheme studioEmeraldTheme() { - // 1. Palette — semantic colour slots, all routed by role. - DocumentPalette palette = DocumentPalette.of( - new Color(20, 80, 60), // primary — deep emerald (titles, accent strip) - new Color(176, 116, 56), // accent — warm copper (totals row, badges) - new Color(252, 248, 240), // surface — warm ivory paper - new Color(238, 232, 218), // surfaceMuted — soft sand for soft panels / table headers - new Color(34, 38, 44), // textPrimary — near-black body - new Color(110, 116, 124), // textMuted — captions, metadata - new Color(210, 200, 180)); // rule — soft sand-on-sand divider - - // 2. Spacing — slightly tighter than the default so a single - // invoice page reads compact without feeling cramped. - SpacingScale spacing = new SpacingScale(4.0, 7.0, 11.0, 18.0, 30.0); - - // 3. Text scale — Times Roman headings + Helvetica body for a - // "studio editorial" tone. Each style references palette - // colours so the type system stays consistent with the - // palette above. - TextScale text = new TextScale( - style(FontName.TIMES_BOLD, 28, DocumentTextDecoration.BOLD, palette.primary()), - style(FontName.TIMES_BOLD, 17, DocumentTextDecoration.BOLD, palette.primary()), - style(FontName.HELVETICA_BOLD, 12, DocumentTextDecoration.BOLD, palette.textPrimary()), - style(FontName.HELVETICA, 10, DocumentTextDecoration.DEFAULT, palette.textPrimary()), - style(FontName.HELVETICA, 9, DocumentTextDecoration.DEFAULT, palette.textMuted()), - style(FontName.HELVETICA_BOLD, 10, DocumentTextDecoration.BOLD, palette.textPrimary()), - style(FontName.HELVETICA_BOLD, 10, DocumentTextDecoration.BOLD, palette.accent())); - - // 4. Table preset — common cell padding pulled from the - // spacing scale so changing `spacing.sm()` ripples - // through both the table and the rest of the document. - DocumentInsets cellPadding = DocumentInsets.symmetric(spacing.xs(), spacing.sm()); - DocumentStroke rule = DocumentStroke.of(palette.rule(), 0.5); - DocumentTableStyle baseCell = DocumentTableStyle.builder() - .padding(cellPadding) - .fillColor(palette.surface()) - .stroke(rule) - .build(); - DocumentTableStyle headerCell = DocumentTableStyle.builder() - .padding(cellPadding) - .fillColor(palette.surfaceMuted()) - .stroke(rule) - .build(); - DocumentTableStyle totalCell = DocumentTableStyle.builder() - .padding(cellPadding) - .fillColor(palette.surfaceMuted()) - .stroke(DocumentStroke.of(palette.accent(), 0.8)) - .build(); - DocumentTableStyle zebraCell = DocumentTableStyle.builder() - .padding(cellPadding) - .fillColor(palette.surfaceMuted()) - .stroke(rule) - .build(); - TablePreset table = new TablePreset(baseCell, headerCell, totalCell, zebraCell); - - // 5. Compose the theme. The page background is the ivory - // surface so the entire page picks up the studio paper. - return new BusinessTheme( - "studio-emerald", - palette, - spacing, - text, - table, - palette.surface()); - } - - /** - * Renders {@link ExampleDataFactory#sampleInvoice()} through - * {@link InvoiceTemplateV2} using {@link #studioEmeraldTheme()}. - * - * @return path to the generated PDF file - * @throws Exception if writing the PDF fails - */ - public static Path generate() throws Exception { - Path outputFile = ExampleOutputPaths.prepare("features/themes", "invoice-custom-theme.pdf"); - BusinessTheme theme = studioEmeraldTheme(); - InvoiceTemplateV2 template = new InvoiceTemplateV2(theme); - - try (DocumentSession document = GraphCompose.document(outputFile) - .pageSize(DocumentPageSize.A4) - .pageBackground(theme.pageBackground()) - .margin(28, 28, 28, 28) - .create()) { - template.compose(document, ExampleDataFactory.sampleInvoice()); - document.buildPdf(); - } - - return outputFile; - } - - public static void main(String[] args) throws Exception { - System.out.println("Generated: " + generate()); - } - - private static DocumentTextStyle style(FontName font, - double size, - DocumentTextDecoration decoration, - DocumentColor color) { - return DocumentTextStyle.builder() - .fontName(font) - .size(size) - .decoration(decoration) - .color(color) - .build(); - } -} diff --git a/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java b/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java index 6562a8356..08094262a 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java @@ -205,9 +205,9 @@ public static Path generate() throws Exception { .plain(", ") .bold("advanced tables (row span, zebra, totals, repeating header)") .plain(", and two cinematic templates ") - .accent("InvoiceTemplateV2 / ProposalTemplateV2", BRAND) + .accent("ModernInvoice / ModernProposal", BRAND) .plain(" driven by ") - .accent("BusinessTheme", BRAND) + .accent("BrandTheme", BRAND) .plain("."))) // ───── Quarterly numbers — full advanced table ───── @@ -281,8 +281,8 @@ public static Path generate() throws Exception { .weights(1, 1, 1) .addSection("Card1", section -> highlightCard(section, "Cinematic templates", - "InvoiceTemplateV2", - "Same data renders through classic / modern / executive themes — switch the constructor argument, ship a new look.")) + "ModernInvoice", + "Same data renders through a BrandTheme — pass a different theme to create(...), ship a new look.")) .addSection("Card2", section -> highlightCard(section, "Shape-as-container", "addCircle + clip path", diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index c7649b02c..ffb05cb58 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -100,7 +100,6 @@ record Entry(String title, String description, List tags, String codeUrl feature("transforms", "transforms", "Layers + Transforms", "rotate / scale on every leaf builder + LayerStack with explicit z-index.", "transforms", "layers"); feature("text", "rich-text-showcase", "Rich Text", "Inline runs with bold / italic / colour / link options, markdown parsing.", "text", "rich"); feature("text", "section-presets", "Section Presets", "Pre-baked section bands, accent strips, soft panels for templates.", "text", "sections"); - feature("themes", "invoice-custom-theme", "Custom BusinessTheme", "Authoring a custom palette + typography scale and feeding it through templates.", "themes"); feature("barcodes", "barcode-showcase", "Barcodes & QR", "QR code, Code128, EAN-13, PDF417 — every supported barcode + per-barcode styling.", "barcodes", "qr"); feature("chrome", "pdf-chrome", "PDF Chrome", "Headers, footers, watermarks, metadata, document protection / encryption.", "chrome", "metadata", "watermark"); feature("streaming", "invoice-http-stream", "HTTP Streaming", "Stream PDF directly to a Servlet response with no buffering.", "streaming", "http"); @@ -140,7 +139,6 @@ static String groupLabel(String category, String group) { case "features/transforms" -> "Transforms & Layers"; case "features/text" -> "Rich Text"; case "features/barcodes" -> "Barcodes & QR"; - case "features/themes" -> "Themes"; case "features/chrome" -> "PDF Chrome (header / footer / watermark)"; case "features/streaming" -> "Streaming & I/O"; case "features/snapshots" -> "Snapshot Testing"; @@ -205,7 +203,6 @@ private static void feature(String group, String id, String title, String desc, case "shapes" -> id.equals("photo-clip") ? "shapes/PhotoClipExample.java" : "shapes/ShapeContainerExample.java"; case "transforms" -> "transforms/TransformsExample.java"; case "text" -> id.equals("section-presets") ? "text/SectionPresetsExample.java" : "text/RichTextShowcaseExample.java"; - case "themes" -> "themes/CustomBusinessThemeExample.java"; case "barcodes" -> "barcodes/BarcodeShowcaseExample.java"; case "chrome" -> "chrome/PdfChromeExample.java"; case "streaming" -> "streaming/HttpStreamingExample.java"; diff --git a/src/main/java/com/demcha/compose/document/templates/api/InvoiceTemplate.java b/src/main/java/com/demcha/compose/document/templates/api/InvoiceTemplate.java deleted file mode 100644 index 2ea737906..000000000 --- a/src/main/java/com/demcha/compose/document/templates/api/InvoiceTemplate.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.demcha.compose.document.templates.api; - -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; - -/** - * Canonical compose contract for reusable invoice templates. - * - *

Responsibility: define one invoice scene that translates business - * data into semantic sections, rows, and totals inside a live - * {@link DocumentSession}.

- * - *
{@code
- * InvoiceTemplate template = new InvoiceTemplateV2();
- * InvoiceDocumentSpec invoice = InvoiceDocumentSpec.builder()
- *         .invoiceNumber("GC-2026-041")
- *         .fromParty(party -> party.name("GraphCompose Studio"))
- *         .billToParty(party -> party.name("Northwind Systems"))
- *         .lineItem("Template architecture", "Reusable business document flow", "1", "GBP 980", "GBP 980")
- *         .totalRow("Total", "GBP 980")
- *         .build();
- *
- * try (DocumentSession document = GraphCompose.document(Path.of("invoice.pdf")).create()) {
- *     template.compose(document, invoice);
- *     document.buildPdf();
- * }
- * }
- */ -public interface InvoiceTemplate { - - /** - * Stable public template identifier. - * - * @return unique template id used by registries and integrations - */ - String getTemplateId(); - - /** - * Human-readable display name. - * - * @return template display name - */ - String getTemplateName(); - - /** - * Optional human-readable description. - * - * @return template description, or an empty string when omitted - */ - default String getDescription() { - return ""; - } - - /** - * Composes an invoice into a live document session. - * - * @param document active mutable document session receiving template nodes - * @param spec invoice document spec - * @throws NullPointerException if an implementation requires non-null inputs - */ - void compose(DocumentSession document, InvoiceDocumentSpec spec); -} diff --git a/src/main/java/com/demcha/compose/document/templates/api/ProposalTemplate.java b/src/main/java/com/demcha/compose/document/templates/api/ProposalTemplate.java deleted file mode 100644 index 92d153c5a..000000000 --- a/src/main/java/com/demcha/compose/document/templates/api/ProposalTemplate.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.demcha.compose.document.templates.api; - -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec; - -/** - * Canonical compose contract for reusable proposal templates. - * - *

Responsibility: define one reusable proposal scene that emits - * semantic proposal structure into a caller-owned - * {@link DocumentSession}.

- * - *
{@code
- * ProposalTemplate template = new ProposalTemplateV2();
- * ProposalDocumentSpec proposal = ProposalDocumentSpec.builder()
- *         .projectTitle("GraphCompose rollout")
- *         .section("Scope", "Introduce reusable invoice and proposal templates.")
- *         .timelineItem("Week 1", "5 days", "Foundation and review loop")
- *         .pricingRow("Delivery", "Template implementation", "GBP 4,450")
- *         .emphasizedPricingRow("Total", "Fixed-price delivery", "GBP 4,450")
- *         .build();
- *
- * try (DocumentSession document = GraphCompose.document(Path.of("proposal.pdf")).create()) {
- *     template.compose(document, proposal);
- *     document.buildPdf();
- * }
- * }
- */ -public interface ProposalTemplate { - - /** - * Stable public template identifier. - * - * @return unique template id used by registries and integrations - */ - String getTemplateId(); - - /** - * Human-readable display name. - * - * @return template display name - */ - String getTemplateName(); - - /** - * Optional human-readable description. - * - * @return template description, or an empty string when omitted - */ - default String getDescription() { - return ""; - } - - /** - * Composes a proposal into a live document session. - * - * @param document active mutable document session receiving template nodes - * @param spec proposal document spec - * @throws NullPointerException if an implementation requires non-null inputs - */ - void compose(DocumentSession document, ProposalDocumentSpec spec); -} diff --git a/src/main/java/com/demcha/compose/document/templates/builtins/InvoiceTemplateV2.java b/src/main/java/com/demcha/compose/document/templates/builtins/InvoiceTemplateV2.java deleted file mode 100644 index 87fc8894a..000000000 --- a/src/main/java/com/demcha/compose/document/templates/builtins/InvoiceTemplateV2.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.demcha.compose.document.templates.builtins; - -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.dsl.TableBuilder; -import com.demcha.compose.document.style.*; -import com.demcha.compose.document.table.DocumentTableColumn; -import com.demcha.compose.document.table.DocumentTableStyle; -import com.demcha.compose.document.templates.api.InvoiceTemplate; -import com.demcha.compose.document.templates.data.invoice.*; -import com.demcha.compose.document.templates.support.common.TemplateLifecycleLog; -import com.demcha.compose.document.theme.BusinessTheme; - -import java.util.List; -import java.util.Objects; - -/** - * Phase E.1 — cinematic invoice template that composes against the - * canonical DSL using a {@link BusinessTheme} for every visual choice. - * - *

The template demonstrates the v1.4 / v1.5 cinematic primitives - * stacked on top of one another:

- * - * - * - *

Every visual style is derived from the {@link BusinessTheme} - * passed to the constructor — palette, text scale, stroke colour. The - * default constructor picks {@link BusinessTheme#modern()}; pass any - * other theme (or a custom one) to render the same - * {@link InvoiceDocumentSpec} in a different look without rewriting - * the composition.

- * - * @author Artem Demchyshyn - */ -public final class InvoiceTemplateV2 implements InvoiceTemplate { - private static final double TABLE_PADDING = 7.0; - - private final BusinessTheme theme; - - /** - * Creates the template with {@link BusinessTheme#modern()}. - */ - public InvoiceTemplateV2() { - this(BusinessTheme.modern()); - } - - /** - * Creates the template with an explicit theme. - * - * @param theme the visual theme used for every section, table, and - * accent - */ - public InvoiceTemplateV2(BusinessTheme theme) { - this.theme = Objects.requireNonNull(theme, "theme"); - } - - @Override - public String getTemplateId() { - return "invoice-v2"; - } - - @Override - public String getTemplateName() { - return "Invoice V2 (cinematic)"; - } - - @Override - public String getDescription() { - return "Theme-driven invoice template using soft panels, accent strips, " - + "rich text, zebra rows, and a repeating totals header."; - } - - @Override - public void compose(DocumentSession document, InvoiceDocumentSpec spec) { - long startNanos = TemplateLifecycleLog.start(getTemplateId(), spec); - try { - InvoiceData data = Objects.requireNonNull(spec, "spec").invoice(); - DocumentColor surfaceMuted = theme.palette().surfaceMuted(); - DocumentColor accent = theme.palette().accent(); - DocumentColor rule = theme.palette().rule(); - - DocumentTableStyle bordered = DocumentTableStyle.builder() - .stroke(DocumentStroke.of(rule, 0.6)) - .padding(DocumentInsets.of(TABLE_PADDING)) - .build(); - DocumentTableStyle headerStyle = DocumentTableStyle.builder() - .fillColor(theme.palette().primary()) - .stroke(DocumentStroke.of(rule, 0.6)) - .padding(DocumentInsets.of(TABLE_PADDING + 1)) - .textStyle(DocumentTextStyle.builder() - .fontName(theme.text().label().fontName()) - .decoration(theme.text().label().decoration()) - .size(theme.text().label().size()) - .color(theme.palette().surface()) - .build()) - .build(); - DocumentTableStyle totalStyle = DocumentTableStyle.builder() - .fillColor(surfaceMuted) - .stroke(DocumentStroke.of(rule, 0.6)) - .padding(DocumentInsets.of(TABLE_PADDING + 1)) - .textStyle(theme.text().label()) - .build(); - - document.dsl().pageFlow() - .name("InvoiceCinematicRoot") - .spacing(14) - .addSection("InvoiceHero", section -> section - // Round only the right corners. The left - // edge sits flush against the accent stripe - // so a left rounding would gap unevenly - // against the strip. - .softPanel(surfaceMuted, DocumentCornerRadius.right(10), 14) - .accentLeft(accent, 4) - .spacing(6) - .addParagraph(p -> p - .text(data.title().isBlank() ? "Invoice" : data.title()) - .textStyle(theme.text().h1()) - .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(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(data.fromParty().name()) - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(joinAddress(data.fromParty())) - .textStyle(theme.text().body()) - // Multi-line address blocks otherwise - // run together with the default 1.0 - // line height. 1.3 gives the address / - // email / phone lines a small breathing - // gap that matches the rest of the - // template. - .lineSpacing(1.3) - .margin(DocumentInsets.zero()))) - .addSection("InvoiceBillToParty", col -> col - .spacing(2) - .addParagraph(p -> p - .text("BILL TO") - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(data.billToParty().name()) - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(joinAddress(data.billToParty())) - .textStyle(theme.text().body()) - .lineSpacing(1.3) - .margin(DocumentInsets.zero())))) - .addTable(table -> { - TableBuilder configured = table - .name("InvoiceLineItems") - // Description gets the leftover width via auto; - // numeric columns are fixed so a long line-item - // description never blows the table past the - // page's inner width. - .columns( - DocumentTableColumn.auto(), - DocumentTableColumn.fixed(54), - DocumentTableColumn.fixed(96), - DocumentTableColumn.fixed(96)) - .defaultCellStyle(bordered) - .headerRow("Description", "Qty", "Unit", "Amount") - .headerStyle(headerStyle) - .repeatHeader() - .zebra(surfaceMuted, theme.palette().surface()); - for (InvoiceLineItem item : data.lineItems()) { - configured.row( - composeDescription(item), - item.quantity(), - item.unitPrice(), - item.amount()); - } - // The InvoiceData spec convention is that the LAST summary - // row is the totals line — InvoiceData itself does not - // tag totals separately. Earlier rows render as plain - // body rows; the last one renders via TableBuilder.totalRow - // so it picks up the totals style + bold + accent fill. - 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(theme.text().label()) - .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(theme.text().label()) - .margin(DocumentInsets.zero())) - .addList(list -> list.items(data.paymentTerms())))) - .build(); - - if (!data.footerNote().isBlank()) { - document.dsl().pageFlow() - .name("InvoiceCinematicFooter") - .addParagraph(p -> p - .text(data.footerNote()) - .textStyle(theme.text().caption()) - // Push the thank-you line down off the - // notes / payment-terms block above so it - // does not visually merge with them. - .margin(new DocumentInsets(14, 0, 0, 0))) - .build(); - } - - TemplateLifecycleLog.success(getTemplateId(), spec, startNanos); - } catch (RuntimeException | Error ex) { - TemplateLifecycleLog.failure(getTemplateId(), spec, startNanos, ex); - throw ex; - } - } - - private static String safeStatus(String status) { - if (status == null || status.isBlank()) { - return "—"; - } - return status; - } - - private static String composeDescription(InvoiceLineItem item) { - // Render only the headline description in the cell. The optional - // {@code details} string can be a long marketing-style sentence; - // including it would force the auto-sized description column to - // measure against that natural width and overflow the inner page - // for typical A4 invoices. Templates that need the details - // alongside should compose them in a separate notes column or - // section. - return item.description(); - } - - private static String joinAddress(InvoiceParty party) { - StringBuilder builder = new StringBuilder(); - for (String line : party.addressLines()) { - if (line == null || line.isBlank()) { - continue; - } - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(line); - } - if (!party.email().isBlank()) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(party.email()); - } - if (!party.phone().isBlank()) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(party.phone()); - } - if (!party.taxId().isBlank()) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append("Tax ID ").append(party.taxId()); - } - return builder.toString(); - } - -} diff --git a/src/main/java/com/demcha/compose/document/templates/builtins/ProposalTemplateV2.java b/src/main/java/com/demcha/compose/document/templates/builtins/ProposalTemplateV2.java deleted file mode 100644 index df2a5372f..000000000 --- a/src/main/java/com/demcha/compose/document/templates/builtins/ProposalTemplateV2.java +++ /dev/null @@ -1,325 +0,0 @@ -package com.demcha.compose.document.templates.builtins; - -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.dsl.TableBuilder; -import com.demcha.compose.document.style.*; -import com.demcha.compose.document.table.DocumentTableColumn; -import com.demcha.compose.document.table.DocumentTableStyle; -import com.demcha.compose.document.templates.api.ProposalTemplate; -import com.demcha.compose.document.templates.data.proposal.*; -import com.demcha.compose.document.templates.support.common.TemplateLifecycleLog; -import com.demcha.compose.document.theme.BusinessTheme; - -import java.util.List; -import java.util.Objects; - -/** - * Phase E.2 — cinematic proposal template that composes against the - * canonical DSL using a {@link BusinessTheme} for every visual choice. - * - *

Stacks the v1.4 / v1.5 cinematic primitives:

- * - * - * - *

Constructed from any {@link BusinessTheme} so the same proposal - * data can ship through {@code BusinessTheme.classic()}, - * {@code BusinessTheme.modern()}, or {@code BusinessTheme.executive()} - * without touching the composition code.

- * - * @author Artem Demchyshyn - */ -public final class ProposalTemplateV2 implements ProposalTemplate { - private static final double TABLE_PADDING = 7.0; - - private final BusinessTheme theme; - - /** - * Creates the template with {@link BusinessTheme#modern()}. - */ - public ProposalTemplateV2() { - this(BusinessTheme.modern()); - } - - /** - * Creates the template with an explicit theme. - * - * @param theme the visual theme used for every section, table, and - * accent - */ - public ProposalTemplateV2(BusinessTheme theme) { - this.theme = Objects.requireNonNull(theme, "theme"); - } - - @Override - public String getTemplateId() { - return "proposal-v2"; - } - - @Override - public String getTemplateName() { - return "Proposal V2 (cinematic)"; - } - - @Override - public String getDescription() { - return "Theme-driven proposal template using soft panels, accent strips, " - + "themed timeline / pricing tables, and a repeating pricing header."; - } - - @Override - public void compose(DocumentSession document, ProposalDocumentSpec spec) { - long startNanos = TemplateLifecycleLog.start(getTemplateId(), spec); - try { - ProposalData data = Objects.requireNonNull(spec, "spec").proposal(); - DocumentColor surface = theme.palette().surface(); - DocumentColor surfaceMuted = theme.palette().surfaceMuted(); - DocumentColor accent = theme.palette().accent(); - DocumentColor rule = theme.palette().rule(); - - DocumentTableStyle bordered = DocumentTableStyle.builder() - .stroke(DocumentStroke.of(rule, 0.6)) - .padding(DocumentInsets.of(TABLE_PADDING)) - .build(); - DocumentTableStyle headerStyle = DocumentTableStyle.builder() - .fillColor(theme.palette().primary()) - .stroke(DocumentStroke.of(rule, 0.6)) - .padding(DocumentInsets.of(TABLE_PADDING + 1)) - .textStyle(DocumentTextStyle.builder() - .fontName(theme.text().label().fontName()) - .decoration(theme.text().label().decoration()) - .size(theme.text().label().size()) - .color(surface) - .build()) - .build(); - DocumentTableStyle emphasizedRowStyle = DocumentTableStyle.builder() - .fillColor(surfaceMuted) - .stroke(DocumentStroke.of(rule, 0.6)) - .padding(DocumentInsets.of(TABLE_PADDING + 1)) - .textStyle(theme.text().label()) - .build(); - - document.dsl().pageFlow() - .name("ProposalCinematicRoot") - .spacing(14) - .addSection("ProposalHero", section -> section - // Round only the right corners — flush left - // edge meets the accent stripe cleanly. - .softPanel(surfaceMuted, DocumentCornerRadius.right(10), 14) - .accentLeft(accent, 4) - .spacing(6) - .addParagraph(p -> p - .text(data.title().isBlank() ? "Proposal" : data.title()) - .textStyle(theme.text().h1()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(data.projectTitle()) - .textStyle(theme.text().h3()) - .margin(DocumentInsets.zero())) - .addRich(rich -> rich - .plain("Proposal ").bold(data.proposalNumber()) - .plain(" Prepared ").bold(data.preparedDate()) - .plain(" Valid until ").bold(data.validUntil()))) - .addSection("ProposalExecutiveSummary", section -> section - .softPanel(surface, 8, 12) - .stroke(DocumentStroke.of(rule, 0.6)) - .spacing(4) - .addParagraph(p -> p - .text("Executive summary") - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(data.executiveSummary()) - .textStyle(theme.text().body()) - .lineSpacing(1.35) - .margin(DocumentInsets.zero()))) - .addRow("ProposalParties", row -> row - .spacing(18) - .weights(1, 1) - .addSection("ProposalSender", col -> col - .spacing(2) - .addParagraph(p -> p - .text("FROM") - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(data.sender().name()) - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(joinAddress(data.sender())) - .textStyle(theme.text().body()) - .lineSpacing(1.3) - .margin(DocumentInsets.zero()))) - .addSection("ProposalRecipient", col -> col - .spacing(2) - .addParagraph(p -> p - .text("TO") - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(data.recipient().name()) - .textStyle(theme.text().label()) - .margin(DocumentInsets.zero())) - .addParagraph(p -> p - .text(joinAddress(data.recipient())) - .textStyle(theme.text().body()) - .lineSpacing(1.3) - .margin(DocumentInsets.zero())))); - - for (ProposalSection section : data.sections()) { - document.dsl().pageFlow() - .name("ProposalSectionGroup") - .spacing(4) - .addParagraph(p -> p - .text(section.title()) - .textStyle(theme.text().h2()) - .margin(new DocumentInsets(12, 0, 4, 0))) - .addSection("ProposalSectionBody", col -> { - for (String paragraph : section.paragraphs()) { - col.addParagraph(p -> p - .text(paragraph) - .textStyle(theme.text().body()) - .lineSpacing(1.35) - .margin(DocumentInsets.zero())); - } - }) - .build(); - } - - if (!data.timeline().isEmpty()) { - document.dsl().pageFlow() - .name("ProposalTimelineGroup") - .spacing(4) - .addParagraph(p -> p - .text("Timeline") - .textStyle(theme.text().h2()) - .margin(new DocumentInsets(12, 0, 4, 0))) - .addTable(table -> { - TableBuilder configured = table - .name("ProposalTimeline") - .columns( - DocumentTableColumn.fixed(110), - DocumentTableColumn.fixed(80), - DocumentTableColumn.auto()) - .defaultCellStyle(bordered) - .headerRow("Phase", "Duration", "Details") - .headerStyle(headerStyle) - .repeatHeader(); - for (ProposalTimelineItem item : data.timeline()) { - configured.row(item.phase(), item.duration(), item.details()); - } - }) - .build(); - } - - if (!data.pricingRows().isEmpty()) { - document.dsl().pageFlow() - .name("ProposalPricingGroup") - .spacing(4) - .addParagraph(p -> p - .text("Investment") - .textStyle(theme.text().h2()) - .margin(new DocumentInsets(12, 0, 4, 0))) - .addTable(table -> { - TableBuilder configured = table - .name("ProposalPricing") - .columns( - DocumentTableColumn.fixed(140), - DocumentTableColumn.auto(), - DocumentTableColumn.fixed(110)) - .defaultCellStyle(bordered) - .headerRow("Item", "Description", "Amount") - .headerStyle(headerStyle) - .repeatHeader() - .zebra(surfaceMuted, surface); - List rows = data.pricingRows(); - for (int i = 0; i < rows.size(); i++) { - ProposalPricingRow item = rows.get(i); - if (item.emphasized() && i == rows.size() - 1) { - configured.totalRow(emphasizedRowStyle, - item.label(), item.description(), item.amount()); - } else { - configured.row(item.label(), item.description(), item.amount()); - } - } - }) - .build(); - } - - if (!data.acceptanceTerms().isEmpty()) { - document.dsl().pageFlow() - .name("ProposalAcceptanceGroup") - .spacing(4) - .addParagraph(p -> p - .text("Acceptance terms") - .textStyle(theme.text().h2()) - .margin(new DocumentInsets(12, 0, 4, 0))) - .addSection("ProposalAcceptanceBody", col -> col - .accentLeft(accent, 3) - .padding(0, 0, 0, 8) - .addList(list -> list.items(data.acceptanceTerms()))) - .build(); - } - - if (!data.footerNote().isBlank()) { - document.dsl().pageFlow() - .name("ProposalFooter") - .addParagraph(p -> p - .text(data.footerNote()) - .textStyle(theme.text().caption()) - .margin(new DocumentInsets(14, 0, 0, 0))) - .build(); - } - - TemplateLifecycleLog.success(getTemplateId(), spec, startNanos); - } catch (RuntimeException | Error ex) { - TemplateLifecycleLog.failure(getTemplateId(), spec, startNanos, ex); - throw ex; - } - } - - private static String joinAddress(ProposalParty party) { - StringBuilder builder = new StringBuilder(); - for (String line : party.addressLines()) { - if (line == null || line.isBlank()) { - continue; - } - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(line); - } - if (!party.email().isBlank()) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(party.email()); - } - if (!party.phone().isBlank()) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(party.phone()); - } - if (!party.website().isBlank()) { - if (builder.length() > 0) { - builder.append('\n'); - } - builder.append(party.website()); - } - return builder.toString(); - } -} diff --git a/src/main/java/com/demcha/compose/document/templates/builtins/package-info.java b/src/main/java/com/demcha/compose/document/templates/builtins/package-info.java deleted file mode 100644 index 7fe856a0a..000000000 --- a/src/main/java/com/demcha/compose/document/templates/builtins/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Built-in canonical templates shipped with GraphCompose. - */ -package com.demcha.compose.document.templates.builtins; 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 a243fcf74..eb9aa07cd 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 @@ -341,8 +341,8 @@ public static BrandTheme invoiceModern() { * The "Modern Proposal" look — the same cinematic "modern business" * surfaces as {@link #invoiceModern()} (cream page, soft-tan panels, * deep-teal title + table headers, gold accent) with the richer h1 / - * h2 / h3 type scale a proposal needs. Mirrors the cinematic - * {@code builtins.ProposalTemplateV2}. + * h2 / h3 type scale a proposal needs. Drives the cinematic + * {@code ModernProposal} preset. * *

Reuses the invoice palette + spacing tokens — the two families * share one modern business look; a future cleanup may rename those 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 f1be624f0..c12007da6 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,6 +1,5 @@ /** * 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. + * records, consumed by 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/data/proposal/package-info.java b/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java index 70122c60a..aacbda968 100644 --- a/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java +++ b/src/main/java/com/demcha/compose/document/templates/data/proposal/package-info.java @@ -1,6 +1,5 @@ /** * Shared, render-neutral proposal document specs and supporting data - * records, consumed by both the cinematic builtin {@code ProposalTemplateV2} - * and the layered {@code proposal.v2} presets. + * records, consumed by the layered {@code proposal.v2} presets. */ package com.demcha.compose.document.templates.data.proposal; 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 99442667b..4afe5ec25 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 @@ -30,12 +30,11 @@ * {@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}; 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}.

+ * payment-terms footer. The visual intent is the cinematic "modern + * business" invoice look, fully theme-driven: the hero, party labels, + * table header, totals, and footer read their colours / fonts / sizes + * from the {@code BrandTheme}. The line-item body cells intentionally + * inherit the DSL default table-cell text — see {@code compose}.

* *

Why the parties render inline rather than through * {@code core.identity.PartyIdentity}: an invoice carries two diff --git a/src/main/java/com/demcha/compose/document/templates/proposal/v2/presets/ModernProposal.java b/src/main/java/com/demcha/compose/document/templates/proposal/v2/presets/ModernProposal.java index 2dd917c9d..45f064c4a 100644 --- a/src/main/java/com/demcha/compose/document/templates/proposal/v2/presets/ModernProposal.java +++ b/src/main/java/com/demcha/compose/document/templates/proposal/v2/presets/ModernProposal.java @@ -32,13 +32,11 @@ * {@link DocumentTemplate} whose {@code compose} sequences a hero panel, * an executive-summary panel, the FROM / TO parties, the body sections, * the timeline + pricing tables, the acceptance terms, and a footer. The - * visual intent is ported from the cinematic - * {@code builtins.ProposalTemplateV2}; the hero, summary, party labels, - * section headings, table headers, totals, and footer read their colours - * / fonts / sizes from the theme (replacing the {@code BusinessTheme} the - * builtin used). The table body cells intentionally inherit the DSL - * default table-cell text to stay a pixel match for the builtin — see - * {@code compose}.

+ * visual intent is the cinematic "modern business" proposal look, fully + * theme-driven: the hero, summary, party labels, section headings, table + * headers, totals, and footer read their colours / fonts / sizes from the + * {@code BrandTheme}. The table body cells intentionally inherit the DSL + * default table-cell text — see {@code compose}.

* *

Why the parties render inline rather than through * {@code core.identity.PartyIdentity}: a proposal carries two @@ -213,11 +211,9 @@ public void compose(DocumentSession document, ProposalDocumentSpec spec) { data.sender(), labelStyle, bodyStyle)) .addSection("ProposalRecipient", col -> partyBlock(col, "TO", data.recipient(), labelStyle, bodyStyle))) - // NB: the cinematic builtin ProposalTemplateV2 omits this build() - // on its first page-flow, so its hero / summary / parties never - // render — a latent bug. The layered preset fixes it: build the - // root flow so the title, executive summary, and FROM/TO parties - // are emitted before the section/timeline/pricing flows below. + // Build the root page-flow so the title, executive summary, and + // FROM/TO parties are emitted before the section/timeline/pricing + // flows below — without this build() the root flow's content drops. .build(); for (ProposalSection section : data.sections()) { diff --git a/src/test/java/com/demcha/compose/document/templates/builtins/InvoiceTemplateV2Test.java b/src/test/java/com/demcha/compose/document/templates/builtins/InvoiceTemplateV2Test.java deleted file mode 100644 index f80d6021f..000000000 --- a/src/test/java/com/demcha/compose/document/templates/builtins/InvoiceTemplateV2Test.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.demcha.compose.document.templates.builtins; - -import com.demcha.compose.GraphCompose; -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.layout.LayoutGraph; -import com.demcha.compose.document.style.DocumentInsets; -import com.demcha.compose.document.templates.api.InvoiceTemplate; -import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; -import com.demcha.compose.document.theme.BusinessTheme; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase E.1 — InvoiceTemplateV2 must compose against the canonical DSL, - * accept any BusinessTheme via the constructor, and produce a valid - * paginated PDF for the standard {@link InvoiceDocumentSpec} sample. - * - * @author Artem Demchyshyn - */ -class InvoiceTemplateV2Test { - - @Test - void defaultConstructorUsesModernTheme() { - InvoiceTemplateV2 template = new InvoiceTemplateV2(); - assertThat(template.getTemplateId()).isEqualTo("invoice-v2"); - assertThat(template.getTemplateName()).isEqualTo("Invoice V2 (cinematic)"); - } - - @Test - void invoiceTemplateImplementsCanonicalInterface() { - InvoiceTemplate template = new InvoiceTemplateV2(); - assertThat(template).isNotNull(); - } - - @Test - void composeProducesValidPdfBytesForSampleInvoice() throws Exception { - InvoiceDocumentSpec spec = sampleInvoice(); - InvoiceTemplateV2 template = new InvoiceTemplateV2(); - - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(BusinessTheme.modern().pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - - template.compose(document, spec); - byte[] bytes = document.toPdfBytes(); - assertThat(bytes).isNotEmpty(); - assertThat(new String(bytes, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)) - .as("PDF magic header — graphics-state leak would corrupt this") - .isEqualTo("%PDF-"); - } - } - - @Test - void differentThemesProduceDistinctRenderedBytes() throws Exception { - InvoiceDocumentSpec spec = sampleInvoice(); - - byte[] modernBytes = renderWithTheme(spec, BusinessTheme.modern()); - byte[] classicBytes = renderWithTheme(spec, BusinessTheme.classic()); - - // Both must be valid PDF byte streams. - assertThat(modernBytes).startsWith("%PDF-".getBytes()); - assertThat(classicBytes).startsWith("%PDF-".getBytes()); - // The themed variants should differ in bytes — the palette colours - // get embedded in the PDF stream, so a theme switch must be - // observable downstream. - assertThat(modernBytes).isNotEqualTo(classicBytes); - } - - @Test - void layoutGraphContainsLineItemsTable() throws Exception { - InvoiceDocumentSpec spec = sampleInvoice(); - InvoiceTemplateV2 template = new InvoiceTemplateV2(); - - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .margin(DocumentInsets.of(28)) - .create()) { - - template.compose(document, spec); - LayoutGraph graph = document.layoutGraph(); - assertThat(graph.totalPages()).isGreaterThanOrEqualTo(1); - // The layout graph should contain at least one node whose - // semantic name signals the line-items table — this is the - // anchor invariant that proves the template ran the table - // composition (not just the hero block). - assertThat(graph.nodes()) - .anyMatch(node -> node.semanticName() != null - && node.semanticName().contains("InvoiceLineItems")); - } - } - - private static byte[] renderWithTheme(InvoiceDocumentSpec spec, BusinessTheme theme) throws Exception { - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(theme.pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - new InvoiceTemplateV2(theme).compose(document, spec); - return document.toPdfBytes(); - } - } - - private static InvoiceDocumentSpec sampleInvoice() { - return InvoiceDocumentSpec.builder() - .title("Invoice") - .invoiceNumber("GC-2026-041") - .issueDate("02 Apr 2026") - .dueDate("16 Apr 2026") - .reference("Platform Refresh Sprint") - .status("Pending") - .fromParty(party -> party - .name("GraphCompose Studio") - .addressLines("18 Layout Street", "London, UK", "EC1A 4GC") - .email("billing@graphcompose.dev")) - .billToParty(party -> party - .name("Northwind Systems") - .addressLines("Attn: Finance Team", "410 Market Avenue", "Manchester, UK") - .email("ap@northwind.example")) - .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", "Visual validation passes", "3", "GBP 320", "GBP 960") - .summaryRow("Subtotal", "GBP 4,370") - .summaryRow("VAT (20%)", "GBP 874") - .totalRow("Total", "GBP 5,244") - .note("Please include the invoice number on your remittance advice.") - .paymentTerm("Payment due within 14 calendar days.") - .footerNote("Thank you for choosing GraphCompose for production rendering.") - .build(); - } -} diff --git a/src/test/java/com/demcha/compose/document/templates/builtins/ProposalTemplateV2Test.java b/src/test/java/com/demcha/compose/document/templates/builtins/ProposalTemplateV2Test.java deleted file mode 100644 index 8e07bec7a..000000000 --- a/src/test/java/com/demcha/compose/document/templates/builtins/ProposalTemplateV2Test.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.demcha.compose.document.templates.builtins; - -import com.demcha.compose.GraphCompose; -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.layout.LayoutGraph; -import com.demcha.compose.document.style.DocumentInsets; -import com.demcha.compose.document.templates.api.ProposalTemplate; -import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec; -import com.demcha.compose.document.theme.BusinessTheme; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase E.2 — ProposalTemplateV2 must compose against the canonical DSL, - * accept any BusinessTheme, and produce a valid PDF for the standard - * sample proposal. - * - * @author Artem Demchyshyn - */ -class ProposalTemplateV2Test { - - @Test - void defaultConstructorPicksModernTheme() { - ProposalTemplateV2 template = new ProposalTemplateV2(); - assertThat(template.getTemplateId()).isEqualTo("proposal-v2"); - assertThat(template.getTemplateName()).isEqualTo("Proposal V2 (cinematic)"); - } - - @Test - void implementsCanonicalProposalTemplateInterface() { - ProposalTemplate template = new ProposalTemplateV2(); - assertThat(template).isNotNull(); - } - - @Test - void composeProducesValidPdfBytesForSampleProposal() throws Exception { - ProposalDocumentSpec spec = sampleProposal(); - ProposalTemplateV2 template = new ProposalTemplateV2(); - - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(BusinessTheme.modern().pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - - template.compose(document, spec); - byte[] bytes = document.toPdfBytes(); - assertThat(bytes).isNotEmpty(); - assertThat(new String(bytes, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)) - .as("PDF magic header — graphics-state leak corrupts this") - .isEqualTo("%PDF-"); - } - } - - @Test - void differentThemesProduceDistinctRenderedBytes() throws Exception { - ProposalDocumentSpec spec = sampleProposal(); - - byte[] modernBytes = renderWithTheme(spec, BusinessTheme.modern()); - byte[] classicBytes = renderWithTheme(spec, BusinessTheme.classic()); - - assertThat(modernBytes).startsWith("%PDF-".getBytes()); - assertThat(classicBytes).startsWith("%PDF-".getBytes()); - assertThat(modernBytes) - .as("theme switch must be observable downstream — palette colours embed in the PDF stream") - .isNotEqualTo(classicBytes); - } - - @Test - void layoutGraphContainsTimelineAndPricingTables() throws Exception { - ProposalDocumentSpec spec = sampleProposal(); - ProposalTemplateV2 template = new ProposalTemplateV2(); - - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .margin(DocumentInsets.of(28)) - .create()) { - - template.compose(document, spec); - LayoutGraph graph = document.layoutGraph(); - assertThat(graph.totalPages()).isGreaterThanOrEqualTo(1); - assertThat(graph.nodes()) - .as("the timeline table is composed for every proposal that has timeline rows") - .anyMatch(node -> node.semanticName() != null - && node.semanticName().contains("ProposalTimeline")); - assertThat(graph.nodes()) - .as("the pricing table is composed for every proposal that has pricing rows") - .anyMatch(node -> node.semanticName() != null - && node.semanticName().contains("ProposalPricing")); - } - } - - private static byte[] renderWithTheme(ProposalDocumentSpec spec, BusinessTheme theme) throws Exception { - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(theme.pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - new ProposalTemplateV2(theme).compose(document, spec); - return document.toPdfBytes(); - } - } - - private static ProposalDocumentSpec sampleProposal() { - return ProposalDocumentSpec.builder() - .title("Proposal") - .proposalNumber("PROP-2026-014") - .preparedDate("02 Apr 2026") - .validUntil("16 Apr 2026") - .projectTitle("GraphCompose rollout") - .executiveSummary("This proposal describes a practical adoption path for reusable GraphCompose templates.") - .sender(party -> party.name("GraphCompose Studio") - .addressLines("18 Layout Street", "London, UK") - .email("hello@graphcompose.dev")) - .recipient(party -> party.name("Northwind Systems") - .addressLines("410 Market Avenue", "Manchester, UK") - .email("platform@northwind.example")) - .section("Scope", - "Introduce built-in invoice and proposal templates with a consistent business presentation layer.") - .section("Deliverables", - "Public DTOs and template interfaces for invoice and proposal rendering.") - .timelineItem("Week 1", "5 days", "Invoice API and first template delivery.") - .timelineItem("Week 2", "5 days", "Proposal layout and review loop.") - .pricingRow("Foundation", "Template APIs and DTO modeling", "GBP 3,200") - .pricingRow("Document delivery", "Invoice and proposal templates", "GBP 4,450") - .emphasizedPricingRow("Total investment", "Fixed-price project delivery", "GBP 9,500") - .acceptanceTerm("Proposal pricing is valid until the stated expiration date.") - .footerNote("Prepared to demonstrate the business-document side of GraphCompose.") - .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 index 61d6faa75..3e04254df 100644 --- 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 @@ -21,7 +21,7 @@ *

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} + * {@code ModernInvoice} renders the cinematic "modern business" invoice * look on a {@code BrandTheme}, so this gate locks that look against drift.

* *

Re-blessing baselines — after a deliberate visual diff --git a/src/test/java/com/demcha/compose/document/templates/proposal/v2/presets/ProposalV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/proposal/v2/presets/ProposalV2VisualParityTest.java index d3f2852e5..20a3dd944 100644 --- a/src/test/java/com/demcha/compose/document/templates/proposal/v2/presets/ProposalV2VisualParityTest.java +++ b/src/test/java/com/demcha/compose/document/templates/proposal/v2/presets/ProposalV2VisualParityTest.java @@ -21,7 +21,7 @@ *

Each preset renders the same canonical {@link ProposalDocumentSpec} * 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 ModernProposal} reproduces the cinematic {@code ProposalTemplateV2} + * {@code ModernProposal} renders the cinematic "modern business" proposal * look on a {@code BrandTheme}, so this gate locks that look against drift.

* *

Re-blessing baselines — after a deliberate visual diff --git a/src/test/java/com/demcha/testing/visual/CustomBusinessThemeDemoTest.java b/src/test/java/com/demcha/testing/visual/CustomBusinessThemeDemoTest.java deleted file mode 100644 index 2b81b0ac8..000000000 --- a/src/test/java/com/demcha/testing/visual/CustomBusinessThemeDemoTest.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.demcha.testing.visual; - -import com.demcha.compose.GraphCompose; -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.style.DocumentColor; -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.DocumentTableStyle; -import com.demcha.compose.document.templates.builtins.InvoiceTemplateV2; -import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; -import com.demcha.compose.document.theme.BusinessTheme; -import com.demcha.compose.document.theme.DocumentPalette; -import com.demcha.compose.document.theme.SpacingScale; -import com.demcha.compose.document.theme.TablePreset; -import com.demcha.compose.document.theme.TextScale; -import com.demcha.compose.font.FontName; -import com.demcha.testing.VisualTestOutputs; -import org.junit.jupiter.api.Test; - -import java.awt.Color; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Phase E.4 — renders the same {@link InvoiceDocumentSpec} once with the - * built-in {@link BusinessTheme#modern()} and once with a hand-built - * "Studio Emerald" {@code BusinessTheme}. The output lives under - * {@code target/visual-tests/custom-business-theme/} so a reviewer can - * open the two PDFs side-by-side and confirm the custom theme really - * does swap the visual identity (emerald primary + copper accent on - * ivory paper) without touching the template code. - * - *

The {@link #studioEmeraldTheme()} method mirrors the body of - * {@code examples/.../CustomBusinessThemeExample.studioEmeraldTheme()}; - * the example exists to be read, this test exists to keep that - * authoring path honest.

- * - * @author Artem Demchyshyn - */ -class CustomBusinessThemeDemoTest { - - @Test - void modernReferenceRendersToValidPdf() throws Exception { - renderWithTheme("invoice-modern-reference", BusinessTheme.modern()); - } - - @Test - void studioEmeraldRendersToValidPdf() throws Exception { - renderWithTheme("invoice-studio-emerald", studioEmeraldTheme()); - } - - @Test - void customThemeIsAcceptedByInvoiceTemplateV2() { - BusinessTheme custom = studioEmeraldTheme(); - // The hand-built theme must satisfy InvoiceTemplateV2's contract - // (i.e. all token slots populated) — constructing the template - // without an exception proves it. - InvoiceTemplateV2 template = new InvoiceTemplateV2(custom); - assertThat(template).isNotNull(); - assertThat(custom.name()).isEqualTo("studio-emerald"); - assertThat(custom.pageBackground()).isNotNull(); - } - - private static void renderWithTheme(String stem, BusinessTheme theme) throws Exception { - Path output = VisualTestOutputs.preparePdf(stem, "custom-business-theme"); - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(theme.pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - new InvoiceTemplateV2(theme).compose(document, sampleInvoice()); - Files.write(output, document.toPdfBytes()); - } - byte[] bytes = Files.readAllBytes(output); - assertThat(bytes).isNotEmpty(); - assertThat(new String(bytes, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); - } - - private static BusinessTheme studioEmeraldTheme() { - DocumentPalette palette = DocumentPalette.of( - new Color(20, 80, 60), - new Color(176, 116, 56), - new Color(252, 248, 240), - new Color(238, 232, 218), - new Color(34, 38, 44), - new Color(110, 116, 124), - new Color(210, 200, 180)); - SpacingScale spacing = new SpacingScale(4.0, 7.0, 11.0, 18.0, 30.0); - TextScale text = new TextScale( - style(FontName.TIMES_BOLD, 28, DocumentTextDecoration.BOLD, palette.primary()), - style(FontName.TIMES_BOLD, 17, DocumentTextDecoration.BOLD, palette.primary()), - style(FontName.HELVETICA_BOLD, 12, DocumentTextDecoration.BOLD, palette.textPrimary()), - style(FontName.HELVETICA, 10, DocumentTextDecoration.DEFAULT, palette.textPrimary()), - style(FontName.HELVETICA, 9, DocumentTextDecoration.DEFAULT, palette.textMuted()), - style(FontName.HELVETICA_BOLD, 10, DocumentTextDecoration.BOLD, palette.textPrimary()), - style(FontName.HELVETICA_BOLD, 10, DocumentTextDecoration.BOLD, palette.accent())); - DocumentInsets cellPadding = DocumentInsets.symmetric(spacing.xs(), spacing.sm()); - DocumentStroke rule = DocumentStroke.of(palette.rule(), 0.5); - TablePreset table = new TablePreset( - DocumentTableStyle.builder() - .padding(cellPadding).fillColor(palette.surface()).stroke(rule).build(), - DocumentTableStyle.builder() - .padding(cellPadding).fillColor(palette.surfaceMuted()).stroke(rule).build(), - DocumentTableStyle.builder() - .padding(cellPadding).fillColor(palette.surfaceMuted()) - .stroke(DocumentStroke.of(palette.accent(), 0.8)).build(), - DocumentTableStyle.builder() - .padding(cellPadding).fillColor(palette.surfaceMuted()).stroke(rule).build()); - return new BusinessTheme("studio-emerald", palette, spacing, text, table, palette.surface()); - } - - private static DocumentTextStyle style(FontName font, - double size, - DocumentTextDecoration decoration, - DocumentColor color) { - return DocumentTextStyle.builder() - .fontName(font).size(size).decoration(decoration).color(color).build(); - } - - private static InvoiceDocumentSpec sampleInvoice() { - return InvoiceDocumentSpec.builder() - .title("Invoice") - .invoiceNumber("GC-2026-041") - .issueDate("02 Apr 2026") - .dueDate("16 Apr 2026") - .reference("Studio Emerald — Custom Theme") - .status("Pending") - .fromParty(party -> party - .name("GraphCompose Studio") - .addressLines("18 Layout Street", "London, UK", "EC1A 4GC") - .email("billing@graphcompose.dev") - .phone("+44 20 5555 1000")) - .billToParty(party -> party - .name("Northwind Systems") - .addressLines("Attn: Finance Team", "410 Market Avenue", "Manchester, UK") - .email("ap@northwind.example")) - .lineItem("Discovery workshop", "Stakeholder interviews", "1", "GBP 1,450", "GBP 1,450") - .lineItem("Template architecture", "Reusable flows", "2", "GBP 980", "GBP 1,960") - .lineItem("Render QA", "Visual validation passes", "3", "GBP 320", "GBP 960") - .summaryRow("Subtotal", "GBP 4,370") - .summaryRow("VAT (20%)", "GBP 874") - .totalRow("Total", "GBP 5,244") - .note("Brand-coloured invoice using Studio Emerald BusinessTheme.") - .paymentTerm("Payment due within 14 calendar days.") - .footerNote("Thank you for choosing GraphCompose for production document rendering.") - .build(); - } -} diff --git a/src/test/java/com/demcha/testing/visual/InvoiceTemplateV2DemoTest.java b/src/test/java/com/demcha/testing/visual/InvoiceTemplateV2DemoTest.java deleted file mode 100644 index be03f3971..000000000 --- a/src/test/java/com/demcha/testing/visual/InvoiceTemplateV2DemoTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.demcha.testing.visual; - -import com.demcha.compose.GraphCompose; -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.style.DocumentInsets; -import com.demcha.compose.document.templates.builtins.InvoiceTemplateV2; -import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec; -import com.demcha.compose.document.theme.BusinessTheme; -import com.demcha.testing.VisualTestOutputs; -import org.junit.jupiter.api.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Renders the same {@link InvoiceDocumentSpec} via {@code InvoiceTemplateV2} - * with each of the three built-in {@link BusinessTheme} themes. The output - * lives under {@code target/visual-tests/invoice-template-v2/} so a reviewer - * can flip through the three PDFs side-by-side and see the theme switch - * visually. - * - * @author Artem Demchyshyn - */ -class InvoiceTemplateV2DemoTest { - - @Test - void modernThemeRendersToValidPdf() throws Exception { - renderTheme("invoice-modern", BusinessTheme.modern()); - } - - @Test - void classicThemeRendersToValidPdf() throws Exception { - renderTheme("invoice-classic", BusinessTheme.classic()); - } - - @Test - void executiveThemeRendersToValidPdf() throws Exception { - renderTheme("invoice-executive", BusinessTheme.executive()); - } - - private static void renderTheme(String stem, BusinessTheme theme) throws Exception { - Path output = VisualTestOutputs.preparePdf(stem, "invoice-template-v2"); - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(theme.pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - new InvoiceTemplateV2(theme).compose(document, sampleInvoice()); - Files.write(output, document.toPdfBytes()); - } - byte[] bytes = Files.readAllBytes(output); - assertThat(bytes).isNotEmpty(); - assertThat(new String(bytes, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)) - .isEqualTo("%PDF-"); - } - - private static InvoiceDocumentSpec sampleInvoice() { - return InvoiceDocumentSpec.builder() - .title("Invoice") - .invoiceNumber("GC-2026-041") - .issueDate("02 Apr 2026") - .dueDate("16 Apr 2026") - .reference("Platform Refresh Sprint") - .status("Pending") - .fromParty(party -> party - .name("GraphCompose Studio") - .addressLines("18 Layout Street", "London, UK", "EC1A 4GC") - .email("billing@graphcompose.dev") - .phone("+44 20 5555 1000") - .taxId("GB-99887766")) - .billToParty(party -> party - .name("Northwind Systems") - .addressLines("Attn: Finance Team", "410 Market Avenue", "Manchester, UK") - .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") - .lineItem("Render QA", "Visual validation passes", "3", "GBP 320", "GBP 960") - .lineItem("Developer enablement", "Examples module + onboarding notes", "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.") - .footerNote("Thank you for choosing GraphCompose for production document rendering.") - .build(); - } -} diff --git a/src/test/java/com/demcha/testing/visual/ProposalTemplateV2DemoTest.java b/src/test/java/com/demcha/testing/visual/ProposalTemplateV2DemoTest.java deleted file mode 100644 index a66b237d6..000000000 --- a/src/test/java/com/demcha/testing/visual/ProposalTemplateV2DemoTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.demcha.testing.visual; - -import com.demcha.compose.GraphCompose; -import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.style.DocumentInsets; -import com.demcha.compose.document.templates.builtins.ProposalTemplateV2; -import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec; -import com.demcha.compose.document.theme.BusinessTheme; -import com.demcha.testing.VisualTestOutputs; -import org.junit.jupiter.api.Test; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Renders the same {@link ProposalDocumentSpec} via - * {@code ProposalTemplateV2} with each of the three built-in - * {@link BusinessTheme} themes. The output lives under - * {@code target/visual-tests/proposal-template-v2/} so a reviewer can - * flip through the three PDFs side-by-side and see the theme switch - * visually. - * - * @author Artem Demchyshyn - */ -class ProposalTemplateV2DemoTest { - - @Test - void modernThemeRendersToValidPdf() throws Exception { - renderTheme("proposal-modern", BusinessTheme.modern()); - } - - @Test - void classicThemeRendersToValidPdf() throws Exception { - renderTheme("proposal-classic", BusinessTheme.classic()); - } - - @Test - void executiveThemeRendersToValidPdf() throws Exception { - renderTheme("proposal-executive", BusinessTheme.executive()); - } - - private static void renderTheme(String stem, BusinessTheme theme) throws Exception { - Path output = VisualTestOutputs.preparePdf(stem, "proposal-template-v2"); - try (DocumentSession document = GraphCompose.document() - .pageSize(595, 842) - .pageBackground(theme.pageBackground()) - .margin(DocumentInsets.of(28)) - .create()) { - new ProposalTemplateV2(theme).compose(document, sampleProposal()); - Files.write(output, document.toPdfBytes()); - } - byte[] bytes = Files.readAllBytes(output); - assertThat(bytes).isNotEmpty(); - assertThat(new String(bytes, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)) - .isEqualTo("%PDF-"); - } - - private static ProposalDocumentSpec sampleProposal() { - return ProposalDocumentSpec.builder() - .title("Proposal") - .proposalNumber("PROP-2026-014") - .preparedDate("02 Apr 2026") - .validUntil("16 Apr 2026") - .projectTitle("GraphCompose rollout for internal document operations") - .executiveSummary("This proposal describes a practical adoption path for reusable GraphCompose templates, render tests, and runnable examples across billing, hiring, and client-facing delivery workflows.") - .sender(party -> party - .name("GraphCompose Studio") - .addressLines("18 Layout Street", "London, UK", "EC1A 4GC") - .email("hello@graphcompose.dev") - .phone("+44 20 5555 1000") - .website("graphcompose.dev")) - .recipient(party -> party - .name("Northwind Systems") - .addressLines("Product Engineering", "410 Market Avenue", "Manchester, UK") - .email("platform@northwind.example") - .phone("+44 161 555 2200") - .website("northwind.example")) - .section("Scope", - "Introduce built-in invoice and proposal templates with a consistent business presentation layer.", - "Keep the production artifact clean by moving development-only preview code out of the published runtime scope.") - .section("Deliverables", - "Public DTOs and template interfaces for invoice and proposal rendering.", - "Render tests and a standalone examples module that generates PDF files on demand.") - .timelineItem("Week 1", "5 days", "Invoice API and first template delivery.") - .timelineItem("Week 2", "5 days", "Proposal layout, review loop, and render tests.") - .timelineItem("Week 3", "3 days", "Examples module and README handoff.") - .pricingRow("Foundation", "Template APIs and DTO modeling", "GBP 3,200") - .pricingRow("Document delivery", "Invoice and proposal templates with tests", "GBP 4,450") - .emphasizedPricingRow("Total investment", "Fixed-price project delivery", "GBP 9,500") - .acceptanceTerm("Proposal pricing is valid until the stated expiration date.") - .acceptanceTerm("Additional template families can be scoped in a separate phase.") - .footerNote("Prepared to demonstrate the business-document side of GraphCompose.") - .build(); - } -}