Template authoring

July 12, 2026 ยท View on GitHub

๐Ÿ—„๏ธ Archived. This cheatsheet covers the classic surface (CvSpec, CvBuilder, *Presets with BusinessTheme) that was removed in GraphCompose 2.0. It is kept for readers maintaining a pre-2.0 caller. The live authoring cheatsheet is templates/v2-layered/authoring-presets.md; the migration map is in which-template-system.md ยง 3. Nothing below is updated anymore.

This page is the single reference for writing new templates and DSL code without boilerplate. Read it once before starting a template; keep it open while you're writing one.

The companion long-form docs are ../../getting-started.md (concepts) and ../../contributing/extension-guide.md (engine internals). When the cheatsheet says "see X recipe", the file lives under ../../recipes/.


1. Entry & output pipeline

try (DocumentSession document = GraphCompose.document(Path.of("output.pdf"))
        .pageSize(DocumentPageSize.A4)
        .pageBackground(theme.pageBackground())   // optional
        .margin(28, 28, 28, 28)
        .create()) {

    document.pageFlow(page -> page
        .addSection("Hero", section -> section
            .softPanel(theme.palette().surfaceMuted(), 10, 14)
            .addParagraph(p -> p.text("Hello").textStyle(theme.text().h1()))));

    document.buildPdf();           // file path supplied above
}
You wantMethod
Write a file (path was set)document.buildPdf()
Write a file (no path was set)document.buildPdf(Path)
Stream to HTTP / S3document.writePdf(OutputStream)
Buffer in memorydocument.toPdfBytes() โ†’ byte[]
Non-PDF backend (DOCX, โ€ฆ)document.export(SemanticBackend, Path)
Snapshot for regression testsdocument.layoutSnapshot() โ†’ LayoutSnapshot

writePdf does not close the supplied stream. The session itself must be closed (try-with-resources).


2. Builder hierarchy

DocumentSession
 โ””โ”€ pageFlow() โ†’ PageFlowBuilder            (extends AbstractFlowBuilder)
     โ”œโ”€ addSection(name, spec)  โ†’ SectionBuilder    (extends AbstractFlowBuilder)
     โ”œโ”€ module(title, spec)     โ†’ ModuleBuilder     (extends AbstractFlowBuilder, prepends a title paragraph)
     โ”œโ”€ addRow(spec)            โ†’ RowBuilder        (horizontal, atomic; rejects nested rows + tables)
     โ”‚   โ””โ”€ addSection / addParagraph / addImage / addLayerStack / โ€ฆ
     โ”œโ”€ addParagraph(spec)      โ†’ ParagraphBuilder    (text or RichText, mutually exclusive)
     โ”œโ”€ addList(spec)           โ†’ ListBuilder        (bullets / dashes / numbers / custom marker)
     โ”œโ”€ addTable(spec)          โ†’ TableBuilder       (splittable; never inside a row)
     โ”œโ”€ addImage / addShape / addEllipse / addLine / addDivider / addSpacer / addBarcode / addPageBreak
     โ”œโ”€ addContainer(spec)      โ†’ ShapeContainerBuilder  (clipped composite, Transformable)
     โ””โ”€ addLayerStack(spec)     โ†’ LayerStackBuilder      (atomic overlay, allowed inside rows)

document.dsl() exposes the same primitives as detached factories when you need a DocumentNode outside a flow (e.g. composing a LayerStack ahead of time).


3. Per-builder one-liners

BuilderProducesTop fluent methodsWatch out for
PageFlowBuilderroot ContainerNode, auto-attaches on build()name, spacing, softPanel, addSection, moduleForgetting trailing .build() โ€” root is never attached
SectionBuilder / ModuleBuilderSectionNodespacing, padding, margin, softPanel(...), accentLeft/Right/Top/Bottom(color, w), cornerRadius(DocumentCornerRadius)ModuleBuilder adds title(), bullets(), dashList(), table()
RowBuilderRowNodespacing, weights(double...), evenWeights(), addSection(spec) (column)gap() is deprecated since 1.5 โ€” use spacing. Rejects nested RowNode and any TableNode at add() time
ParagraphBuilderParagraphNodetext, textStyle, align, lineSpacing(double), rich(Consumer<RichText>), inlineLink/inlineImagetext() and rich() are mutually exclusive
ListBuilderListNodeitems(...), bullet() / dash() / noMarker() / marker(String), textStyle, itemSpacing, continuationIndentnormalizeMarkers(true) strips leading - / * from raw input
TableBuilderTableNodecolumns(...) / autoColumns(int), headerRow(...) + headerStyle(style), row(...), totalRow(style, ...), zebra(odd, even), repeatHeader()Style precedence at build(): rowStyle/headerStyle/totalRow always beat zebra
ImageBuilderImageNode`source(Pathbyte[]
ShapeBuilderShapeNodesize, fillColor, stroke, `cornerRadius(doubleDocumentCornerRadius)`
ShapeContainerBuilderShapeContainerNode (Transformable)rectangle/roundedRect/ellipse/circle(...), clipPolicy(ClipPolicy), 9-point alignment shortcuts, position(node, dx, dy, align[, z]), rotate/scalebuild() throws if outline missing. Default ClipPolicy.CLIP_PATH
LayerStackBuilderLayerStackNode (atomic)layer(node[, align[, z]]), 9-point shortcuts, back(node), position(node, dx, dy, align[, z])Allowed inside a RowBuilder. Source order = paint order unless zIndex is set
DividerBuilderShapeNodewidth, thickness, colorSubclass of ShapeBuilder; default 1pt LIGHT_GRAY
SpacerBuilderSpacerNodesize(w, h) / width() / height()โ€”
LineBuilderLineNodehorizontal(w) / vertical(h) / diagonal, color, thicknessโ€”
EllipseBuilderEllipseNodecircle(d) / size(w, h), fillColor, strokeaddCircle(d, fill) is the convenience overload on the parent flow
BarcodeBuilderBarcodeNodedata, qrCode/code128/code39/ean13/ean8, size, quietZone(int)Default type is QR_CODE
PageBreakBuilderPageBreakNodename, marginThat's the entire surface
RichTextList<InlineRun>text(String) (static seed), plain/bold/italic/underline/strikethrough, color/accent/size, link(text, uri), image(...)Pass either a built RichText or a Consumer<RichText> to ParagraphBuilder.rich(...)
Transformable<T>mixin (currently only ShapeContainerBuilder)rotate(deg), scale(uniform), scale(sx, sy)Render-only; layout snapshot stays deterministic

4. Style types

TypeFactoriesGotcha
DocumentColorBLACK / WHITE / GRAY / DARK_GRAY / LIGHT_GRAY / ROYAL_BLUE / ORANGE, of(Color), rgb(r, g, b)In a themed template, always route through theme.palette().* instead of constants
DocumentStrokeof(color) (1pt), of(color, width)Width must be finite & non-negative
DocumentInsetszero(), of(value), symmetric(v, h), top(v), bottom(v), left(v), right(v)Constructor order is TRBL, not CSS shorthand
DocumentTextStyleDEFAULT, builder().fontName().size().decoration().color().build(), withSize(...), withColor(...)Default font is FontName.HELVETICA, default size is 14pt if 0/negative
DocumentCornerRadiusZERO, of(r), of(tl,tr,br,bl), right(r), left(r), top(r), bottom(r), .isUniform(), .isZero()Order is TL, TR, BR, BL. Render-only; clamped to half of smaller side
DocumentTextDecorationDEFAULT, BOLD, ITALIC, BOLD_ITALIC, UNDERLINE, STRIKETHROUGHโ€”
ClipPolicyCLIP_BOUNDS, CLIP_PATH, OVERFLOW_VISIBLEDOCX backend ignores CLIP_PATH and emits a one-time capability warning
ShapeOutlineRectangle(w,h), RoundedRectangle(w,h,r), Ellipse(w,h), circle(d)All dimensions must be positive
DocumentTransformNONE, rotate(deg), scale(u), scale(sx,sy), withRotation, withScale, .isIdentity()Scale must be non-zero. Rotation is around placement centre

5. Theme system

Palette slots โ†’ role

SlotWhere it goes
palette().primary()Document title (h1 colour), brand accents on tables/headers
palette().accent()Status keywords (RichText), border strips (accentLeft/Right/...), badges, total-row borders
palette().surface()Page background fallback, default table cell fill
palette().surfaceMuted()Hero softPanel fill, table header / total / zebra fill
palette().textPrimary()Body text colour, h3 heading
palette().textMuted()Captions, metadata
palette().rule()Table cell borders, divider lines, accent strip background

Text scale โ†’ reach for these (don't rebuild styles)

SlotUse for
text().h1()Document title (one per page-flow)
text().h2()Section heading
text().h3()Sub-heading inside a section
text().body()Default paragraph
text().caption()Captions, metadata, footer notes
text().label()Form labels, table header text
text().accent()Inline accent (status keyword via RichText)

Table preset slots

Preset slotApply via
table().defaultCellStyle()TableBuilder.defaultCellStyle(style)
table().headerStyle()TableBuilder.headerStyle(style)
table().totalRowStyle()TableBuilder.totalRow(style, ...)
table().zebraStyle()TableBuilder.zebra(odd, even) (use surface() as the "odd" if you want a clean alternate)

Built-in themes

  • BusinessTheme.classic() โ€” crisp blue + white, no page background
  • BusinessTheme.modern() โ€” cream paper + teal/gold, pageBackground = palette.surface()
  • BusinessTheme.executive() โ€” graphite + warm accent, no page background

BusinessTheme.withPageBackground(color) and withName(name) give you cheap forks. To brand for your project, hand-build a BusinessTheme(...) once โ€” see BusinessTheme.


6. Golden patterns (copy these)

6.1 Theme-driven hero with right-rounded soft panel + left accent stripe

.addSection("Hero", hero -> hero
    .softPanel(theme.palette().surfaceMuted(), DocumentCornerRadius.right(10), 14)
    .accentLeft(theme.palette().accent(), 4)
    .spacing(6)
    .addParagraph(p -> p.text(spec.title()).textStyle(theme.text().h1()))
    .addParagraph(p -> p.text(spec.subtitle()).textStyle(theme.text().caption())))

The soft panel sits flush against the accent stripe โ€” round only the right corners so the join is clean.

6.2 Address blocks with lineSpacing(1.3)

.addParagraph(p -> p.text(joinAddress(party))
    .textStyle(theme.text().body())
    .lineSpacing(1.3)               // default 1.0 squashes \n-joined lines
    .margin(DocumentInsets.zero()))

6.3 Two-column block via addRow with weights

.addRow("Parties", row -> row
    .spacing(18)
    .weights(1, 1)
    .addSection("From",   col -> col.addParagraph(...))
    .addSection("BillTo", col -> col.addParagraph(...)))

weights distributes the row's width; addSection is the canonical "column". Don't try to put a TableNode here โ€” RowBuilder rejects it at add() time.

6.4 Themed table with header + zebra + repeating header

.addTable(t -> {
    TableBuilder configured = t
        .columns(DocumentTableColumn.auto(), DocumentTableColumn.fixed(54))
        .defaultCellStyle(borderedCell)
        .headerRow("Description", "Qty")
        .headerStyle(headerStyle)
        .repeatHeader()                                // re-emit on each continuation page
        .zebra(theme.palette().surfaceMuted(), theme.palette().surface());

    spec.lineItems().forEach(item -> configured.row(item.description(), item.qty()));
    configured.totalRow(totalStyle, "Total", spec.total());
})

6.5 Inline accent via RichText

.addParagraph(p -> p.rich(rich -> rich
    .plain("Status ")
    .accent(spec.status(), theme.palette().accent())))

Highlights one keyword inline โ€” no extra ParagraphBuilder and no manual style juggling.

6.6 Per-side accent strips around a header

section
    .accentLeft(theme.palette().accent(), 4)     // left edge stripe
    .accentBottom(theme.palette().rule(), 1);    // thin rule under the header

7. Anti-patterns (don't do these)

  1. No raw new Color(...) or DocumentColor.rgb(...) in a themed template. Route through theme.palette().* so a theme switch actually re-skins.
  2. No re-built DocumentTextStyle.builder() for h1 / h2 / body. Use theme.text().h1() etc. โ€” that's literally what the text scale is for.
  3. No RowBuilder.gap() โ€” it's @Deprecated(since="1.5.0"). Use spacing(...) so vertical and horizontal flows share a verb.
  4. Don't put RowNode or TableNode inside a RowBuilder. Throws IllegalArgumentException at add() time. Use addSection(...) for "column-with-vertical-stack-of-stuff", and put tables in the surrounding flow.
  5. Don't call no-arg buildPdf() if the session was created via GraphCompose.document() (no path). Throws IllegalStateException. Use buildPdf(Path), writePdf(OutputStream), or toPdfBytes().
  6. Don't import com.demcha.compose.engine.* from a template. Enforced by PublicApiNoEngineLeakTest. Everything you need lives under com.demcha.compose.document.*.
  7. Don't construct a ShapeContainerBuilder without an outline. build() throws. Always call rectangle/roundedRect/ellipse/circle(...) first.
  8. Don't forget the trailing .build() on pageFlow(). Without it the root never attaches to the session.
  9. Don't mix pageFlow(Consumer) and pageFlow().build() for the same root. Pick one ergonomics style and stick with it within a template.
  10. Don't compose a hero around a LayerStack without giving it a fixed size. Layer stacks are atomic โ€” they need either an explicit padding/margin plus a sized back(...) layer, or a ShapeContainerBuilder outline driving the bounds.

8. New-template skeleton

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.DocumentColor;
import com.demcha.compose.document.style.DocumentCornerRadius;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.table.DocumentTableColumn;
import com.demcha.compose.document.table.DocumentTableStyle;
import com.demcha.compose.document.theme.BusinessTheme;

import java.util.Objects;

/**
 * Starter template โ€” copy and rename.
 *
 * Pattern: theme injected via constructor, every visible token comes
 * from {@code theme.palette()}, {@code theme.text()}, or
 * {@code theme.table()}. The same template renders in any
 * {@code BusinessTheme} without code changes.
 */
public final class StatusReportTemplateV1 implements StatusReportTemplate {

    private final BusinessTheme theme;

    public StatusReportTemplateV1() {
        this(BusinessTheme.modern());
    }

    public StatusReportTemplateV1(BusinessTheme theme) {
        this.theme = Objects.requireNonNull(theme, "theme");
    }

    @Override public String getTemplateId()   { return "status-report-v1"; }
    @Override public String getTemplateName() { return "Status Report V1"; }

    @Override
    public void compose(DocumentSession document, StatusReportSpec spec) {
        DocumentColor accent       = theme.palette().accent();
        DocumentColor surfaceMuted = theme.palette().surfaceMuted();
        DocumentColor rule         = theme.palette().rule();

        DocumentTableStyle borderedCell = DocumentTableStyle.builder()
                .stroke(DocumentStroke.of(rule, 0.6))
                .padding(DocumentInsets.of(7))
                .build();
        DocumentTableStyle headerStyle = DocumentTableStyle.builder()
                .fillColor(theme.palette().primary())
                .stroke(DocumentStroke.of(rule, 0.6))
                .padding(DocumentInsets.of(8))
                .textStyle(theme.text().label().withColor(theme.palette().surface()))
                .build();
        DocumentTableStyle totalStyle = DocumentTableStyle.builder()
                .fillColor(surfaceMuted)
                .stroke(DocumentStroke.of(rule, 0.6))
                .textStyle(theme.text().label())
                .build();

        document.pageFlow()
                .name("StatusReportRoot")
                .spacing(14)

                // Hero โ€” right-rounded soft panel + left accent stripe
                .addSection("Hero", hero -> hero
                        .softPanel(surfaceMuted, DocumentCornerRadius.right(10), 14)
                        .accentLeft(accent, 4)
                        .spacing(6)
                        .addParagraph(p -> p.text(spec.title()).textStyle(theme.text().h1()))
                        .addParagraph(p -> p.text(spec.subtitle()).textStyle(theme.text().caption())))

                // Summary section
                .addSection("Summary", section -> section
                        .spacing(4)
                        .addParagraph(p -> p.text("Summary").textStyle(theme.text().h2()))
                        .addParagraph(p -> p.text(spec.summary())
                                .textStyle(theme.text().body())
                                .lineSpacing(1.3)))

                // Metrics table โ€” themed header + zebra + total row + repeating header
                .addTable(table -> {
                    TableBuilder configured = table
                            .name("Metrics")
                            .columns(DocumentTableColumn.auto(), DocumentTableColumn.fixed(96))
                            .defaultCellStyle(borderedCell)
                            .headerRow("Metric", "Value")
                            .headerStyle(headerStyle)
                            .repeatHeader()
                            .zebra(surfaceMuted, theme.palette().surface());
                    spec.metrics().forEach(m -> configured.row(m.name(), m.value()));
                    configured.totalRow(totalStyle, "Total", spec.total());
                })
                .build();
    }
}

Render the template via:

BusinessTheme theme = BusinessTheme.modern();
StatusReportTemplate template = new StatusReportTemplateV1(theme);

try (DocumentSession document = GraphCompose.document(Path.of("status.pdf"))
        .pageSize(DocumentPageSize.A4)
        .pageBackground(theme.pageBackground())
        .margin(28, 28, 28, 28)
        .create()) {
    template.compose(document, spec);
    document.buildPdf();
}

9. Template testing pattern

A new template gets two test layers:

class StatusReportTemplateV1Test {

    // 1. Layout snapshot โ€” deterministic, machine-stable JSON.
    @Test
    void pinsLayoutForModernTheme() throws Exception {
        try (DocumentSession document = newSession(BusinessTheme.modern())) {
            new StatusReportTemplateV1(BusinessTheme.modern()).compose(document, sample());
            LayoutSnapshotAssertions.assertMatches(document,
                    "templates/status-report/v1_modern");
        }
    }

    // 2. Visual PDF โ€” the file a reviewer opens.
    @Test
    void rendersValidPdfForModernTheme() throws Exception {
        Path output = VisualTestOutputs.preparePdf("status-report-modern", "status-report-v1");
        try (DocumentSession document = newSession(BusinessTheme.modern())) {
            new StatusReportTemplateV1(BusinessTheme.modern()).compose(document, sample());
            Files.write(output, document.toPdfBytes());
        }
        byte[] bytes = Files.readAllBytes(output);
        assertThat(bytes).isNotEmpty();
        assertThat(new String(bytes, 0, 5, US_ASCII)).isEqualTo("%PDF-");
    }
}

Pin one snapshot per supported theme. The first run writes the JSON under core/src/test/resources/layout-snapshots/...; re-run with -Dgraphcompose.updateSnapshots=true to accept a deliberate change.

PDFs land under target/visual-tests/<test-folder>/<stem>.pdf. They are renderer-bytes checks โ€” useful for catching crashes and quick visual review, not as the regression contract (use snapshots for that).

For the runnable end-to-end pattern see InvoiceV2VisualParityTest and LayoutSnapshotRegressionExample.


10. Where to look next

NeedFile
First-time orientationgetting-started.md
Theme deep-dive (palette, scales, bridge)recipes/themes.md
Tables (row span, zebra, totals, repeat header)recipes/tables.md
Shapes-as-containers, transforms, z-indexrecipes/shape-as-container.md + recipes/transforms.md
Adding a new node / builder / backendextension-guide.md + recipes/extending.md
Streaming output to HTTP / S3recipes/streaming.md
Snapshot-based regression workflowrecipes/extending.md ยง 4
Migrating from v1.4 to v1.5migration-v1-4-to-v1-5.md
Architecture decision recordsadr/
Reference templatesModernInvoice, ModernProposal
Hand-built theme exampleBusinessTheme