Skip to content

feat(api): let a semantic export backend ask for the compiled layout - #702

Merged
DemchaAV merged 2 commits into
2.5-devfrom
feature/semantic-export-layout-context
Sep 22, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
feature/semantic-export-layout-context

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

A semantic export 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. 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.

The concrete case waiting on this: DocxSemanticBackend writes no table width at all — no tblW, no gridCol — so Word shrinks every table to its content while the reference PDF spans the text width. The column split it needs is a resolved width, not something a semantic backend can compute.

What changed

  • SemanticBackend.requiresResolvedLayout(), default false. A backend that returns true is handed the same session's compiled layout in SemanticExportContext.layoutGraph(). It is for reading geometry the engine settled, not for placing content at coordinates — a backend that wants coordinates is a fixed-layout backend and should implement that contract instead.

  • DocumentRenderingFacade.export resolves a layout only for a backend that asked. 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 the Javadoc says so explicitly rather than leaving the next reader to assume otherwise: DocumentSession resolves a FontMetricsProvider in its constructor, only graph-compose-render-pdf registers one, and a session cannot be created without it whatever a backend later asks for. The first draft of this claimed the dependency as the saving; it is wrong, and the wrong version is the kind that gets restored by someone tidying up.

  • Consistency is structural, not conventional. 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.

  • SemanticExportContext gains the component as a record. That moves the canonical constructor to five arguments, so the published four-argument one is written out by hand and callers compiled against it keep linking; its Javadoc says why it exists, so it does not get deleted as redundant. requireLayoutGraph() is for a backend that asked and would rather the absence explain 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 needs rather than the whole graph. Experimental is what keeps that possible without a major.

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 -amBUILD SUCCESS across all eight modules.

13 tests cover the seam. Nine in graph-compose-core, for what needs no session: the false default, both published constructors, the explained absence and the returned layout, the null-to-EMPTY normalisation, the copied font collection — and SemanticExportLayoutResolutionTest, which implements DocumentRenderingFacade.Context and counts how often a layout is asked for. That counter exists because asserting the value handed to the backend does not prove the work was skipped: hoisting the call out of the ternary hands a non-asking backend the same null while compiling anyway. The counter was verified by making exactly that edit and watching it fail on but was: 1.

Four in graph-compose-qa, for 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 by page count, and describes the canvas the export was configured with. Plus an empty document, which the render methods refuse outright and a layout-asking semantic export must not inherit that refusal from.

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; check-stability-doc.mjs verifies the new @Beta members are named.

Notes

Source compatibility is not fully preserved, in two narrow ways japicmp cannot see — both recorded in the CHANGELOG. A record pattern destructuring 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. Binary linkage is intact.

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.

CHANGELOG placement needs a decision that is not this PR's to make. The entry sits under the existing ## v2.4.1 — Planned heading, which is what this branch already carries — as do #697 and #698, both of which added public API under it. The branch is still on 2.4.1-SNAPSHOT. If the 2.5 line is meant to open, the heading and the version move together in one commit, and this entry moves with them; putting that decision inside a feature PR seemed worse than naming it here.

Lane: canonical (document.api, document.backend.semantic) — new public API, @since 2.5.0, @Beta, stability document and knowledge pack updated alongside.

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.
…s it

Three defects in what 85a5ab2 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.
@DemchaAV
DemchaAV merged commit 23ace8b into 2.5-dev Sep 22, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/semantic-export-layout-context branch September 22, 2026 09:36
DemchaAV added a commit that referenced this pull request Sep 22, 2026
Forward integration so the 2.5 line carries the 2.4.1 release and
everything after it. Without it every branch cut from develop and
aimed at 2.5-dev carries the difference into its own diff: PR #701
showed 388 files and 46k lines of website, CI and dependency work that
is not its own, and could not be merged or even built, because a
pull_request runs against a merge ref GitHub cannot create for a
conflicting branch.

Two conflicts, both resolved against what the 2.5 line already decided.

assets/readme/examples/emoji-shortcodes.pdf keeps 2.5-dev's copy: it was
re-rendered by #698 so its emoji copy out of the text layer, and
develop's is the older render from the v2.4.1 cut.

CHANGELOG.md auto-merged into a claim that was not true. The 2.5 line's
entries -- the layout opt-in from #702, the SVG icon text and the PDF
text layer from #697 and #698, and their tests -- had been written under
a heading that said "Planned" on this branch and says "2026-09-21" on
develop, so the merge filed them under a release that shipped before any
of them existed. They move to a new "v2.5.0 - Planned" section; the
released v2.4.1 section is now byte-identical to develop's, which is the
check that says nothing else moved with them.

The version is left as the merge brought it, 2.4.2-SNAPSHOT. What the
2.5 line calls itself is a release decision, not a merge resolution.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant