diff --git a/CHANGELOG.md b/CHANGELOG.md index 376ef4d3..0d7ccc52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,22 @@ follow semantic versioning; release dates are ISO 8601. ### Documentation +- **The last three documents that put `MissingBackendException` at the render call.** + An earlier pass moved the module READMEs and the exception's own Javadoc onto + `create()` and stopped at the repository root, so the troubleshooting entry, the 2.0 + migration guide and ADR 0016 kept telling a reader to look at `buildPdf()` — and the + migration guide links straight into the troubleshooting entry, so the two reinforced + each other. All three now name the call that actually fails, and the troubleshooting + entry adds the one case that really does surface at the output call: `buildPptx()` + when the PPTX backend is missing but the PDF one is not. The contract test carried + the same confusion: it wrapped `create()`, `pageFlow(...)` and `toPdfBytes()` in one + assertion, so only the first line ever ran while its name promised the third. It is + split, and a second case pins the other side of the boundary — configuring a document + needs no backend, opening the session does. The output-call half is covered where it + is actually reachable: a test in `render-pdf`, whose classpath has one backend and not + the other, calls `buildPptx(...)` through the public API rather than the resolver + underneath it. The old heading keeps working as an anchor, so links already published + against it still land on the entry. - **The examples stop describing the releases they were written for.** Eight committed previews read as documents about 1.x: three framed a current feature as "v1.6 Phase A/B/C" — a plan for a release that shipped — one told the reader to tag v1.9.0 to diff --git a/core/src/test/java/com/demcha/compose/document/api/MissingBackendContractTest.java b/core/src/test/java/com/demcha/compose/document/api/MissingBackendContractTest.java index 903d0b23..2f3be980 100644 --- a/core/src/test/java/com/demcha/compose/document/api/MissingBackendContractTest.java +++ b/core/src/test/java/com/demcha/compose/document/api/MissingBackendContractTest.java @@ -5,13 +5,22 @@ import com.demcha.compose.document.exceptions.MissingBackendException; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * The lean {@code graph-compose-core} carries no render backend. Any operation that - * needs one — measuring text to lay out, rendering, or rasterizing a document — must - * fail with a {@link MissingBackendException} that names the artifact to add - * ({@code graph-compose-render-pdf}). This is the defining contract of the lean core. + * The lean {@code graph-compose-core} carries no render backend, and says so at the + * first operation that needs one with a {@link MissingBackendException} naming the + * artifact to add ({@code graph-compose-render-pdf}). This is the defining contract + * of the lean core. + * + *
That first operation is {@code create()}, not a render call: opening a session + * resolves the font-metrics provider, because layout measures text before anything is + * drawn. The render-time site exists too, but it is unreachable from here — no session + * opens on this classpath — so the format-specific cases below assert on the resolver + * directly. The public path to it, {@code buildPptx()} without {@code render-pptx}, + * needs a classpath where one backend is present and another is not, and is covered by + * {@code MissingPptxBackendContractTest} in {@code render-pdf}.
* *The test lives in core's own backend-free test scope on purpose: a * backend on the classpath (as in the qa module) would resolve the provider and hide @@ -19,20 +28,36 @@ */ class MissingBackendContractTest { + /** + * The throw is at {@code create()}, not at the render call. + * + *
This used to wrap {@code create()}, {@code pageFlow(...)} and + * {@code toPdfBytes()} in one assertion. It passed, but only the first line ever + * ran — and its name said "rendering", which is where three published documents + * then placed the failure. Asserting on {@code create()} alone is what pins the + * documented contract: opening a session resolves the font-metrics provider, + * because layout measures text before anything is drawn.
+ */ @Test - void renderingWithoutABackendThrowsMissingBackendExceptionNamingRenderPdf() { - assertThatThrownBy(() -> { - try (DocumentSession session = GraphCompose.document() - .pageSize(200, 200) - .create()) { - session.pageFlow(page -> page.module("m", module -> module.paragraph("hi"))); - session.toPdfBytes(); - } - }) + void openingASessionWithoutABackendThrowsNamingRenderPdf() { + assertThatThrownBy(() -> GraphCompose.document().pageSize(200, 200).create()) .isInstanceOf(MissingBackendException.class) .hasMessageContaining("graph-compose-render-pdf"); } + /** + * The other side of the same boundary: configuring a document is backend-free, so + * a builder that resolved the provider eagerly — or one that deferred it past + * {@code create()} to the render call — would fail here. + */ + @Test + void configuringADocumentDoesNotNeedABackendUntilTheSessionOpens() { + GraphCompose.DocumentBuilder builder = GraphCompose.document().pageSize(200, 200); + + assertThat(builder).isNotNull(); + assertThatThrownBy(builder::create).isInstanceOf(MissingBackendException.class); + } + @Test void missingKnownFormatNamesTheArtifactToAdd() { assertThatThrownBy(() -> BackendProviders.fixedLayout("pptx")) diff --git a/docs/README.md b/docs/README.md index 74f6930d..7e952f5a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -48,7 +48,7 @@ back here. - **[architecture/lifecycle.md](architecture/lifecycle.md)** — the document lifecycle from `GraphCompose.document(...)` through `buildPdf()`. - **[architecture/pagination-ordering.md](architecture/pagination-ordering.md)** — how nodes are paginated and ordered. - **[architecture/package-map.md](architecture/package-map.md)** — what's in which package. -- **[architecture/canonical-legacy-parity.md](architecture/canonical-legacy-parity.md)** — canonical (v1.5+) vs legacy compatibility matrix. +- **[architecture/canonical-legacy-parity.md](architecture/canonical-legacy-parity.md)** — per-feature authoring coverage of the canonical API, refreshed for the 2.1 line. The recipes, the capabilities catalogue and the troubleshooting guide all link into it. ### Operations - **[operations/production-rendering.md](operations/production-rendering.md)** — server-side rendering, streaming, thread safety. diff --git a/docs/adr/0016-multi-module-packaging.md b/docs/adr/0016-multi-module-packaging.md index c49408bf..7936e2fb 100644 --- a/docs/adr/0016-multi-module-packaging.md +++ b/docs/adr/0016-multi-module-packaging.md @@ -39,8 +39,8 @@ render backends discovered at runtime through a `ServiceLoader` SPI. the canonical DSL / nodes / style / layout, chart / svg / markdown / barcode, and the `FixedLayoutBackendProvider` / `FontMetricsProvider` SPI seams. Depends only on `slf4j-api` + `flexmark`. Rendering nothing until a backend is on the classpath — - it throws `MissingBackendException` (naming the artifact to add) if asked to build a - PDF without one. + `create()` throws `MissingBackendException` (naming the artifact to add) without one, + since opening a session resolves the font-metrics provider that ships with the backend. - **`graph-compose-render-pdf`** — the entire PDFBox backend (`document.backend.fixed.pdf.**` and the `engine.render.pdf.**` tree), PDFBox, and zxing. Registers the PDF `FixedLayoutBackendProvider` / `FontMetricsProvider` via `META-INF/services`. diff --git a/docs/migration/v2.0.0-modules.md b/docs/migration/v2.0.0-modules.md index 36a8e7e2..ce5d898a 100644 --- a/docs/migration/v2.0.0-modules.md +++ b/docs/migration/v2.0.0-modules.md @@ -70,8 +70,9 @@ source break in the split — PDF compatibility was kept as the priority. ## `MissingBackendException` — the lean-core signal `graph-compose-core` on its own renders nothing until a render backend is on the classpath. -Asking it to build a PDF throws `MissingBackendException`, whose message names the artifact -to add: +**Opening a session** — `create()` — throws `MissingBackendException`, because measuring text +is the first thing layout needs and the metrics provider ships with the backend. You do not +get as far as asking for a PDF. The message names the artifact to add: ``` No fixed-layout render backend on the classpath: add the @@ -82,7 +83,7 @@ implementation) to render, rasterize, or measure a document. Depend on `graph-compose` (or `graph-compose-bundle`) instead of `graph-compose-core` and the PDF backend is already present. See the -[troubleshooting entry](../troubleshooting.md#missingbackendexception-when-rendering). +[troubleshooting entry](../troubleshooting.md#missingbackendexception-when-opening-a-session). ## Removed deprecated APIs diff --git a/docs/recipes/extending.md b/docs/recipes/extending.md index 4ac1ecc7..3b0fea8d 100644 --- a/docs/recipes/extending.md +++ b/docs/recipes/extending.md @@ -8,9 +8,10 @@ which uses the v1.5 `ShapeContainerNode` work as a worked example. | You want to... | Touch | Read | | --- | --- | --- | | Add a new semantic node | `DocumentNode` record + `NodeDefinition` + render handler | [Extension guide § 1](../contributing/extension-guide.md#1-add-a-semantic-node) | -| Add a fluent setter | One `*Builder` only | [Extension guide § 2](../contributing/extension-guide.md#2-add-a-fluent-setter-to-a-builder) | -| Add a render backend | Implement `FixedLayoutBackend` or `SemanticBackend` | [Extension guide § 3](../contributing/extension-guide.md#3-add-a-render-backend) | -| Pin layout in a snapshot test | Use `LayoutSnapshotAssertions.assertMatches` | [Extension guide § 4](../contributing/extension-guide.md#4-validate-a-custom-nodes-layout-via-snapshots) | +| Add a fluent setter | One `*Builder` only | [Extension guide § 2](../contributing/extension-guide.md#2-add-a-fluent-setter-to-an-existing-builder) | +| Add a render handler for an existing backend | One `*FragmentRenderHandler` | [Extension guide § 3](../contributing/extension-guide.md#3-add-a-render-handler-for-an-existing-backend) | +| Add a render backend | Implement `FixedLayoutBackend` or `SemanticBackend` | [Extension guide § 4](../contributing/extension-guide.md#4-add-a-new-backend) | +| Pin layout in a snapshot test | Use `LayoutSnapshotAssertions.assertMatches` | [Extension guide § 5](../contributing/extension-guide.md#5-layout-snapshot-tests-for-your-own-nodes) | ## 1. Add a semantic node — five-step skeleton diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a6a23534..07234aa7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -37,13 +37,22 @@ fall back to inline content with a one-time capability warning. Use DOCX only for paragraph / list / table / image / section content. Per-feature mapping: [canonical ↔ legacy parity matrix](architecture/canonical-legacy-parity.md). -## `MissingBackendException` when rendering + -**Cause.** You depend on `graph-compose-core` (the lean 2.0 engine) but no render +## `MissingBackendException` when opening a session + +**Cause.** You depend on `graph-compose-core` (the lean engine artifact) but no render backend is on the classpath. The core carries the `DocumentSession` authoring API and a -`ServiceLoader` seam, but the actual renderer ships separately — so `document.buildPdf()` -/ `toPdfBytes()` / `toImages()` throws `MissingBackendException` until a backend is -discoverable. +`ServiceLoader` seam; the renderer ships separately. + +The throw comes **earlier than the name suggests**: `create()` resolves the font-metrics +provider immediately, because laying text out needs measurement before anything is drawn. +So **opening the session** fails — you never reach `buildPdf()` / `toPdfBytes()` / +`toImages()`, and the stack trace points at your `create()` call, not at a render call. + +The one case that does surface at the output call is asking for a format whose backend is +missing while another is present: `buildPptx()` without `graph-compose-render-pptx` on a +classpath that has the PDF backend. The message names that artifact instead. **Fix.** Add the PDF backend, or depend on `graph-compose` (which already bundles it): diff --git a/render-pdf/src/test/java/com/demcha/compose/document/api/MissingPptxBackendContractTest.java b/render-pdf/src/test/java/com/demcha/compose/document/api/MissingPptxBackendContractTest.java new file mode 100644 index 00000000..586f7133 --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/api/MissingPptxBackendContractTest.java @@ -0,0 +1,49 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.exceptions.MissingBackendException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The other half of the {@code MissingBackendException} contract: the one case that + * really does surface at the output call. + * + *On a lean core the session never opens — {@code create()} resolves the + * font-metrics provider first — so no test on a backend-free classpath can reach a + * render call. This module is where the case becomes reachable: the PDF backend is + * present, so measurement resolves and the session opens, while {@code render-pptx} + * is absent, so asking for a deck fails at {@code buildPptx(...)} and names the + * artifact to add.
+ * + *The engine-side test asserts on {@code BackendProviders.fixedLayout("pptx")} + * directly. That pins the resolver, not the path a caller takes to it: a convenience + * method that stopped routing through the resolver, or started resolving eagerly, + * would leave that test green. This one goes through the public API.
+ * + *Note the ordering the assertion depends on: a document with no roots fails + * {@code ensureRenderable()} with an {@code IllegalStateException} before + * any backend is looked up, so the page content below is not decoration — without it + * this test would pass for the wrong reason.
+ */ +class MissingPptxBackendContractTest { + + @Test + void buildingADeckWithoutThePptxBackendThrowsNamingRenderPptx(@TempDir Path directory) throws Exception { + Path deck = directory.resolve("deck.pptx"); + + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 200) + .create()) { + session.pageFlow(page -> page.module("m", module -> module.paragraph("hi"))); + + assertThatThrownBy(() -> session.buildPptx(deck)) + .isInstanceOf(MissingBackendException.class) + .hasMessageContaining("graph-compose-render-pptx"); + } + } +}