feat(docx): make an exported document behave like a Word document, not just look like one - #701
Merged
Merged
Conversation
…you edit The DOCX column of the capability matrix is a list of crosses, but a cross says a payload is unsupported, not what a reader actually gets when they open the file and start typing. Nothing in the repository measured that, so the gap between "editable Word document" and "what we ship" was an impression rather than a number. Adds a corpus and the harness that measures it. EditableExportFixtures builds one two-page document out of the constructs whose Word representations disagree — mixed runs, a two-column row, a panel that has to grow, a real table, an image, a list, a page-number footer — plus a small boundary fixture for a rounded panel and a clipped outline. EditableExportProbeTest renders it through both backends and reads the package back from disk, counting the things that decide how a Word file behaves rather than how it looks: whether a styles part exists at all, whether there is any numbering, how many fonts are embedded, and whether any body content ended up in a text box. It hashes word/document.xml rather than the package, because a DOCX carries creation timestamps and a PDF a time-seeded /ID, so a package hash only ever says "different run". PdfRegionDiff compares the reference PDF against a PDF an editor rendered from the DOCX, on a grid rather than as one page-wide percentage: an A4 page is mostly margin, so a paragraph that vanished still scores above ninety percent. It reports the one comparison that needs no tolerance — a region with ink in the reference and none in the candidate — separately from the pixel fractions. Two scripts drive the half no build can do. convert-with-word.ps1 renders the DOCX through Word COM or LibreOffice and records which version did it; edit-protocol-word.ps1 performs the editing scenarios and checks them after a save and reopen, since an edit that only holds in memory has survived nothing. A missing editor is recorded NOT_RUN and exits non-zero; it never becomes a pass. The measurements record a baseline; they do not gate. The exporter under measurement is documented to drop fixed-layout geometry, so asserting that nothing is lost would assert a known falsehood. Only pagination, page size and the absence of text boxes are asserted, because those held on first measurement and a regression in them would be a defect rather than a known gap. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0. Against Word 16.0 (16.0.20326) on Windows 11: both fixtures paginate identically to the reference (2 and 1 pages) at matching page size; the export carries real paragraphs, two tables, a real footer part and zero text boxes; 14 and 16 regions of content are lost, worst grid cell 78.9% differing. Five of six editing scenarios pass — lengthening a sentence, inserting a paragraph, adding and deleting a table row, and a clean save/reopen. Restyling through the Normal style fails, measured rather than assumed: Normal accepts 14pt and the body text stays at 10.5pt, because every run carries a direct size. LibreOffice is not installed on this machine, so its whole column is NOT_RUN.
… the export leaves The probe established that the DOCX export is already fully flowing text and already survives most editing, and that two contract items fail: restyling through the Normal style does nothing, and pressing Enter in a list produces a plain paragraph. Both were measured, neither was explained. Reading the package back said why — there is no styles part and no numbering part at all — but "the construct is missing" is not the same claim as "adding the construct fixes it in Word", and only Word can settle the second. EditableExportPrototype settles it. It takes the package the semantic backend produced, walks the same document tree, and adds three things: a styles part carrying the body font and size as the document default with the direct size and font stripped from every run that only restated it; a numbering part with the list paragraphs attached to it and the marker character removed now that Word draws it; and paragraph shading plus a left border on the paragraphs inside a filled container, coloured from the node. Against Word 16.0 on the same document, the baseline passes five of seven editing scenarios and the prototype passes seven: Normal set to 14pt now moves the body from 10.5pt to 14pt, and Word reads the checklist as a real list (ListType 4) that a new paragraph joins as an item. Initial fidelity moves much less — worst grid cell 78.9% to 53.7%, cells over 25% from 45 to 40, lost regions 14 to 13. That gap between the two is the useful result. The lost regions are not the panel, which the prototype does draw: they are the table's Qty and Amount columns, which Word sizes to its own auto-width while the reference spans the text width. A column width is a measurement, not a construct, so no amount of Word vocabulary reaches it. The prototype also costs geometry where it wins behaviour: a real Word list indents its marker about a quarter inch, wider than the reference's marker gap. The prototype matches paragraphs to nodes by their text, which the architecture rules out and which is sound only because these fixtures have distinct paragraph texts. It is a measuring instrument, not a design, and its Javadoc says so. Provenance belongs to the work that gives the export real access to resolved layout. Also records the font result properly: Lato is declared in the package, embedded in neither export, and is not installed on the measuring machine, so Word substituted. Checked rather than assumed. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0. Both editing protocols and the fidelity comparison rerun from a clean target; the numbers above reproduce exactly. LibreOffice is not installed here, so its column stays NOT_RUN and no gate is claimed across the matrix.
…as Normal Two things the export never wrote, both of them ordinary Word paragraph machinery, both measured as broken in Word 16.0 before this. A SectionNode or ContainerNode was treated as a transparent wrapper: its children were written and its fillColor, borders and stroke were dropped with nothing in the log to say so. A card exported as bare text, and the reader had no way to know a panel was missing. Word has no element that wraps a run of paragraphs, but it shades and borders each one, and consecutive paragraphs sharing a fill render as a single band — close enough to a panel to be worth having. Each paragraph a painted container wraps now carries the fill as w:shd and the borders as w:pBdr, a uniform stroke standing in for all four sides when per-side borders are absent. Nested containers resolve innermost-first and the paint stops where the container does. Every body paragraph is created through one helper so no writer can forget it; a page break is deliberately excluded, since a shaded break draws a band across the page. Three parts of a container's design are not representable and are documented rather than approximated: the corner radius, because Word paragraph shading is rectangular — dropped with one warning per export; the padding, because a paragraph's shading hugs its own text; and a table inside a painted container, which keeps its own cell paint. The package also carried no styles part at all, so Word invented a latent Normal that no run referred to while every run spelled out its own font and size. A direct run property beats a style, so the ordinary way a person restyles a Word document was accepted and did nothing: setting Normal to 14pt left the body at 10.5pt. The export now writes document defaults and a Normal carrying the document's dominant text style, and a run that only restates it writes no w:rFonts, w:sz, w:szCs or w:color. The dominant style is chosen by how many characters are set in it, not by how many nodes use it — headings are numerous and short while body text is long, so counting nodes elects the heading style and leaves every body run carrying a direct size, which is the bug this measure avoids. A document with no text writes no styles part. Two existing tests asserted the old location of properties that moved. They were not relaxed. DocxRunStyleTest's claim is about a code chip keeping its own face while the surrounding text does not, and that claim is unchanged — only the surrounding run now inherits rather than repeats. DocxRightToLeftTest's claim is that Hebrew has a w:szCs to read, and it now holds in two places, so it is asserted in two tests: the direct case, on a run that differs from the body style, and the inherited case, where the size moved to Normal. One Normal, and no generated heading styles: a heading still carries direct formatting rather than a named Heading 1, so restyling the body works and restyling all headings at once does not. Named heading styles are separate work. Verification: ./mvnw -B -ntp test -Dtest='Docx*Test' -pl :graph-compose-render-docx -am, 105 tests, exit 0. Capability matrix, DOCX recipe and CHANGELOG updated in this change, as the matrix's own rule requires.
A marker written into the run text looks like a list and is not one. Measured in Word 16.0 against the previous export, ListFormat.ListType came back as "no numbering": the reader had bulleted-looking paragraphs, so pressing Enter produced a blank paragraph instead of the next item, and demoting with Tab did nothing. That is the contract item this repairs. A list now gets a numbering.xml definition, w:numPr on each item and the authored marker as the level's text, with nesting as a list level rather than two spaces per depth. The ListMarker.defaultForDepth cascade becomes the levels' markers and markerFor(depth, ...) still chooses a level's own, so which marker each depth gets is unchanged — only where it is written. Four kinds of list keep the plain-paragraph form, because Word cannot hold them without changing what was asked for: a markerless list, which would gain a marker and an indent it declined; a drawn marker, which has no Word list analogue; a list whose siblings at one depth carry different markers, since a definition names one marker per level and replacing one with the other silently would be worse than writing both as text; and rich items, whose runs the numbered path does not write. This does not make markerGap work and does not claim to. Real Word numbering was measured against that requirement and rejected for it, and it is still rejected: Word places content at an absolute indent and cannot be told "one marker width plus a gap from here". The level's marker column is a stated constant — 180 twips, plus 120 per nesting level, chosen near the single space the text form left — and the Javadoc says it is a convention rather than a measurement, for the same reason hangingIndent is unrepresentable here. What this buys is behaviour; what it costs is that the marker column is not the configured gap. Twenty-one assertions across four suites encoded the old form. None were relaxed to pass. DocxListParityTest's subject is marker normalization, which is unchanged — its markers are now read from the level definitions, where they live. DocxHangingIndentIsIgnoredTest still pins that the flag and the gap change nothing, and pins it harder: it now compares the whole list definition with and without the flag, not just the text. Its old claim that no numbering is written was about rejecting numbering as a way to honour the gap, and the Javadoc now separates the two. The freeze test named itself "legacy" and said in its own words that native list geometry would have to rewrite it; it is renamed DocxListGeometryFreezeTest and pins the new geometry — including that no indent characters of either backend's kind reach the run text, which is the divergence it used to guard and which no longer exists. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0, 116 tests in the module. Eight new tests in DocxListNumberingTest cover the numbered path and all four fallbacks. Capability matrix, the DOCX and lists recipes and CHANGELOG updated in this change.
…rt paths Five findings from the pre-PR review, four of them in code this branch just added and one older defect the render made obvious. The styles part named the font in w:ascii only. POI's XWPFRun.setFontFamily writes ascii, hAnsi, cs and eastAsia, and the export now suppresses a run's own rFonts when it matches the document default — so naming one slot sent every High-ANSI character, every complex script and all CJK to Word's theme font while the rest of the line kept the asked-for family. A Hebrew body paragraph in the dominant style lost its w:cs entirely, which is a regression against the export this branch started from. All four slots are written. A list nested past nine levels produced a document Word refuses to open. CT_AbstractNum/lvl is maxOccurs="9" and POI saves a tenth without complaint, so a guard-free path turned a readable document into an unopenable one. Deeper than nine keeps the text form. The dominant text style was keyed on DocumentTextStyle. That record's equality is its components', and DocumentColor defines no equals, so styles built inline per paragraph — ordinary authoring — each weighed alone and the body's characters never added up. Measured: six body paragraphs against three headings sharing one instance elected the heading, so restyling Normal moved the headings instead of the body. Styles are now weighed by what the styles part writes: family, half-points, packed RGB. Size and colour are compared the same way where a run decides whether to restate the style. A row inside a painted container broke the band into stripes. Its paragraphs live in table cells and cannot carry the paint, so the cells take it instead. The newBodyParagraph Javadoc claimed every body paragraph went through it, which was not true of four writers; it now names the three that stay out and why. A list whose flat items are all blank claimed level zero for a marker no paragraph is written for, either refusing numbering to a uniform nested list or minting a definition nothing references. It seeds level zero only from an item that survives normalization. Also two defects the Word render showed. A RowNode is carried as a one-row table so editors keep the side-by-side layout, but POI ships Word's default single-line grid and nothing turned it off, so every two-column block exported ruled where the PDF draws nothing. And the header/footer writer added its right tab stop through addNewPPr() after the spacing calls had already created the paragraph properties — a second w:pPr that Word ignores in favour of the first, so the page number sat on Word's default half-inch grid instead of the right margin. Both confirmed fixed in a Word render of the probe corpus. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0, BUILD SUCCESS, 324 core + 124 module tests. Ten new tests, one per finding plus the resolution of an item's numId through w:num to the definition carrying its marker — every marker assertion read abstract definition zero directly, so a correct marker on an unreferenced definition would have passed. Measured through Word 16.0 on the probe corpus: worst grid cell 78.9% to 53.9%, cells over 25% from 45 to 38, regions of lost content from 14 to 12.
| * {@code Object} and hands back whichever member matched.</p> | ||
| */ | ||
| private static int twips(Object measure) { | ||
| return Integer.parseInt(String.valueOf(measure)); |
| * {@code Object} and hands back whichever member matched.</p> | ||
| */ | ||
| private static int twips(Object measure) { | ||
| return Integer.parseInt(String.valueOf(measure)); |
Comment on lines
+95
to
+97
| session.chrome().zone(DocumentPageZone.footer(30, page -> new RowBuilder() | ||
| .name("FooterZone") | ||
| .gap(8) |
|
|
||
| // Two columns of unequal length: the row must keep them side by side and | ||
| // must not lose the longer column's tail at a page boundary. | ||
| page.addRow("TwoColumns", r -> r.gap(18).columns(weight(3), weight(2)) |
CommittedAssetDriftTest held the committed previews against what the catalogue renders, and the Word one had moved: this branch gives a container's fill and borders, a styles part and real list numbering to the DOCX export, so the same example produces a different document. The guard names the fix — re-render the file, or revert what moved it — and the change is intended, so the file is re-rendered. Only the DOCX. The PDF beside it differs byte-for-byte on every run through its time-seeded /ID, which AssetContent drops, so the guard did not flag it and committing it would be churn. Verification: ./mvnw -B -ntp test -Dtest=CommittedAssetDriftTest -f examples/pom.xml, 3 tests, BUILD SUCCESS. Regenerated through WordExportExample after installing core, render-pdf, render-docx, render-pptx and templates, since the examples module resolves them from the local repository rather than the reactor.
POI writes w:tblW as w=0 type=auto, which is Word's instruction to shrink a table around its own content. Nothing overrode it, so every exported table sized itself to its text while the reference render spans much more, and a row carried as a one-row table collapsed around a label. A row takes the whole width it is offered whatever its children measure, so its table gets the content width. A table's own width is written only where it needs no measuring: the width the author stated, and the column grid when every column is fixed, with a stated width's surplus going to the last column as the layout gives it. A table with an auto column and no stated width keeps Word's sizing, because an auto column is as wide as its content needs and that is a measurement this backend cannot make. Tests: six cases over the exported package -- an auto table left alone, a stated width honoured through auto columns, a fixed grid written exactly, the surplus rule, a mixed table left alone, and a row spanning the content width.
POI's createTable ships a full set of single-line table borders, and turning them off added a second element per edge instead of replacing the first. CT_TblBorders permits one child per edge, so every exported row carried a w:tblBorders the schema does not allow. Word reads the last element and draws nothing, which is why the render looked right while the part was invalid. Tests: the carrier has exactly one element per edge and no single-line border survives.
A row's slots are arithmetic on the width it is offered for three of the four ways it can divide: weights, an even split and fixed columns are all shares of what is left after the gaps. Only an auto column and the flex path ask what a child's content naturally measures. Word was left to guess all four, and its autofit sizes columns to their text, so a short label beside a long one took a fraction of the width the layout gives it. The three that are arithmetic are now written as the grid. Word has no inter-column gap, so the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin: the text box is exactly the slot and each column starts exactly where its slot does. Cell margins are written even when zero, because Word's own default is not, and the table is marked fixed-layout, without which the grid is only a starting suggestion that Word re-fits to the content. This moves the columns onto the layout's and uncovers a drift that was partly cancelling it: Word starts the body 12.8pt lower than the reference and sets each body line 13.9pt against 9.7pt, so a row that was one line too short had been pulling the page back up. The line height is measured from the font, so closing it needs the resolved layout rather than arithmetic. Tests: seven over the exported package -- an even split, weights with a gap, fixed plus weight, padding in the outer columns, and the two cases that stay Word's.
The capability table said a table is a Word table and a row is a one-row table without saying how wide either comes out, which was the part that differed most from the reference render. Says what is written, what is left to Word and why, and what to state to pin it.
Comment on lines
+85
to
+86
| XWPFTable table = onlyTable(page -> page.addRow(r -> r | ||
| .gap(20) |
|
|
||
| /** {@code ST_TwipsMeasure} is an xmlbeans union, so the accessor is typed Object. */ | ||
| private static long twips(Object measure) { | ||
| return Long.parseLong(String.valueOf(measure)); |
|
|
||
| /** {@code ST_TwipsMeasure} is an xmlbeans union, so the accessor is typed Object. */ | ||
| private static long twips(Object measure) { | ||
| return Long.parseLong(String.valueOf(measure)); |
CommittedAssetDriftTest went red on word-export-companion.docx: three commits landed after the last re-render -- a table stating its width, a row carrier writing each border once, and a row's cells taking the slots the layout gives them -- and each of them changes the bytes of the document the example produces. The reactor gate does not cover this. The examples module is outside it and resolves the engine from the local repository, so a change to the export is invisible there until CI runs the examples job. Only the DOCX. The PDF beside it differs on every run through its time-seeded /ID, which the comparison drops, so committing it would be churn. Verification: ./mvnw -B -ntp test -f examples/pom.xml, 93 tests, BUILD SUCCESS, after installing core, render-pdf, render-docx, render-pptx and templates and regenerating through WordExportExample.
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.
The base now carries develop, so this branch stops dragging the difference into its own diff -- 388 files and 46k lines of website, CI and dependency work that was never its own -- and GitHub can build a merge ref for it again, which is what CI runs against and why no check had started since the conflict appeared. Three conflicts, each resolved toward the side that owns the thing. assets/readme/examples/word-export-companion.docx keeps this branch's render: the branch is the only side that changes the DOCX export, so the preview produced by its exporter is the one that matches. docs/architecture/backend-capability-matrix.md keeps both edits, not one of them. The base added a row for the text an inline icon stands for; this branch rewrote the rectangle row's DOCX cell for container paint. They describe different rows and both belong. CHANGELOG.md auto-merged into a claim that was not true, for the second time in this integration. This branch wrote its entries under a heading that reads "Planned" here and "2026-09-21" on the base, so the merge filed the DOCX work under a release that shipped without it. The entries move to the open v2.5.0 section, and the released v2.4.1 section is compared line for line against the base afterwards -- identical, which is what says nothing else travelled with them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A DOCX export produced a file that looked like the document and did not behave like one. Three constructs Word has and this exporter never wrote — a styles part, a numbering part, and a container's fill and borders — were missing, so a reader could open the file, try the ordinary things, and find that none of them worked. All three are ordinary
w:p-level machinery: none needswps:wsp,poi-ooxml-full, or access to resolved layout, which is why they land ahead of the layout work rather than behind it.Measured through Microsoft Word 16.0 on a corpus built for this: the editing protocol goes from 5 of 7 scenarios to 7 of 7, and the initial-look comparison against the reference PDF improves from a worst grid cell of 78.9% differing to 53.9%, with regions of lost content down from 14 to 12.
Commits (review sequentially)
32472055— the measuring harness. A two-page corpus of the constructs whose Word representations disagree, plus a boundary fixture.EditableExportProbeTestrenders it both ways and reads the package back from disk, counting what decides how a Word file behaves rather than how it looks: whether a styles part exists, whether there is numbering, how many fonts are embedded, whether any body content is a floating text box.PdfRegionDiffcompares the reference PDF against a PDF an editor rendered from the DOCX, on a grid rather than as one page-wide percentage — an A4 page is mostly margin, so a paragraph that vanished still scores above ninety percent. Two PowerShell scripts drive the half no build can do: Word through COM, with a missing editor recordedNOT_RUNand exiting non-zero rather than becoming a pass.2f6d6b6c— the prototype that settled the question. Reading the OOXML specification cannot answer whether Word's paragraph machinery carries this design and keeps behaving when somebody edits it. A throwaway augmenter added the three constructs over the real export and Word answered yes to all three. It is kept only so the measurement can be reproduced against an older build, and its Javadoc says to delete it once nothing needs that comparison.9ff7e4c4— container paint and the Normal style.SectionNode/ContainerNodewas treated as a transparent wrapper: children written,fillColor/borders/strokedropped with nothing in the log. Word has no element that wraps a run of paragraphs, but it shades and borders each one and consecutive paragraphs sharing a fill render as one band — close enough to a panel to be worth having. Separately, the package carried no styles part at all, so Word invented a latentNormalno run referred to while every run spelled out its own font and size; a direct run property beats a style, so "change the Normal style" was accepted and did nothing (measured: Normal set to 14pt, body stayed at 10.5pt).7662c60b— a list Word owns. The marker was a character in the run text, which looks like a list and is not one:ListFormat.ListTypecame back as "no numbering", so Enter produced a blank paragraph instead of the next item. A list now gets anumbering.xmldefinition,w:numPrper item, and the authored marker as the level's text, with nesting as a level rather than two spaces per depth.40b65488— five defects in the paths the three commits above introduced, plus two the render showed. Detailed under Notes for review.Design decisions worth naming
The dominant text style is elected by characters, not by node count. Headings are numerous and short while body text is long, so counting nodes elects the heading style and leaves every body run carrying a direct size — the feature then silently targets the wrong style. Styles are weighed by what the styles part actually writes (family, half-points, packed RGB) rather than by
DocumentTextStyleequality: that record's equality is its components',DocumentColordefines noequals, and styles built inline per paragraph would each weigh alone.Numbering does not honour
markerGap, and does not claim to. Word places content at an absolute indent and cannot be told "one marker width plus a gap from here"; real Word numbering was measured against that requirement and rejected for it, and it is still rejected. The level's marker column is a stated constant — 180 twips, plus 120 per nesting level, chosen near the single space the text form left — documented as a convention rather than a measurement, for the same reasonhangingIndentis unrepresentable here. What this buys is behaviour; what it costs is that the marker column is not the configured gap.Four kinds of list keep the plain-paragraph form, because Word cannot hold them without changing what was asked for: a markerless list, which would gain a marker and an indent it declined; a drawn marker, which has no Word list analogue; a list whose siblings at one depth carry different markers, since a definition names one marker per level; and a list nested past nine levels, since
CT_AbstractNum/lvlismaxOccurs="9"and a tenth produces a file Word refuses to open.Three parts of a container's design are not representable and are documented rather than approximated: the corner radius, since Word paragraph shading is rectangular (one warning per export); the container's
padding, so the band hugs its text; and an author's table inside a painted container, which keeps its own cell paint.Testing
./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.graph-compose-render-docx124 tests,graph-compose-core324.New suites:
DocxContainerPaintTest(7) — fill reaches every paragraph, borders land on the side asked for, a uniform stroke stands in for four sides, an unpainted wrapper writes nothing, inner panel wins, paint stops with the container, a row inside a panel does not stripe.DocxDocumentStyleTest(7) — the styles part names the document's own body, a run that only restates it says nothing, a run that differs keeps saying so, the default is chosen by characters, styles differing only by colour identity weigh as one, the family is named for all four character ranges, a document with no text invents no style.DocxListNumberingTest(13) — the numbered path, all four fallbacks, the nine-level boundary in both directions, and the resolution of an item'snumIdthroughw:numto the definition carrying its marker.Twenty-one assertions across four existing suites encoded the marker-as-text form. None were relaxed to pass:
DocxListParityTest's subject is marker normalization, unchanged, with its markers now read from the level definitions where they live;DocxHangingIndentIsIgnoredTeststill pins that the flag and the gap change nothing and pins it harder, comparing the whole list definition with and without the flag rather than just the text. The freeze test named itself "legacy" and said in its own Javadoc that native list geometry would have to rewrite it; it isDocxListGeometryFreezeTestnow and pins the new geometry, including that no indent characters of either backend's kind reach the run text — the divergence it used to guard, which no longer exists.Editor measurement, Microsoft Word 16.0 (16.0.20326) on Windows 11, against the production export: lengthen a sentence, insert a paragraph, restyle through
Normal, insert a table row, delete a table row, continue a list with Enter, and a clean save/reopen — 7 of 7, each verified after a save and a reopen, since an edit that only holds in memory has survived nothing.Notes for review
40b65488closes five defects in the new paths and is worth reading on its own:w:asciionly. POI'sXWPFRun.setFontFamilywritesascii,hAnsi,csandeastAsia, and the export now suppresses a run's ownrFontswhen it matches the default — so naming one slot sent every High-ANSI character, every complex script and all CJK to Word's theme font. A Hebrew body paragraph in the dominant style lost itsw:csentirely, a regression against the export this branch started from.newBodyParagraphJavadoc claimed every body paragraph went through it, which was untrue of four writers; it now names the three that stay out and why.Two older defects the render showed, both confirmed fixed in a Word render: a
RowNodeis carried as a one-row table so editors keep the side-by-side layout, but POI ships Word's default single-line grid and nothing turned it off, so every two-column block exported ruled where the PDF draws nothing. And the header/footer writer added its right tab stop throughaddNewPPr()after the spacing calls had already created the paragraph properties — a secondw:pPrthat Word ignores in favour of the first, so the page number sat on Word's default half-inch grid instead of the right margin.Known limits, not fixed here:
writeTablewrites no width at all — notblW, nogridCol— so Word shrinks a table to its content while the reference spans the text width. The outer width needs no metrics and is a small follow-up; the column split underautoColumnsneeds intrinsic content widths, which means measuring text, which this backend has no font runtime to do.Normal, no generated heading styles. A heading still carries direct formatting rather than a namedHeading 1, so restyling the body works and restyling all headings at once does not.embedRegularis zero; a family the reader lacks is substituted. Verified by measurement rather than assumed — Lato is declared by the corpus and is not installed on the measuring machine.NOT_RUN. It is not installed on the measuring machine, so half the intended editor matrix is unmeasured and no gate is claimed across it. The conversion script reports this asNOT_RUNand exits non-zero rather than passing quietly.Lane: shared-engine (render backend,
render-docx) — no public API change;graph-compose-render-docxcarries no japicmp gate, and the diff adds no public members. Capability matrix, the DOCX and lists recipes, and CHANGELOG are updated alongside the behaviour, as the matrix's own rule requires.