Layout snapshot tests are the primary geometry-regression layer in GraphCompose.
They sit between unit-level layout math tests and final PDF render tests:
- unit tests validate isolated geometry rules
- layout snapshot tests validate the resolved document tree after layout and pagination
- PDF render tests remain the outer smoke and human inspection layer
This ordering makes regressions cheap to diagnose:
- if coordinates drift, the JSON snapshot fails immediately
- if layering or pagination changes unexpectedly, the diff shows it directly
- if the snapshot still matches but the final PDF looks wrong, the issue is likely in rendering rather than layout
Visual PDF tests are still useful, but they are expensive to inspect and harder to diff precisely.
Layout snapshots solve a different problem: they let the library compare resolved geometry directly, before rendered pixels become the source of truth.
Use them when you want to know that:
- a node moved to a different coordinate
- a page break started or ended on a different page
- sibling ordering changed
Layer(depth)resolution changed- a template still resolves to the same layout after internal engine changes
DocumentSession.layoutSnapshot() captures the document after layout and pagination, but before PDF rendering.
That means the coordinates in the snapshot are:
- after layout resolution
- after page-breaking decisions have been applied
- before any PDFBox drawing happens
In other words, the snapshot represents the layout engine's resolved truth, not the renderer's output.
layoutSnapshot() is a debug and test API.
It does not render the PDF by itself.
If you later call:
buildPdf()toPdfBytes()render(...)
on the same DocumentSession, GraphCompose reuses the already resolved layout so the
debug snapshot and final PDF stay in sync.
This matters for two reasons:
- the runtime PDF path stays clean and predictable for normal library users
- snapshot-first regression tests can still render the exact same resolved layout for inspection
If application code never calls layoutSnapshot(), this feature does not change the normal output pipeline.
try (DocumentSession document = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(24, 24, 24, 24)
.create()) {
document.pageFlow(page -> page
.module("Snapshot Example", module -> module.paragraph("Hello GraphCompose")));
LayoutSnapshot snapshot = document.layoutSnapshot();
}Use the test harness for normal snapshot regression coverage:
import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;
@Test
void shouldMatchInvoiceLayoutSnapshotAndRenderPdf() throws Exception {
Path outputFile = VisualTestOutputs.preparePdf("invoice_render_file", "clean", "templates", "invoice");
try (DocumentSession document = GraphCompose.document(outputFile)
.pageSize(DocumentPageSize.A4)
.margin(22, 22, 22, 22)
.create()) {
template.compose(document, spec);
LayoutSnapshotAssertions.assertMatches(document, "canonical-templates/invoice/invoice_standard_layout");
document.buildPdf();
}
}This gives one test two kinds of feedback:
- machine-precise layout regression coverage
- a PDF artifact for visual inspection
If you are adding a new feature, template, or pagination case, the fastest way to add snapshot coverage is:
- create a JUnit test that instantiates a canonical
DocumentSession - compose the document into that session
- call
LayoutSnapshotAssertions.assertMatches(...) - optionally call
buildPdf()if you also want a PDF artifact for visual inspection
Minimal pattern:
import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;
import org.junit.jupiter.api.Test;
class MyFeatureLayoutSnapshotTest {
@Test
void shouldKeepMyFeatureLayoutStable() throws Exception {
try (DocumentSession document = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(22, 22, 22, 22)
.create()) {
feature.compose(document, fixtureData());
LayoutSnapshotAssertions.assertMatches(
document,
"features/my_feature_layout");
}
}
}First run for a brand-new snapshot:
./mvnw "-Dgraphcompose.updateSnapshots=true" "-Dtest=MyFeatureLayoutSnapshotTest" testThat creates the committed baseline under:
core/src/test/resources/layout-snapshots/features/my_feature_layout.json
Normal verification run after that:
./mvnw "-Dtest=MyFeatureLayoutSnapshotTest" testIf the test fails, compare:
- expected baseline in
core/src/test/resources/layout-snapshots/... - generated actual file in
target/visual-tests/layout-snapshots/.../*.actual.json
Use snapshot tests when the thing you care about is layout stability. If you need visual confirmation too, keep document.buildPdf() in the same test or pair the snapshot test with a render test.
Library consumers can use the same public helpers that GraphCompose uses in its own tests:
import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;
import org.junit.jupiter.api.Test;
class InvoiceTemplateSnapshotTest {
@Test
void shouldKeepInvoiceLayoutStable() throws Exception {
DocumentTemplate<InvoiceDocumentSpec> template = ModernInvoice.create();
InvoiceDocumentSpec spec = invoiceFixture();
try (DocumentSession document = GraphCompose.document()
.pageSize(DocumentPageSize.A4)
.margin(22, 22, 22, 22)
.create()) {
template.compose(document, spec);
LayoutSnapshotAssertions.assertMatches(document, "canonical-templates/invoice/invoice_standard_layout");
}
}
}Repository snapshot coverage should be authored against the canonical DocumentSession path because layoutSnapshot() now lives there directly.
If you want different baseline folders in your own project, use the public overloads with custom roots:
LayoutSnapshotAssertions.assertMatches(
document,
Path.of("src", "test", "resources", "layout-snapshots"),
Path.of("target", "visual-tests", "layout-snapshots"),
"consumer/invoice_layout");DocumentSession.layoutSnapshot() extracts a deterministic JSON snapshot of the resolved entity tree.
The snapshot intentionally contains stable layout data only:
- format version
- canvas and page metadata
- total page count
- deterministic node paths
- parent path and child index
- depth and layer
- computed coordinates
- placement box coordinates, size, and page span
- content size
- margin and padding
Since 2.2.2 layoutSnapshot(...) can also report typography. It is opt-in,
and it does not live on LayoutSnapshot:
LayoutDiagnosticSnapshot rich = document.layoutSnapshot(
LayoutSnapshotOptions.builder().typography(true).build());
LayoutSnapshot layout = rich.layout(); // identical to document.layoutSnapshot()LayoutSnapshot still has exactly the four components it had in 2.0, so its
JSON, its toString() and its equals are unchanged however you serialize
it — through LayoutSnapshotJson, through an ObjectMapper of your own, or
by hand. Nothing added here can reach a baseline you already have on disk.
LayoutDiagnosticSnapshot wraps that snapshot and carries the sections you
asked for. Its formatVersion versions the envelope, independently of the
layout snapshot's 2.0: adding a section later moves one and not the other.
LayoutSnapshotOptions is a builder so that next section costs a method rather
than a new layoutSnapshot(...) overload.
LayoutSnapshotOptions is a builder so a later section — links, paint,
accessibility — is one more method rather than one more overload.
Each entry describes one resolved paragraph fragment:
- the declared font, the resolved family, and the decoration that picks
the face within it, plus a
fontSubstitutedflag - font size and line count
- the bounds of the laid-out line boxes
- one entry per line: index, bounds, and baseline, in absolute page coordinates
It hangs off fragments rather than nodes because that is what text is: a paragraph
broken across a page boundary has one fragment per page, each with its own lines.
Join it to nodes on path — one-to-many, since a node such as a chart owns many
text fragments. Entries are ordered by path, then page, then emission ordinal.
All three font fields are needed to identify a face. HELVETICA_BOLD is an alias of
its family and contributes nothing on its own — the bold comes from the decoration —
so fontSubstituted is true for HELVETICA_BOLD with no decoration (it renders
regular) and false for HELVETICA_BOLD with BOLD (it renders exactly what it
named). resolvedFamily, decoration and fontSize describe the text the engine
measured, after any autoSize shrink or span override.
baselineExactisfalsefor a paragraph using a non-defaultTextVerticalAlign: that mode shifts the glyphs by a correction derived from the backend font's cap height, and a renderer-neutral snapshot has no backend font to ask. The line box is not shifted with them, so treat the whole entry as positional there, not as a bound on painted output.- Bounds are laid-out line boxes, not tight glyph ink. A code chip's fill extends past them by its own padding, and an inline graphic on the baseline can rise above them.
- Text drawn outside the paragraph pipeline — a table cell written as a plain string, a header, a watermark — produces no entry. An empty list means "no paragraph text", not "no text".
- Coordinates are the laid-out ones, so a shape container carrying a
transform, or one that clips its children, is not reflected. - A paragraph whose spans mix fonts is described by its first span.
It intentionally excludes unstable or noisy values such as:
- UUIDs
- raw text payload — including the text of each line, which is why a line is identified by its index
- colors
- PDF resource ids
Each node is identified by stable tree order plus semantic naming.
The extractor uses the following strategy:
- prefer
EntityNamewhen it exists - otherwise fall back to
<entityKind>[childIndex] - build the final identity from the full parent path
This keeps sibling collisions deterministic and makes diffs readable even when many nodes share the same render kind.
The extractor also normalizes numeric values before serialization. The current default is rounding doubles to 3 decimal places so snapshots stay stable across tiny floating-point differences while still catching real layout regressions.
Committed baselines:
core/src/test/resources/layout-snapshots/...
Mismatch artifacts generated during normal test runs:
target/visual-tests/layout-snapshots/.../*.actual.json
Prefer semantic names that describe the document state:
canonical-templates/invoice/invoice_standard_layoutcanonical-templates/proposal/proposal_long_layoutintegration/table_pagination_testtemplates/cv/font-themes/template_cv_1_poppins
The last path segment becomes the JSON file name. The preceding segments become folders.
Normal mode compares against committed baselines:
./mvnw testTo accept an intentional layout change locally:
./mvnw "-Dgraphcompose.updateSnapshots=true" testOr update a focused test only:
./mvnw "-Dgraphcompose.updateSnapshots=true" "-Dtest=MyFeatureLayoutSnapshotTest" testThe same property works for downstream projects that use LayoutSnapshotAssertions from the published graph-compose-testing artifact.
In normal mode:
- expected JSON stays committed in
core/src/test/resources/layout-snapshots - mismatches write an
.actual.jsonartifact undertarget/visual-tests/layout-snapshots - the assertion failure points to both expected and actual paths
In update mode:
- the baseline JSON is overwritten intentionally
- the
.actual.jsonmismatch artifact is removed if it exists
CI should never enable graphcompose.updateSnapshots.
The expected behavior in CI is strict comparison only:
- match the committed baseline
- write
.actual.jsonwhen there is a mismatch - fail fast so the diff can be reviewed locally
This keeps baseline updates explicit and prevents accidental golden-file drift in automated pipelines.
When adding snapshot coverage to an existing visual test:
- if the test already creates a
DocumentSession, addLayoutSnapshotAssertions.assertMatches(...)beforebuildPdf() - keep the composition path explicit so the same canonical document can be snapshotted and rendered in one test
- keep the existing PDF render assertion and artifact generation
- generate the baseline once with
-Dgraphcompose.updateSnapshots=true
The recommended developer flow is:
- unit tests for local layout math
- layout snapshot tests for full-document geometry regressions
- PDF render tests for final visual confidence
Prioritize documents that are most sensitive to layout regressions:
- multi-page templates
- tables with pagination
- nested container compositions
- documents where sibling order or
Layer(depth)matters - theme or font variants that affect text measurement
FontShowcaseLayoutSnapshotTestChartLayoutSnapshotTestShapeContainerLayoutSnapshotTestLayoutSnapshotPublicApiDogfoodTest
If a snapshot fails:
- open the
.actual.jsonfile undertarget/visual-tests/layout-snapshots - compare path, coordinates, page span, and layer/order changes
- decide whether the change is expected
- if expected, re-run with
-Dgraphcompose.updateSnapshots=true - if not expected, investigate the layout math before trusting the rendered PDF
Useful signals to check first:
startPageandendPagecomputedXandcomputedYplacementX,placementY,width, andheight- node
path layer
Layout snapshots are not a replacement for every test.
Do not use them as the only safety net when:
- you are testing renderer-specific drawing behavior
- the failure you care about is pixel-level rather than geometry-level
- a small unit test can prove the same rule more directly
They work best as the middle layer in the test pyramid, not as the only layer.