diff --git a/CHANGELOG.md b/CHANGELOG.md index 625cd65f5..861d3eceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ 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 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 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..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,12 +90,20 @@ 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 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; 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..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 @@ -16,6 +16,34 @@ 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. + * 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 + * 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/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 new file mode 100644 index 000000000..3f5e4acb6 --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/backend/semantic/SemanticExportContextTest.java @@ -0,0 +1,111 @@ +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.layout.LayoutGraph; +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 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<>(); + 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..7e5baf1c8 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/SemanticExportLayoutContextTest.java @@ -0,0 +1,162 @@ +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 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); + + 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); + } +}