From 85a5ab2231d56751ea09db1b0c85920a23e5ceef Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 21 Sep 2026 15:34:49 +0100
Subject: [PATCH 1/2] feat(api): let a semantic export backend ask for the
compiled layout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A semantic backend walks the authored tree and is handed no geometry. That is
right for most of them and wrong for the ones that need a number the engine has
already worked out — a resolved column width, a settled page count — and today
there is no way to ask: the backend either re-derives it, which means measuring
text it has no font runtime to measure, or does without.
SemanticBackend.requiresResolvedLayout() defaults to false. A backend that
returns true is handed the same session's compiled layout in
SemanticExportContext.layoutGraph(), and DocumentRenderingFacade resolves one
only for a backend that asked.
The default is the load-bearing half. Compiling a layout measures text, and
measurement needs a font runtime that lives in a render module — so resolving
one for every semantic export would quietly make a render backend a hard
requirement of exports that do not render, and adding the DOCX artifact alone
would stop being enough to export DOCX. A backend that says nothing causes no
layout to be compiled at all.
Consistency comes free rather than by convention: the graph, the canvas and the
layout are all read off the same session state at the same revision, so a
backend given both is given a layout compiled from the graph beside it. An edit
between two exports is visible to the second, which the qa test asserts by
watching the page count grow.
SemanticExportContext gains the component as a record, which moves the canonical
constructor to five arguments and would remove the published four-argument
descriptor. That one is written out by hand instead, so callers compiled against
it keep linking; its Javadoc says why it exists rather than leaving the next
reader to delete it as redundant. requireLayoutGraph() is for a backend that
asked and would rather the absence explained itself than hand back null.
All three members are @Beta and named in docs/api-stability.md. The seam is new
and what a semantic backend should be *given* is still settling — a later minor
may narrow it to the measurements a backend actually needs rather than the whole
graph — and Experimental is what keeps that possible without a major.
Verification: ./mvnw -B -ntp test -Dtest=SemanticExportContextTest -pl
:graph-compose-core (5 tests) and -Dtest=SemanticExportLayoutContextTest -pl
:graph-compose-qa -am (4 tests), both BUILD SUCCESS. The core tests cover what
needs no session — the default, both published constructors, the explained
absence, the copied font collection; the qa tests cover what needs a live
document and a font runtime — a layout is handed over, is withheld from a
backend that did not ask, tracks an edit between exports, and describes the
canvas the export was configured with.
Binary compatibility: ./mvnw -B -ntp -DskipTests -P japicmp verify -pl
:graph-compose-core,:graph-compose-templates, BUILD SUCCESS against the 2.x
floor, with a non-empty core/target/japicmp/japicmp-against-baseline.xml — an
execution that does not run writes no report and fails nothing, so the green
step alone would not have proved the comparison happened.
Knowledge pack regenerated in this change; docs/api-stability.md names the new
@Beta members, which check-stability-doc.mjs verifies.
---
CHANGELOG.md | 18 +++
.../document/api/DocumentRenderingFacade.java | 9 +-
.../backend/semantic/SemanticBackend.java | 24 +++
.../semantic/SemanticExportContext.java | 55 ++++++-
.../semantic/SemanticExportContextTest.java | 88 +++++++++++
docs/api-stability.md | 9 ++
knowledge/api/backends.json | 54 ++++++-
knowledge/api/backends.md | 7 +-
knowledge/api/extension-spi.json | 14 +-
knowledge/api/extension-spi.md | 3 +-
.../api/SemanticExportLayoutContextTest.java | 143 ++++++++++++++++++
11 files changed, 414 insertions(+), 10 deletions(-)
create mode 100644 core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 625cd65f5..f30fafe98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,24 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A semantic export backend can ask for the compiled layout.** _(Experimental — see
+ [API stability](docs/api-stability.md).)_ A semantic backend walks the authored tree and
+ gets no geometry, which is right for most of them and wrong for the ones that need a
+ number the engine already worked out — a resolved column width, a settled page count.
+ `SemanticBackend.requiresResolvedLayout()` defaults to `false`; a backend that returns
+ `true` is handed the same session's layout in `SemanticExportContext.layoutGraph()`,
+ compiled from the graph passed to the same `export` call.
+
+ The default matters as much as the option. Compiling a layout measures text, and
+ measurement needs a font runtime that lives in a render module — so resolving one for
+ every semantic export would quietly make a render backend a hard requirement of exports
+ that do not render. A backend that does not ask causes no layout to be compiled, and the
+ published `SemanticExportContext` constructors keep working unchanged: the four-argument
+ one is written out by hand rather than left to the record, since adding the component
+ moved the canonical constructor to five arguments and callers compiled against the
+ published descriptor would otherwise stop linking. `requireLayoutGraph()` is there for a
+ backend that asked and wants the absence to say why rather than hand back `null`.
+
- **An inline SVG icon can state the text it stands for.** `SvgIcon.withText(String)` returns
a copy of the icon carrying that text, read back with `SvgIcon.text()`: what a reader that
copies, searches or extracts the page should find where the icon is drawn in a line of text,
diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
index a891377e3..a2bff430a 100644
--- a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
+++ b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
@@ -90,12 +90,19 @@ R render(FixedLayoutBackend backend, Path outputFile) throws Exception {
R export(SemanticBackend backend, Path outputFile) throws Exception {
context.ensureOpen();
Objects.requireNonNull(backend, "backend");
+ // Compiled only for a backend that asked. Compiling measures text, and measurement
+ // needs a font runtime the core does not ship, so resolving it unconditionally
+ // would make a render backend a hard requirement of every semantic export.
+ // The graph, the canvas and the layout all come off the same session state, so a
+ // backend given both is given a layout compiled from the graph beside it.
+ LayoutGraph resolvedLayout = backend.requiresResolvedLayout() ? context.layoutGraph() : null;
return backend.export(context.documentGraph(),
new SemanticExportContext(
context.canvas(),
context.customFontFamilies(),
outputFile,
- context.outputOptions()));
+ context.outputOptions(),
+ resolvedLayout));
}
byte[] toPdfBytes() throws Exception {
diff --git a/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java b/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java
index 5fdb7cbb9..503f72939 100644
--- a/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java
+++ b/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java
@@ -16,6 +16,30 @@ public interface SemanticBackend {
*/
String name();
+ /**
+ * Whether this backend needs the compiled layout alongside the semantic graph.
+ *
+ * A semantic backend walks the authored tree and needs no geometry, which is why
+ * the default is {@code false} and why the session does not compile a layout for one.
+ * That matters beyond the wasted work: compiling a layout measures text, and
+ * measurement needs a font runtime the core does not ship — so resolving it for every
+ * semantic export would make a render backend a hard requirement of exports that do
+ * not render.
+ *
+ * A backend that answers {@code true} is handed the same session's compiled layout
+ * in {@link SemanticExportContext#layoutGraph()}. It is for reading what the engine
+ * already worked out — a resolved width, a settled page count — not for placing
+ * content at coordinates; a backend that wants coordinates is a fixed-layout backend
+ * and should implement that contract instead.
+ *
+ * @return true to be given a resolved layout; false to be given the graph alone
+ * @since 2.5.0
+ */
+ @com.demcha.compose.document.api.Beta
+ default boolean requiresResolvedLayout() {
+ return false;
+ }
+
/**
* Exports the semantic document graph without running a fixed-layout renderer.
*
diff --git a/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticExportContext.java b/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticExportContext.java
index 516b30584..244d6bc1f 100644
--- a/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticExportContext.java
+++ b/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticExportContext.java
@@ -1,6 +1,7 @@
package com.demcha.compose.document.backend.semantic;
import com.demcha.compose.document.layout.LayoutCanvas;
+import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.output.DocumentOutputOptions;
import com.demcha.compose.font.FontFamilyDefinition;
@@ -17,12 +18,18 @@
* @param outputFile optional export output file
* @param outputOptions backend-neutral document output options (metadata,
* watermark, headers/footers, protection)
+ * @param layoutGraph the same document's compiled layout, or {@code null} — present only
+ * for a backend that asked for it through
+ * {@link SemanticBackend#requiresResolvedLayout()}, and always
+ * compiled from the graph handed to the same {@code export} call
+ * @since 2.5.0 carries {@code layoutGraph}
*/
public record SemanticExportContext(
LayoutCanvas canvas,
Collection customFontFamilies,
Path outputFile,
- DocumentOutputOptions outputOptions
+ DocumentOutputOptions outputOptions,
+ @com.demcha.compose.document.api.Beta LayoutGraph layoutGraph
) {
/**
* Normalizes the custom font collection into an immutable snapshot and
@@ -34,6 +41,25 @@ public record SemanticExportContext(
outputOptions = outputOptions == null ? DocumentOutputOptions.EMPTY : outputOptions;
}
+ /**
+ * Constructor without a resolved layout, for a backend that reads the graph alone.
+ *
+ * Written out rather than left to the record because adding the component moved
+ * the canonical constructor to five arguments, and this four-argument descriptor is
+ * published: callers compiled against it would stop linking.
+ *
+ * @param canvas physical page canvas for semantic export
+ * @param customFontFamilies document-local font families available to the backend
+ * @param outputFile optional export output file
+ * @param outputOptions backend-neutral document output options
+ */
+ public SemanticExportContext(LayoutCanvas canvas,
+ Collection customFontFamilies,
+ Path outputFile,
+ DocumentOutputOptions outputOptions) {
+ this(canvas, customFontFamilies, outputFile, outputOptions, null);
+ }
+
/**
* Backwards-compatible constructor without explicit output options.
*
@@ -44,7 +70,32 @@ public record SemanticExportContext(
public SemanticExportContext(LayoutCanvas canvas,
Collection customFontFamilies,
Path outputFile) {
- this(canvas, customFontFamilies, outputFile, DocumentOutputOptions.EMPTY);
+ this(canvas, customFontFamilies, outputFile, DocumentOutputOptions.EMPTY, null);
+ }
+
+ /**
+ * The compiled layout, for a backend that asked for one.
+ *
+ * @return the resolved layout
+ * @throws IllegalStateException if no layout was supplied — which means the backend
+ * did not ask for one, or the context was built by hand
+ * without it
+ * @since 2.5.0
+ */
+ @com.demcha.compose.document.api.Beta
+ public LayoutGraph requireLayoutGraph() {
+ if (layoutGraph == null) {
+ return throwMissingLayout();
+ }
+ return layoutGraph;
+ }
+
+ private static LayoutGraph throwMissingLayout() {
+ throw new IllegalStateException(
+ "No resolved layout in this export context. A backend is given one only "
+ + "when it returns true from SemanticBackend.requiresResolvedLayout(); a "
+ + "context built directly must be given the layout compiled from the same "
+ + "document graph.");
}
}
diff --git a/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java b/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java
new file mode 100644
index 000000000..ef55b1121
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java
@@ -0,0 +1,88 @@
+package com.demcha.compose.document.backend.semantic;
+
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.layout.LayoutCanvas;
+import com.demcha.compose.document.output.DocumentOutputOptions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The layout a semantic backend may now be given, and the shape of the context that
+ * carries it.
+ *
+ * These are the parts that need no session: that a backend which says nothing wants
+ * no layout, that the published constructors still work and leave the layout absent, and
+ * that asking for one that was never supplied says why rather than handing back
+ * {@code null}. Whether the session actually compiles one, and whether it compiles the
+ * layout of the graph beside it, is a question about a live document and is asked in the
+ * qa module, where a font runtime exists to measure with.
+ *
+ * @author Artem Demchyshyn
+ */
+class SemanticExportContextTest {
+
+ private static final LayoutCanvas CANVAS =
+ LayoutCanvas.from(595, 842, com.demcha.compose.engine.components.style.Margin.of(36));
+
+ @Test
+ void aBackendThatSaysNothingShouldNotBeGivenALayout() {
+ SemanticBackend quiet = new SemanticBackend<>() {
+ @Override
+ public String name() {
+ return "quiet";
+ }
+
+ @Override
+ public String export(DocumentGraph graph, SemanticExportContext context) {
+ return "ok";
+ }
+ };
+
+ // The default is what keeps a render backend from becoming a hard requirement of
+ // exports that do not render: compiling a layout measures text.
+ assertThat(quiet.requiresResolvedLayout()).isFalse();
+ }
+
+ @Test
+ void thePublishedFourArgumentConstructorShouldStillBuildAContext() {
+ SemanticExportContext context = new SemanticExportContext(
+ CANVAS, List.of(), null, DocumentOutputOptions.EMPTY);
+
+ assertThat(context.canvas()).isSameAs(CANVAS);
+ assertThat(context.layoutGraph())
+ .as("a caller that predates the layout component gets none")
+ .isNull();
+ }
+
+ @Test
+ void theThreeArgumentConstructorShouldStillDefaultTheOutputOptions() {
+ SemanticExportContext context = new SemanticExportContext(CANVAS, List.of(), null);
+
+ assertThat(context.outputOptions()).isSameAs(DocumentOutputOptions.EMPTY);
+ assertThat(context.layoutGraph()).isNull();
+ }
+
+ @Test
+ void askingForALayoutThatWasNeverSuppliedShouldSayWhy() {
+ SemanticExportContext context = new SemanticExportContext(
+ CANVAS, List.of(), null, DocumentOutputOptions.EMPTY);
+
+ assertThatThrownBy(context::requireLayoutGraph)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("requiresResolvedLayout");
+ }
+
+ @Test
+ void theCustomFontCollectionShouldBeCopiedOutOfTheCallersHands() {
+ List mutable = new java.util.ArrayList<>();
+ SemanticExportContext context = new SemanticExportContext(
+ CANVAS, mutable, null, DocumentOutputOptions.EMPTY, null);
+
+ assertThatThrownBy(() -> context.customFontFamilies().add(null))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+}
diff --git a/docs/api-stability.md b/docs/api-stability.md
index 2b36f1333..46a92b7a7 100644
--- a/docs/api-stability.md
+++ b/docs/api-stability.md
@@ -57,6 +57,15 @@ matrix.
> Geometry identity with the PDF backend is a design invariant and will not
> change; the API shape around it may still move in a minor release.
>
+> Three members of the otherwise-Stable **semantic export SPI** carry `@Beta`, new
+> in 2.5.0: `SemanticBackend.requiresResolvedLayout`, the `layoutGraph` component of
+> `SemanticExportContext`, and `SemanticExportContext.requireLayoutGraph`. They let a
+> semantic backend be handed the compiled layout beside the graph — geometry it reads
+> rather than places. The seam is marked Experimental because what a semantic backend
+> should be *given* is still settling: a later minor may narrow it to the measurements
+> a backend actually needs instead of the whole graph. Backends that do not ask are
+> unaffected, and the published `SemanticExportContext` constructors keep working.
+>
> Seven members of the otherwise-Stable **PDF backend** also carry `@Beta`. The
> package is not Experimental — these are:
> `PdfFixedLayoutBackend.renderSections` / `writeSections`, the low-level seam
diff --git a/knowledge/api/backends.json b/knowledge/api/backends.json
index 492e2dd98..103b89977 100644
--- a/knowledge/api/backends.json
+++ b/knowledge/api/backends.json
@@ -23,9 +23,9 @@
],
"counts": {
"types": 70,
- "methods": 376,
+ "methods": 379,
"constants": 18,
- "generated": 192
+ "generated": 193
},
"packages": [
{
@@ -5989,6 +5989,36 @@
{
"type": "DocumentOutputOptions",
"name": null
+ },
+ {
+ "type": "LayoutGraph",
+ "name": null
+ }
+ ]
+ },
+ {
+ "kind": "constructor",
+ "name": "SemanticExportContext",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": null,
+ "params": [
+ {
+ "type": "LayoutCanvas",
+ "name": "canvas"
+ },
+ {
+ "type": "Collection",
+ "name": "customFontFamilies"
+ },
+ {
+ "type": "Path",
+ "name": "outputFile"
+ },
+ {
+ "type": "DocumentOutputOptions",
+ "name": "outputOptions"
}
]
},
@@ -6014,6 +6044,16 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "requireLayoutGraph",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "LayoutGraph",
+ "params": [],
+ "stability": "beta"
+ },
{
"kind": "method",
"name": "canvas",
@@ -6049,6 +6089,16 @@
"typeParameters": null,
"returns": "DocumentOutputOptions",
"params": []
+ },
+ {
+ "kind": "method",
+ "name": "layoutGraph",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "LayoutGraph",
+ "params": [],
+ "stability": "beta"
}
]
},
diff --git a/knowledge/api/backends.md b/knowledge/api/backends.md
index 3c3488dbb..cea5aa307 100644
--- a/knowledge/api/backends.md
+++ b/knowledge/api/backends.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.1-SNAPSHOT
-Types: 70 · methods: 376 · constants: 18 · compiler-generated members: 192
+Types: 70 · methods: 379 · constants: 18 · compiler-generated members: 193
## com.demcha.compose.document.backend.fixed
@@ -538,12 +538,15 @@ Types: 70 · methods: 376 · constants: 18 · compiler-generated members: 192
## com.demcha.compose.document.backend.semantic
### SemanticExportContext (record)
-- `new SemanticExportContext(LayoutCanvas, Collection, Path, DocumentOutputOptions)`
+- `new SemanticExportContext(LayoutCanvas, Collection, Path, DocumentOutputOptions, LayoutGraph)`
+- `new SemanticExportContext(LayoutCanvas canvas, Collection customFontFamilies, Path outputFile, DocumentOutputOptions outputOptions)`
- `new SemanticExportContext(LayoutCanvas canvas, Collection customFontFamilies, Path outputFile)`
+- `LayoutGraph requireLayoutGraph() [beta]`
- `LayoutCanvas canvas()`
- `Collection customFontFamilies()`
- `Path outputFile()`
- `DocumentOutputOptions outputOptions()`
+- `LayoutGraph layoutGraph() [beta]`
### SemanticExportManifest (record)
- `new SemanticExportManifest(String, Path, int, List)`
diff --git a/knowledge/api/extension-spi.json b/knowledge/api/extension-spi.json
index d4f7a50d1..b8f73f68b 100644
--- a/knowledge/api/extension-spi.json
+++ b/knowledge/api/extension-spi.json
@@ -23,9 +23,9 @@
],
"counts": {
"types": 9,
- "methods": 26,
+ "methods": 27,
"constants": 0,
- "generated": 26
+ "generated": 27
},
"packages": [
{
@@ -366,6 +366,16 @@
"returns": "String",
"params": []
},
+ {
+ "kind": "method",
+ "name": "requiresResolvedLayout",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "boolean",
+ "params": [],
+ "stability": "beta"
+ },
{
"kind": "method",
"name": "export",
diff --git a/knowledge/api/extension-spi.md b/knowledge/api/extension-spi.md
index 314d03bea..7548c0a9d 100644
--- a/knowledge/api/extension-spi.md
+++ b/knowledge/api/extension-spi.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.1-SNAPSHOT
-Types: 9 · methods: 26 · constants: 0 · compiler-generated members: 26
+Types: 9 · methods: 27 · constants: 0 · compiler-generated members: 27
## com.demcha.compose.document.backend.fixed
@@ -70,6 +70,7 @@ Types: 9 · methods: 26 · constants: 0 · compiler-generated members: 26
### SemanticBackend (interface)
- `String name()`
+- `boolean requiresResolvedLayout() [beta]`
- `R export(DocumentGraph, SemanticExportContext)`
## com.demcha.compose.document.layout
diff --git a/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java b/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java
new file mode 100644
index 000000000..3a4c5e1b6
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java
@@ -0,0 +1,143 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.backend.semantic.SemanticBackend;
+import com.demcha.compose.document.backend.semantic.SemanticExportContext;
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A semantic backend that asks for the compiled layout is given one, and one that does
+ * not ask never causes it to be compiled.
+ *
+ * The second half is the part worth protecting. Compiling a layout measures text, and
+ * measurement needs a font runtime that lives in a render module — so if the session
+ * resolved a layout for every semantic export, adding a DOCX dependency alone would stop
+ * being enough to export DOCX. The counter is a backend that records whether it was
+ * handed one.
+ *
+ * @author Artem Demchyshyn
+ */
+class SemanticExportLayoutContextTest {
+
+ /** Records what the session handed it, and optionally asks for a layout. */
+ private static final class RecordingBackend implements SemanticBackend {
+
+ private final boolean wantsLayout;
+ private final List layouts = new ArrayList<>();
+ private final List graphs = new ArrayList<>();
+ private final AtomicInteger calls = new AtomicInteger();
+
+ RecordingBackend(boolean wantsLayout) {
+ this.wantsLayout = wantsLayout;
+ }
+
+ @Override
+ public String name() {
+ return wantsLayout ? "layout-aware" : "graph-only";
+ }
+
+ @Override
+ public boolean requiresResolvedLayout() {
+ return wantsLayout;
+ }
+
+ @Override
+ public String export(DocumentGraph graph, SemanticExportContext context) {
+ calls.incrementAndGet();
+ graphs.add(graph);
+ layouts.add(context.layoutGraph());
+ return name();
+ }
+ }
+
+ @Test
+ void aBackendThatAsksShouldBeGivenTheCompiledLayout() throws Exception {
+ RecordingBackend backend = new RecordingBackend(true);
+
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text("One line.")));
+ assertThat(session.export(backend)).isEqualTo("layout-aware");
+ }
+
+ assertThat(backend.layouts).hasSize(1);
+ assertThat(backend.layouts.get(0)).isNotNull();
+ assertThat(backend.layouts.get(0).totalPages()).isPositive();
+ }
+
+ @Test
+ void aBackendThatDoesNotAskShouldBeGivenNoLayout() throws Exception {
+ RecordingBackend backend = new RecordingBackend(false);
+
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text("One line.")));
+ assertThat(session.export(backend)).isEqualTo("graph-only");
+ }
+
+ assertThat(backend.layouts).containsExactly((LayoutGraph) null);
+ }
+
+ @Test
+ void theLayoutShouldBeCompiledFromTheGraphHandedToTheSameCall() throws Exception {
+ RecordingBackend backend = new RecordingBackend(true);
+
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text("One line.")));
+ session.export(backend);
+
+ // Edit, then export again. A layout cached from before the edit would report
+ // the old document, which is the failure a backend reading resolved widths
+ // could not detect for itself.
+ session.pageFlow(page -> {
+ for (int i = 0; i < 80; i++) {
+ page.addParagraph(p -> p.text(
+ "A further paragraph, added so the document outgrows one page."));
+ }
+ });
+ session.export(backend);
+ }
+
+ assertThat(backend.calls).hasValue(2);
+ assertThat(backend.layouts.get(1).totalPages())
+ .as("the second export sees the document as it is now")
+ .isGreaterThan(backend.layouts.get(0).totalPages());
+ assertThat(backend.graphs.get(1).roots().size())
+ .isGreaterThan(backend.graphs.get(0).roots().size());
+ }
+
+ @Test
+ void theLayoutShouldDescribeTheSameCanvasTheContextCarries() throws Exception {
+ RecordingBackend backend = new RecordingBackend(true);
+
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(420, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text("One line.")));
+ session.export(backend);
+ }
+
+ LayoutGraph layout = backend.layouts.get(0);
+ assertThat(layout.canvas().width()).isEqualTo(420);
+ assertThat(layout.canvas().innerWidth())
+ .as("the page geometry the export was configured with, not a default")
+ .isEqualTo(420 - 40);
+ }
+}
From 50af4e8e7293680df06f742fbf64d3a5d6be088f Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 21 Sep 2026 15:51:17 +0100
Subject: [PATCH 2/2] fix(api): correct what the layout opt-in saves, and test
that it saves it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three defects in what 85a5ab22 shipped: a justification that is not true, the
feature's central behaviour untested, and two normalisations nothing exercised.
The Javadoc, the facade comment and the CHANGELOG all said that resolving a
layout for every semantic export would make a render backend a hard requirement
of exports that do not render. It would not, because it already is one.
DocumentSession's constructor calls refreshMeasurementServices(), which resolves
a FontMetricsProvider through BackendProviders, and the only registration in the
repository ships in graph-compose-render-pdf — so a session cannot be created at
all without that artifact, before a node is added and whatever a backend later
asks for. The saving is real but it is measurement and pagination over the whole
document, not the dependency. All three now say that, and say explicitly what is
not saved, so the next reader does not reinstate the wrong reason.
The flag's whole purpose had no test. The qa suite asserted the value handed to
the backend, never that no layout was compiled — so hoisting the call out of the
ternary (var g = context.layoutGraph(); ... ? g : null) restores the exact cost
the flag exists to avoid with every assertion still green.
SemanticExportLayoutResolutionTest implements DocumentRenderingFacade.Context
and counts how often the layout is asked for; the counter was verified by making
that edit and watching the test go red on "but was: 1" before reverting it.
Two more revert-proof gaps: requireLayoutGraph() was only ever driven down its
throwing path, so gutting it to return null broke nothing, and the compact
constructor's null-to-EMPTY normalisation of outputOptions was never exercised —
the three-argument constructor passes EMPTY itself, so a caller writing null
explicitly would have handed a backend outputOptions() == null and failed inside
the backend. Both are covered now.
Also records two consequences the first commit did not name. Source
compatibility is not preserved in two narrow ways: a record pattern
destructuring the four-component form no longer compiles, and
getRecordComponents().length is 5 rather than 4 — binary linkage is intact and
japicmp-verified, source recompilation of those two forms is not. And the
record's equals, hashCode and toString now traverse the layout, so on a long
document they walk every placed node and fragment; nothing in the repository
hashes a context, but a backend logging one would notice.
Verification: ./mvnw -B -ntp clean verify -pl
:graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa
-am, BUILD SUCCESS across all eight modules. Thirteen tests now cover this seam,
nine in core and four in qa.
---
CHANGELOG.md | 30 ++--
.../document/api/DocumentRenderingFacade.java | 7 +-
.../backend/semantic/SemanticBackend.java | 12 +-
.../SemanticExportLayoutResolutionTest.java | 142 ++++++++++++++++++
.../semantic/SemanticExportContextTest.java | 23 +++
.../api/SemanticExportLayoutContextTest.java | 19 +++
6 files changed, 217 insertions(+), 16 deletions(-)
create mode 100644 core/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutResolutionTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f30fafe98..861d3eceb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,15 +15,27 @@ follow semantic versioning; release dates are ISO 8601.
`true` is handed the same session's layout in `SemanticExportContext.layoutGraph()`,
compiled from the graph passed to the same `export` call.
- The default matters as much as the option. Compiling a layout measures text, and
- measurement needs a font runtime that lives in a render module — so resolving one for
- every semantic export would quietly make a render backend a hard requirement of exports
- that do not render. A backend that does not ask causes no layout to be compiled, and the
- published `SemanticExportContext` constructors keep working unchanged: the four-argument
- one is written out by hand rather than left to the record, since adding the component
- moved the canonical constructor to five arguments and callers compiled against the
- published descriptor would otherwise stop linking. `requireLayoutGraph()` is there for a
- backend that asked and wants the absence to say why rather than hand back `null`.
+ The default matters as much as the option: compiling a layout runs measurement and
+ pagination over the whole document, work that grows with the document and that an export
+ ignoring geometry has no use for. A backend that does not ask causes none of it. It does
+ not save the render-module dependency — `DocumentSession` resolves a `FontMetricsProvider`
+ in its constructor and only `graph-compose-render-pdf` registers one, so a session cannot
+ be created without it whatever a backend later asks for.
+
+ Binary compatibility is preserved: the four-argument `SemanticExportContext` constructor
+ is written out by hand rather than left to the record, since adding the component moved
+ the canonical constructor to five arguments and callers compiled against the published
+ descriptor would otherwise stop linking. **Source compatibility is not**, in two narrow
+ ways — a record pattern that destructures the four-component form
+ (`case SemanticExportContext(var canvas, var fonts, var out, var opts)`) no longer
+ compiles, and `getRecordComponents().length` is 5 rather than 4. `requireLayoutGraph()`
+ is there for a backend that asked and wants the absence to say why rather than hand back
+ `null`.
+
+ One consequence worth knowing before putting a context in a collection or a log line:
+ the record's `equals`, `hashCode` and `toString` now traverse the layout, so on a long
+ document they walk every placed node and fragment. The four original components are
+ unchanged.
- **An inline SVG icon can state the text it stands for.** `SvgIcon.withText(String)` returns
a copy of the icon carrying that text, read back with `SvgIcon.text()`: what a reader that
diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
index a2bff430a..32d500b0f 100644
--- a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
+++ b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
@@ -90,9 +90,10 @@ R render(FixedLayoutBackend backend, Path outputFile) throws Exception {
R export(SemanticBackend backend, Path outputFile) throws Exception {
context.ensureOpen();
Objects.requireNonNull(backend, "backend");
- // Compiled only for a backend that asked. Compiling measures text, and measurement
- // needs a font runtime the core does not ship, so resolving it unconditionally
- // would make a render backend a hard requirement of every semantic export.
+ // Compiled only for a backend that asked: compiling runs measurement and
+ // pagination over the whole document, which an export that ignores geometry has
+ // no use for. It does not save the render-module dependency — the session already
+ // needed a FontMetricsProvider to be constructed at all.
// The graph, the canvas and the layout all come off the same session state, so a
// backend given both is given a layout compiled from the graph beside it.
LayoutGraph resolvedLayout = backend.requiresResolvedLayout() ? context.layoutGraph() : null;
diff --git a/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java b/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java
index 503f72939..0d36823b8 100644
--- a/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java
+++ b/core/src/main/java/com/demcha/compose/document/backend/semantic/SemanticBackend.java
@@ -21,10 +21,14 @@ public interface SemanticBackend {
*
* A semantic backend walks the authored tree and needs no geometry, which is why
* the default is {@code false} and why the session does not compile a layout for one.
- * That matters beyond the wasted work: compiling a layout measures text, and
- * measurement needs a font runtime the core does not ship — so resolving it for every
- * semantic export would make a render backend a hard requirement of exports that do
- * not render.
+ * Compiling one runs measurement and pagination over the whole document — work that
+ * grows with the document and that an export ignoring geometry has no use for.
+ *
+ * It does not save the render-module dependency, and this flag should not
+ * be described as if it did: {@code DocumentSession} resolves a
+ * {@code FontMetricsProvider} in its constructor, only
+ * {@code graph-compose-render-pdf} registers one, and a session cannot be created
+ * without it whatever a backend later asks for.
*
* A backend that answers {@code true} is handed the same session's compiled layout
* in {@link SemanticExportContext#layoutGraph()}. It is for reading what the engine
diff --git a/core/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutResolutionTest.java b/core/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutResolutionTest.java
new file mode 100644
index 000000000..1bddb3831
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutResolutionTest.java
@@ -0,0 +1,142 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.document.backend.fixed.FixedLayoutRenderer;
+import com.demcha.compose.document.backend.semantic.SemanticBackend;
+import com.demcha.compose.document.backend.semantic.SemanticExportContext;
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.layout.LayoutCanvas;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.output.DocumentOutputOptions;
+import com.demcha.compose.engine.components.style.Margin;
+import com.demcha.compose.font.FontFamilyDefinition;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The facade asks the session for a layout only when the backend asked for one.
+ *
+ *
Whether the backend is handed a layout is a different question, and the
+ * qa suite answers it against a live session. This one answers the question that
+ * matters for what the feature costs: was the layout compiled at all. The two
+ * come apart — hoisting the call out of the ternary
+ * ({@code var g = context.layoutGraph(); ... ? g : null}) hands a non-asking backend
+ * the same {@code null} while compiling the layout anyway, which is exactly the cost the
+ * flag exists to avoid, and every assertion about the handed-over value stays green.
+ * So the session stands in as a counter here, and the assertion is on the count.
+ *
+ * @author Artem Demchyshyn
+ */
+class SemanticExportLayoutResolutionTest {
+
+ @Test
+ void aBackendThatDoesNotAskShouldNotCauseALayoutToBeCompiled() throws Exception {
+ CountingContext context = new CountingContext();
+
+ new DocumentRenderingFacade(context).export(new ProbeBackend(false), null);
+
+ assertThat(context.layoutRequests)
+ .as("the session was never asked to compile a layout")
+ .isZero();
+ }
+
+ @Test
+ void aBackendThatAsksShouldCauseExactlyOneCompilation() throws Exception {
+ CountingContext context = new CountingContext();
+
+ new DocumentRenderingFacade(context).export(new ProbeBackend(true), null);
+
+ assertThat(context.layoutRequests)
+ .as("asked for once, not once per read")
+ .isEqualTo(1);
+ }
+
+ /** Answers whatever it is asked, and counts how often the layout is wanted. */
+ private static final class CountingContext implements DocumentRenderingFacade.Context {
+
+ private final LayoutCanvas canvas = LayoutCanvas.from(595, 842, Margin.of(36));
+ private final DocumentGraph graph = new DocumentGraph(List.of());
+ private int layoutRequests;
+
+ @Override
+ public void ensureOpen() {
+ }
+
+ @Override
+ public void ensureRenderable() {
+ }
+
+ @Override
+ public String sessionId() {
+ return "counting";
+ }
+
+ @Override
+ public long revision() {
+ return 1;
+ }
+
+ @Override
+ public int rootCount() {
+ return 0;
+ }
+
+ @Override
+ public LayoutCanvas canvas() {
+ return canvas;
+ }
+
+ @Override
+ public List customFontFamilies() {
+ return List.of();
+ }
+
+ @Override
+ public LayoutGraph layoutGraph() {
+ layoutRequests++;
+ return new LayoutGraph(canvas, 1, List.of(), List.of());
+ }
+
+ @Override
+ public DocumentGraph documentGraph() {
+ return graph;
+ }
+
+ @Override
+ public DocumentOutputOptions outputOptions() {
+ return DocumentOutputOptions.EMPTY;
+ }
+
+ @Override
+ public FixedLayoutRenderer convenienceBackend(String format) {
+ throw new UnsupportedOperationException("no fixed-layout render in this test");
+ }
+ }
+
+ /** Says whether it wants a layout and records what it was given. */
+ private static final class ProbeBackend implements SemanticBackend {
+
+ private final boolean wantsLayout;
+
+ ProbeBackend(boolean wantsLayout) {
+ this.wantsLayout = wantsLayout;
+ }
+
+ @Override
+ public String name() {
+ return "probe";
+ }
+
+ @Override
+ public boolean requiresResolvedLayout() {
+ return wantsLayout;
+ }
+
+ @Override
+ public LayoutGraph export(DocumentGraph graph, SemanticExportContext context) {
+ return context.layoutGraph();
+ }
+ }
+}
diff --git a/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java b/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java
index ef55b1121..3f5e4acb6 100644
--- a/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java
+++ b/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java
@@ -2,6 +2,7 @@
import com.demcha.compose.document.layout.DocumentGraph;
import com.demcha.compose.document.layout.LayoutCanvas;
+import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.output.DocumentOutputOptions;
import org.junit.jupiter.api.Test;
@@ -76,6 +77,28 @@ void askingForALayoutThatWasNeverSuppliedShouldSayWhy() {
.hasMessageContaining("requiresResolvedLayout");
}
+ @Test
+ void askingForALayoutThatWasSuppliedShouldHandItBack() {
+ // The throwing path was covered and the returning one was not, so the method
+ // could have been gutted to `return null` with nothing going red.
+ LayoutGraph layout = new LayoutGraph(CANVAS, 1, List.of(), List.of());
+ SemanticExportContext context = new SemanticExportContext(
+ CANVAS, List.of(), null, DocumentOutputOptions.EMPTY, layout);
+
+ assertThat(context.requireLayoutGraph()).isSameAs(layout);
+ }
+
+ @Test
+ void nullOutputOptionsShouldStillBecomeTheEmptyBundle() {
+ // The three-argument constructor passes EMPTY itself, so this normalisation was
+ // never exercised: a caller writing null explicitly would have handed a backend
+ // outputOptions() == null and it would have failed inside the backend.
+ SemanticExportContext context = new SemanticExportContext(
+ CANVAS, List.of(), null, null, null);
+
+ assertThat(context.outputOptions()).isSameAs(DocumentOutputOptions.EMPTY);
+ }
+
@Test
void theCustomFontCollectionShouldBeCopiedOutOfTheCallersHands() {
List mutable = new java.util.ArrayList<>();
diff --git a/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java b/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java
index 3a4c5e1b6..7e5baf1c8 100644
--- a/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java
@@ -122,6 +122,25 @@ void theLayoutShouldBeCompiledFromTheGraphHandedToTheSameCall() throws Exception
.isGreaterThan(backend.graphs.get(0).roots().size());
}
+ @Test
+ void anEmptyDocumentShouldStillExportForABackendThatAsksForALayout() throws Exception {
+ // A semantic export of an empty document is legitimate and always worked — the
+ // path never touched layout. Asking for one sends it somewhere it has never been:
+ // the render methods refuse an empty document outright, and a layout-aware
+ // semantic export must not inherit that refusal, nor fail compiling nothing.
+ RecordingBackend backend = new RecordingBackend(true);
+
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ assertThat(session.export(backend)).isEqualTo("layout-aware");
+ }
+
+ assertThat(backend.layouts.get(0)).isNotNull();
+ assertThat(backend.layouts.get(0).fragments()).isEmpty();
+ }
+
@Test
void theLayoutShouldDescribeTheSameCanvasTheContextCarries() throws Exception {
RecordingBackend backend = new RecordingBackend(true);