Skip to content

feat(docx): keep an inline chip's fill in the exported Word file - #709

Merged
DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-inline-shading
Sep 22, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-inline-shading

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

A status badge and an inline code() span both came out of the DOCX export as bare text.
The reduction every backend shares — InlineRun.textRuns — keeps a chip's glyphs and drops
its background, and nothing downstream put it back, so a red badge reading "overdue" was the
same colour as the sentence around it. A chip is the piece of a document that is doing the
talking, and the export was dropping exactly it.

What changed

The fill becomes the run's own w:shd, which takes any RGB — in a paragraph and inside
a list item alike, since a badge in a bulleted list is a badge for the same reason. Word's
highlighter pen (w:highlight) was the other candidate: it takes one of sixteen named
colours, which no brand palette is a member of, and reads as text someone marked up rather
than as design.

The runs are walked as the document authored them. The reduction answers what to
write, and a chip is more than its text, so the fill is read from the authored run beside
the reduced one — textOf still asks that one reduction about each run rather than
repeating its rules here, so a chip's text is normalized exactly as it is everywhere else.

A translucent fill is flattened against what Word paints underneath it. w:shd is
opaque, and the chip this DSL reaches for most — inlineCode(...) — is a fifth-opacity
grey: written at full strength it is a solid slab where the page has a tint. colourUnder
reads the shading this export itself wrote on the run's paragraph or on the cell holding it,
and falls back to the page's white — read back from the file being written rather than
tracked in a field, so it cannot drift from what was written, and read without creating the
w:tcPr POI's own accessor would have added to every plain cell holding a chip.

The chip then agrees with the file it is in, which on a white page is the colour the PDF
shows. Deliberately not the PDF's own composite: a translucent container fill lands opaque
in DOCX (its own recorded approximation), so compositing the chip over the translucent
original would paint it a colour Word never draws.

What Word has no way to say is the chip's shape. Shading covers the glyph box, so the
corner radius is square and the padding that widens the run on the page is not in the file.
The flattening is the third loss: the colour is right, but the chip stops being translucent,
so recolouring what sits under it in Word no longer moves it. All three are recorded in the
export report, per chip — and a chip that loses none of them reports nothing, because a
record of loss listing things that lost nothing cannot be read.

One trap worth naming: run properties are a repeating choice in the schema, so w:shd is an
array there and addNewShd() appends. Asked twice, a run would carry two shadings and Word
would read whichever it met first — the same shape of defect as the duplicated table borders.

Verification

./mvnw -B -ntp clean verify over the eight-module gate → BUILD SUCCESS (core 815,
render-pdf 337, render-docx 212, testing 5, render-pptx 140, templates 127, qa 1784).
./mvnw -B -ntp test -f examples/pom.xml93 tests, BUILD SUCCESS; no committed preview
drifted.

render-docx goes from 199 to 212 tests. DocxInlineBackgroundTest covers an opaque fill
written exactly as given; the default code chip flattened to EFF1F3 over the page, to
E1E7EE inside a shaded card and to E1E7EE again inside a shaded table cell; a plain cell
holding a chip coming away with no w:tcPr; a chip keeping its own ink and its place in the
sentence; a linked chip staying a hyperlink; a chip inside a hanging-indent list item; a chip
in a right-to-left paragraph keeping both its fill and its direction; the shape and the
flattening recorded as approximated; a chip that loses nothing reporting nothing; and a
paragraph whose runs carry no text still reading as its text.

Checked on the export the probe corpus produces, not only on unit fixtures — the two chips in
mixed-two-pager.docx come out as:

'code()' -> <w:shd w:val="clear" w:color="auto" w:fill="EFF1F3"/>
'v2'     -> <w:shd w:val="clear" w:color="auto" w:fill="DBE9F6"/>

the first flattened from rgba(175,184,193,.2) over white, the second the badge's own colour
untouched.

Measured on the page an editor drew, not only on the XML. LibreOffice Writer's render of
mixed-two-pager.docx, sampled at 144 DPI against the reference PDF:

reference LibreOffice from the DOCX
the badge's DBE9F6 734 px, box [974,137..1010,161] 367 px, box [957,134..978,157]
raw AFB8C1 (the unflattened code fill) 0 px 0 px

The badge's colour lands in the same place; its area is half, which is the padding the file
cannot carry — the loss the report names. AFB8C1 appears nowhere, so no translucent chip was
written at full strength. (Word through COM would not convert on this machine this round —
it spun without producing a page — so the editor here is LibreOffice.)

Lane: canonical — document.backend.semantic.docx, plus three javadoc paragraphs in
core that still said a text backend drops the fill. No engine, layout or pagination
internals are touched, and no public signature changes.

Stacks on #708 (feature/docx-headings), which is merged into this branch; review that
one first.

* unstyled cell holding a chip would come away carrying an empty one.</p>
*/
private java.awt.Color colourUnder(XWPFRun run) {
XWPFParagraph para = run.getParagraph();
if (!hex.matches("(?i)[0-9a-f]{6}")) {
return null;
}
return new java.awt.Color(Integer.parseInt(hex, 16));
A status badge and an inline code span both came out as bare text. The
reduction every backend shares keeps a chip's glyphs and drops its
background, and nothing downstream put it back — so a red badge reading
"overdue" was the same colour as the sentence around it.

The fill is now the run's own w:shd, which takes any RGB, in a paragraph
and inside a list item alike. Word's highlighter pen was the other
candidate and takes one of sixteen named colours, which no brand palette
is a member of.

What Word has no way to say is the chip's shape: shading covers the glyph
box, so the corner radius is square and the padding that widens the run on
the page is not in the file. Both are recorded in the export report, per
chip, and a chip that loses neither reports nothing.

A w:shd fill is opaque, and the chip this DSL reaches for most —
inlineCode(...) — is a fifth-opacity grey, a solid slab if written at full
strength. A translucent fill is flattened first against what the export
itself wrote underneath it: the paragraph's shading, the cell's, or the
page. That is the composite the PDF makes of the same chip.
Asking an unpainted cell what colour it is went through POI's
cell-properties accessor, which creates the w:tcPr it cannot find — so a
chip in a plain cell left an empty one behind. Read through isSetTcPr
instead, and pin it with a test that a cell nobody painted stays unpainted.

A chip in a shaded cell had no test at all: the branch would have
flattened against white and looked almost right. It flattens against the
cell now, proven.

The list-item test was passing on the export's no-layout fallback: an item
made of inline runs needs hangingIndent(true) to be laid out, and without
it the document failed layout and the export fell back — so the chip was
never proven on the list path it claims. Also pins a chip in a
right-to-left paragraph, where the direction and the shading are written
into the same w:rPr by different calls.

A flattened translucent fill is recorded as a loss now. The colour on the
page is right, which is why it read as exact, but the chip stops being
translucent: shade that paragraph another colour in Word and it keeps the
tint it was flattened to.

The claim that the flattening reproduces the PDF's composite was too
broad — a translucent container fill lands opaque, and a chip on it
composites over the opaque one. The chip agrees with the file it is in,
and the docs now say that instead.
@DemchaAV
DemchaAV force-pushed the feature/docx-inline-shading branch from c7aacc1 to 15d734d Compare September 22, 2026 19:39
@DemchaAV
DemchaAV merged commit dbd0ff7 into 2.5-dev Sep 22, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-inline-shading branch September 22, 2026 19: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.

2 participants