From 0e9fe937e59fb6256af23cd815a3ff6e28f165eb Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 3 Aug 2026 14:16:44 +0100 Subject: [PATCH] docs(errors): MissingBackendException is documented where it fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier pass moved the module READMEs, the root README and the exception's own Javadoc onto create(), and stopped at the repository root. Three documents under docs/ kept telling a reader to look at buildPdf(): the troubleshooting entry, the 2.0 migration guide and ADR 0016 — and the migration guide links straight into the troubleshooting entry, so the two reinforced each other. On a lean core the session never opens: create() resolves the font-metrics provider, because layout measures text before anything is drawn, and the stack trace points at the caller's create(). All three now name that call. The troubleshooting entry adds the one case that really does surface at the output call — buildPptx() when the PPTX backend is missing and the PDF one is not — and its heading no longer says "when rendering"; the single inbound anchor moves with it. MissingBackendContractTest carried the same confusion: one assertion wrapped create(), pageFlow(...) and toPdfBytes(), so only the first line ever ran while the test 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. Also: the docs index described canonical-legacy-parity.md as a v1.5-era legacy matrix, which the document itself stopped being; and three of the four cross-references in the extension recipe pointed at extension-guide headings that had been renamed or renumbered, landing the reader at the top of the page. ./mvnw -B -ntp clean verify — BUILD SUCCESS, 692 tests in the closing module. MissingBackendContractTest 3 -> 4. --- CHANGELOG.md | 16 ++++++ .../api/MissingBackendContractTest.java | 51 ++++++++++++++----- docs/README.md | 2 +- docs/adr/0016-multi-module-packaging.md | 4 +- docs/migration/v2.0.0-modules.md | 7 +-- docs/recipes/extending.md | 7 +-- docs/troubleshooting.md | 19 +++++-- .../api/MissingPptxBackendContractTest.java | 49 ++++++++++++++++++ 8 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 render-pdf/src/test/java/com/demcha/compose/document/api/MissingPptxBackendContractTest.java 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"); + } + } +}