Skip to content

feat(docx): export a multi-section document as Word sections - #726

Merged
DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-multi-section
Sep 23, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-multi-section

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Why

MultiSectionDocument is how a document gets more than one page geometry: a cover in one size, a body in another, a footer on one part and not the other. It rendered only to PDF (and to PPTX when the sizes match), and had no DOCX export at all. The semantic SPI had nowhere to put one: SemanticBackend carried export(graph, context) for a single document and nothing like the fixed-layout renderSections.

What changed

Semantic SPI (core, all @Beta)

  • SemanticSection(graph, context) — one section handed to a semantic backend: its authored graph and the context it would be exported with on its own. The resolved layout is included when the backend asks for one.
  • SemanticBackend.exportSections(List<SemanticSection>) — a default method, so existing backends keep compiling.
    • One section is exported through export.
    • More than one throws UnsupportedOperationException naming the backend. Silently concatenating the sections would give the whole document the first section's page, which is exactly what a multi-section document exists to avoid.
    • An empty list throws IllegalArgumentException.
  • MultiSectionDocument gains:
    • export(SemanticBackend);
    • toDocxBytes(), writeDocx(OutputStream) and buildDocx(Path), resolved through SemanticBackendProviders like the session's own DOCX methods. The export is produced in full before it is written, and buildDocx goes through AtomicFileOutput.
  • DocumentRenderingFacade.semanticSection builds the graph, context and layout once. The single-document export and the new package-private DocumentSession.toSemanticSection both use it, so a section gets exactly what the session would get.

DocxSemanticBackend

Structure.

  • export and exportSections share one writer.
  • Every section but the last hands its w:sectPr to its last paragraph, which is where Word ends a section. The body sectPr belongs to the last section.
  • Two endings get an added carrier paragraph, collapsed to one exact point so it cannot spill onto a page of its own:
    • a section that ends in a table, because Word does not end a section on a table;
    • a section that wrote nothing into the body — an empty session, or one of nodes this export drops. Its last paragraph is still the one closing the section before it, and reusing that paragraph would fold the two sections into one.
  • Each section gets its own page size, orientation, margins, and the header/footer parts its page zones (DocumentPageZone) describe, through an XWPFHeaderFooterPolicy bound to that section's sectPr. The text DocumentHeaderFooter slots and the watermark are not written, as for a single document.

Where Word would do otherwise, it is told what the PDF does.

  • Page numbers. w:pgNumType w:start="1" on every section, and a page zone's total becomes SECTIONPAGES instead of NUMPAGES. The PDF counts each section from its own first page.
  • Headers and footers. A section with no header or footer of its own, following one that has one, gets an empty part. Word would otherwise repeat the previous section's. The empty part is a one-point paragraph at distance 0 from the page edge, so it cannot push the body down past a narrow margin.
  • Metadata comes from the first section that declares it, as in the PDF.

Shared across the document.

  • One styles part, whose Normal is the dominant text style of all sections.
  • One font table, where the first definition of a family wins, as in the PDF.
  • One set of bookmark names, so a link in the cover reaches an anchor in the body.

Page-total placeholder. A page total now shows the laid-out page count (DocxLayoutMetrics.pageCount) before an editor updates the field, instead of 1. LibreOffice does not update SECTIONPAGES: with the old placeholder the body's second page read "2 of 1".

What changes for a single document. export for one document goes through the same writer, and one section exports byte-identically to its session (asserted by a test).

  • The only output change is the page-total placeholder above, and only for a document whose zone places pageTotal().
  • The committed word-export-companion.docx preview has no page total and did not move (CommittedAssetDriftTest green).
  • A header/footer policy is created only when a section has zones, as before, so a zone-less export gains no sectPr.

Docs and knowledge pack

  • Docs:
    • docs/recipes/docx-export.md has a new "Several sections in one document" section.
    • The capability-matrix row for multi-section documents changes from n/a to ✅, and its reading note is updated.
    • docs/capabilities.md, the render-docx/README.md paragraph that said multi-section export runs through PDF only, the api-stability.md Experimental entry, and CHANGELOG.
  • The knowledge pack is regenerated. The four knowledge checks CI runs pass locally:
    • extract-api --check
    • check-stability-doc
    • check-claims --check
    • check-routes

Verification

In an editor. A cover (300×400), a landscape body (500×300, footer "Body · page · total", 30 lines) and a back page (300×400) were exported and converted with LibreOffice. Pages:

Page Size Footer
1 300×400 none
2 500×300 page 1 of 2
3 500×300 page 2 of 2
4 300×400 none

The body breaks at the same line (18) as the engine's PDF of the same document.

Gate. Full reactor gate (clean verify over core, render-pdf/docx/pptx, templates, testing, qa, coverage) → BUILD SUCCESS. Examples after install: 93 green. Core javadoc:javadoc is clean.

New tests.

DocxMultiSectionTest (14):

  • each section's size, orientation and margins;
  • pgNumType start 1 and a SECTIONPAGES total;
  • the page total's placeholder is the section's laid-out count, and a single document's is its own;
  • a section after a footered one gets its own empty footer;
  • a section after a headed one gets an empty header, one point tall at distance 0;
  • a section before any footer gets none;
  • three sections with an empty middle one stay three, each with its own page;
  • a cover link resolves to a bookmark in the body;
  • a section ending in a table is closed by a one-point carrier paragraph;
  • metadata comes from the first section that states any;
  • buildDocx and writeDocx write the same two-section document;
  • a closed document refuses to export;
  • one section is byte-identical to its session.

SemanticBackendSectionsTest (3):

  • the default exports one section;
  • the default refuses two without exporting either;
  • the default rejects an empty list.

Sabotaging each of these fails the matching test:

  • the empty-footer override;
  • the page restart;
  • the page-total placeholder;
  • the check that the carrier is not a paragraph already closing a section.

Known limit. The same anchor name in two sections writes two bookmarks with one name. The PDF keeps the last one and warns; the DOCX export does not warn yet.

Lane: canonical (semantic export SPI + MultiSectionDocument) with shared-engine (render-docx). New public API is @Beta and named in api-stability.md. The only change to existing API is a default method added to SemanticBackend, which is binary- and source-compatible.

MultiSectionDocument rendered only to PDF. It gains export(backend),
toDocxBytes, writeDocx and buildDocx, and the semantic SPI gains
SemanticBackend.exportSections and the SemanticSection record, all
@beta. The default exports one section and refuses several.

DocxSemanticBackend writes a Word section per section with its own
page size, orientation, margins, header and footer. Page numbers
restart at 1 and a zone's total is SECTIONPAGES; a section without a
zone gets an empty header or footer instead of the inherited one;
styles, fonts and bookmark names are shared. A page total's
placeholder is the laid-out page count, which LibreOffice shows as it
does not update SECTIONPAGES. One section exports byte-identically to
its session.
A section that adds no body element - an empty session, or one of
nodes the export drops - found the previous section's closing
paragraph as its last and overwrote that section's properties, so
three sections came out as two. A paragraph that already closes a
section is no longer reused; an added carrier is one point tall.

An empty header or footer standing in for an inherited one is also
one point tall at distance 0, so it cannot push a narrow-margin body
down. The header/footer policy is created only when a section has
zones, as before. The docs say page-zone header and footer, which is
what maps; the missing-layout note names its section.
@DemchaAV
DemchaAV merged commit 12f1897 into 2.5-dev Sep 23, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-multi-section branch September 23, 2026 13:57
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