What happens
A RichText paragraph with mixed runs exports to DOCX with every run in the paragraph's base style. A bold segment, an accent-coloured segment, and plain text all come out identical. The run's own textStyle is present on the node and never read.
The same call also ignores linkTarget, so a run authored as a link exports as ordinary text — the label survives, the destination does not.
Where
render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
- :301 — inside the
inlineTextRuns() loop, writeParagraph calls applyStyle(docRun, node.textStyle()). It has run in hand and passes the node's style instead.
- :382 —
writeRowCellChild writes a table-cell paragraph from the concatenated paragraph.text() and one style, so a RichText cell is flattened the same way. No text is lost here: ParagraphNode's compact constructor concatenates run text into text when text is blank, including highlight-chip text.
- :429 — related, one line:
applyStyle switches on BOLD / ITALIC / BOLD_ITALIC / UNDERLINE and lets STRIKETHROUGH fall through default -> {}.
InlineTextRun is (String text, DocumentTextStyle textStyle, DocumentLinkTarget linkTarget), and its own Javadoc says the style "falls back to the paragraph style when null" — the fallback is what the backend applies unconditionally.
Reproducing
Compose a paragraph with RichText mixing a bold run, a coloured run, and a linked run; export with DocxSemanticBackend; open the .docx. All three render in the base paragraph style, and the linked run is plain text.
Fix direction
- At
:301, honour the run: fall back to node.textStyle() only when run.textStyle() is null — the semantics InlineTextRun already documents.
- At
:382, walk paragraph.inlineTextRuns() the way writeParagraph does instead of writing the concatenated string as a single run, so table cells keep per-run styling too.
- At
:429, add the STRIKETHROUGH branch mapping to Word's strike run property.
Hyperlink relationships are a larger piece of work (an XWPFHyperlinkRun plus an OPC relationship per link) and are better tracked separately — this issue covers the styling that is already available on the node and discarded.
Also update
docs/architecture/backend-capability-matrix.md per its own maintenance rule. The paragraph row is currently ⚠️ for DOCX because of exactly this, and text decorations are ⚠️ because of STRIKETHROUGH; both move once the fix lands. render-docx/README.md carries the same two bullets.
Scope
Post-2.1. Deliberately not folded into release prep — it changes DOCX output for any document using mixed-style paragraphs, so it wants its own test coverage rather than riding along with a documentation pass.
Two more of the same shape
Found while reclassifying the DOCX column of the capability matrix (#448). Both are the same defect as above — data the node already carries, never read by the backend — so they belong here rather than in their own issues.
Table colSpan / rowSpan are not applied
writeTable reads only DocumentTableCell.lines(). The record also carries colSpan, rowSpan and a DocumentTableStyle, and none of the three is used.
The consequence is structural rather than cosmetic. Column count comes from node.rows().get(0).size() — the number of cell records in the first row, not the visual column count. A merged cell makes those two numbers differ, so every row below a span lands in the wrong column. Word has w:gridSpan and w:vMerge; this is not a format limitation.
The same method also never calls applyStyle, so table text carries no font, size, colour or weight at all.
Image fitMode and scale are ignored
writeImage embeds the picture at node.width() / node.height(). ImageNode also carries fitMode (a DocumentImageFitMode, defaulting to STRETCH) and scale, and neither is read:
CONTAIN and COVER behave as STRETCH — aspect ratio is not preserved and COVER does not crop.
scale exists precisely for the case where width and height are omitted, but that path falls back to a hardcoded 100 × 100 pt instead.
- The picture type is the literal
Document.PICTURE_TYPE_PNG regardless of the actual bytes, so a JPEG or GIF is declared as PNG.
Fix direction
- In
writeTable, honour colSpan / rowSpan via w:gridSpan / w:vMerge, size the grid from the resolved column count rather than the first row's record count, and apply the per-cell style and the cell's text style.
- In
writeImage, resolve the drawn box from fitMode and scale the way the fixed-layout path does, and detect the picture type from the image bytes instead of hardcoding PNG.
Matrix rows for both are already ⚠️ as of #448, with the same detail; they move once this lands.
What happens
A
RichTextparagraph with mixed runs exports to DOCX with every run in the paragraph's base style. A bold segment, an accent-coloured segment, and plain text all come out identical. The run's owntextStyleis present on the node and never read.The same call also ignores
linkTarget, so a run authored as a link exports as ordinary text — the label survives, the destination does not.Where
render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.javainlineTextRuns()loop,writeParagraphcallsapplyStyle(docRun, node.textStyle()). It hasrunin hand and passes the node's style instead.writeRowCellChildwrites a table-cell paragraph from the concatenatedparagraph.text()and one style, so aRichTextcell is flattened the same way. No text is lost here:ParagraphNode's compact constructor concatenates run text intotextwhentextis blank, including highlight-chip text.applyStyleswitches onBOLD/ITALIC/BOLD_ITALIC/UNDERLINEand letsSTRIKETHROUGHfall throughdefault -> {}.InlineTextRunis(String text, DocumentTextStyle textStyle, DocumentLinkTarget linkTarget), and its own Javadoc says the style "falls back to the paragraph style when null" — the fallback is what the backend applies unconditionally.Reproducing
Compose a paragraph with
RichTextmixing a bold run, a coloured run, and a linked run; export withDocxSemanticBackend; open the.docx. All three render in the base paragraph style, and the linked run is plain text.Fix direction
:301, honour the run: fall back tonode.textStyle()only whenrun.textStyle()is null — the semanticsInlineTextRunalready documents.:382, walkparagraph.inlineTextRuns()the waywriteParagraphdoes instead of writing the concatenated string as a single run, so table cells keep per-run styling too.:429, add theSTRIKETHROUGHbranch mapping to Word's strike run property.Hyperlink relationships are a larger piece of work (an
XWPFHyperlinkRunplus an OPC relationship per link) and are better tracked separately — this issue covers the styling that is already available on the node and discarded.Also update
docs/architecture/backend-capability-matrix.mdper its own maintenance rule. The paragraph row is currentlySTRIKETHROUGH; both move once the fix lands.render-docx/README.mdcarries the same two bullets.Scope
Post-2.1. Deliberately not folded into release prep — it changes DOCX output for any document using mixed-style paragraphs, so it wants its own test coverage rather than riding along with a documentation pass.
Two more of the same shape
Found while reclassifying the DOCX column of the capability matrix (#448). Both are the same defect as above — data the node already carries, never read by the backend — so they belong here rather than in their own issues.
Table
colSpan/rowSpanare not appliedwriteTablereads onlyDocumentTableCell.lines(). The record also carriescolSpan,rowSpanand aDocumentTableStyle, and none of the three is used.The consequence is structural rather than cosmetic. Column count comes from
node.rows().get(0).size()— the number of cell records in the first row, not the visual column count. A merged cell makes those two numbers differ, so every row below a span lands in the wrong column. Word hasw:gridSpanandw:vMerge; this is not a format limitation.The same method also never calls
applyStyle, so table text carries no font, size, colour or weight at all.Image
fitModeandscaleare ignoredwriteImageembeds the picture atnode.width()/node.height().ImageNodealso carriesfitMode(aDocumentImageFitMode, defaulting toSTRETCH) andscale, and neither is read:CONTAINandCOVERbehave asSTRETCH— aspect ratio is not preserved andCOVERdoes not crop.scaleexists precisely for the case where width and height are omitted, but that path falls back to a hardcoded100 × 100pt instead.Document.PICTURE_TYPE_PNGregardless of the actual bytes, so a JPEG or GIF is declared as PNG.Fix direction
writeTable, honourcolSpan/rowSpanviaw:gridSpan/w:vMerge, size the grid from the resolved column count rather than the first row's record count, and apply the per-cell style and the cell's text style.writeImage, resolve the drawn box fromfitModeandscalethe way the fixed-layout path does, and detect the picture type from the image bytes instead of hardcoding PNG.Matrix rows for both are already⚠️ as of #448, with the same detail; they move once this lands.