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);