Skip to content

feat(docx): make an exported document behave like a Word document, not just look like one - #701

Merged
DemchaAV merged 12 commits into
2.5-devfrom
feature/docx-editable-backend
Sep 22, 2026
Merged

DemchaAV merged 12 commits into
2.5-devfrom
feature/docx-editable-backend

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

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 needs wps: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)

  1. 32472055 — the measuring harness. A two-page corpus of the constructs whose Word representations disagree, plus a boundary fixture. EditableExportProbeTest renders 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. 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. Two PowerShell scripts drive the half no build can do: Word through COM, with a missing editor recorded NOT_RUN and exiting non-zero rather than becoming a pass.

  2. 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.

  3. 9ff7e4c4 — container paint and the Normal style. SectionNode/ContainerNode was treated as a transparent wrapper: children written, fillColor/borders/stroke dropped 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 latent Normal no 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).

  4. 7662c60b — a list Word owns. The marker was a character in the run text, which looks like a list and is not one: ListFormat.ListType came back as "no numbering", so Enter produced a blank paragraph instead of the next item. A list now gets a numbering.xml definition, w:numPr per item, and the authored marker as the level's text, with nesting as a level rather than two spaces per depth.

  5. 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 DocumentTextStyle equality: that record's equality is its components', DocumentColor defines no equals, 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 reason hangingIndent is 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/lvl is maxOccurs="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 -amBUILD SUCCESS. graph-compose-render-docx 124 tests, graph-compose-core 324.

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's numId through w:num to 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; DocxHangingIndentIsIgnoredTest still 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 is DocxListGeometryFreezeTest now 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

40b65488 closes five defects in the new paths and is worth reading on its own:

  • 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 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 its w:cs entirely, a regression against the export this branch started from.
  • A list nested past nine levels produced a document Word refuses to open; POI saves the invalid part without complaint.
  • The dominant-style key (see Design decisions).
  • A row inside a painted container broke the band into stripes; its cells take the paint instead. The newBodyParagraph Javadoc claimed every body paragraph went through it, which was untrue 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.

Two older defects the render showed, both confirmed fixed in a Word render: 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.

Known limits, not fixed here:

  • A table is still sized by Word, not by the document. writeTable writes no width at all — no tblW, no gridCol — 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 under autoColumns needs intrinsic content widths, which means measuring text, which this backend has no font runtime to do.
  • One Normal, 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.
  • Fonts are declared, not embedded. embedRegular is 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.
  • LibreOffice Writer is 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 as NOT_RUN and exits non-zero rather than passing quietly.

Lane: shared-engine (render backend, render-docx) — no public API change; graph-compose-render-docx carries 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.

…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.
@DemchaAV
DemchaAV changed the base branch from develop to 2.5-dev September 21, 2026 20:10
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.
@DemchaAV
DemchaAV merged commit 4058781 into 2.5-dev Sep 22, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-editable-backend branch September 22, 2026 11:55
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.

2 participants