Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ follow semantic versioning; release dates are ISO 8601.
other, so it catches one being corrected without the other — not a pair that was wrong
together from the start.

### Fixed

- **DOCX keeps the styling a mixed paragraph asks for.** A `RichText` paragraph exported
with every run in the paragraph's base style, so a bold segment, an accent-coloured
segment and plain text all came out identical — a valid `.docx`, no warning, and the
emphasis simply absent. `InlineTextRun` documents its style as falling back to the
paragraph's *when null*; the backend was applying that fallback unconditionally, with
the run in hand. Each run now carries its own style, in a paragraph and in a `row`
cell, which used to be written from its concatenated text in one style. (A `table`
cell is written from lines rather than runs and still carries no styling.)

- **`STRIKETHROUGH` reaches Word.** It was the one `DocumentTextDecoration` with no
branch in the DOCX style mapping and fell through to no decoration at all.

## v2.1.1 — 2026-08-05

### Build
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
4 changes: 2 additions & 2 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Payload records live in `core` under

| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — every run takes the paragraph's style, so per-run styling and `linkTarget` are dropped |
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; `linkTarget` is still dropped |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ |
| Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ❌ |
Expand All @@ -75,7 +75,7 @@ Payload records live in `core` under
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ |
| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `<a:alpha>` via POI on every surface — fills, strokes, text runs, table paint | ❌ |
| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ⚠️ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline; `STRIKETHROUGH` is dropped) |
| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) |

## Navigation and interactivity

Expand Down
15 changes: 7 additions & 8 deletions render-docx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,16 @@ the boundary and without the transform. A document that draws exports its text a
not its drawing.

What maps: paragraphs, lists, block images, tables, and document metadata (title, author,
subject, keywords). Run styling carries font family, size, colour, bold, italic and
underline.
subject, keywords). Run styling carries font family, size, colour, bold, italic,
underline and strikethrough, per run rather than per paragraph.

What maps only in part:

- **Table cells keep their text, not their structure.** `colSpan` and `rowSpan` are not
applied, so a table with merged cells exports with its columns misaligned. Per-cell
style and fill/border paint are dropped, and cell text carries no run styling.
style and fill/border paint are dropped, and a `table` cell's text carries no styling
at all — it is written from the cell's lines rather than from runs. (A `row` cell is
a paragraph and does keep per-run styling.)
- **Image fit is ignored.** The picture is embedded at the node's width and height;
`CONTAIN` and `COVER` therefore behave as `STRETCH`, and an image sized only by `scale`
falls back to 100 × 100 pt.
Expand All @@ -65,11 +67,8 @@ before you promise a `.docx` to a reader:
- **Hyperlinks are dropped**, external and internal alike; the link text survives as plain text.
- **No bookmarks and no navigation outline.**
- **No repeating headers or footers**, and no watermark layer.
- **`STRIKETHROUGH` is dropped** — the other decorations map.
- **No barcodes or QR codes**, and no inline images or code/badge chips inside a paragraph.
- **Per-run styling in a mixed-style paragraph is flattened.** Every run in a `RichText`
paragraph is written with the paragraph's style, so a bold or accent-coloured segment
loses its own styling; the text itself is kept.
- **No barcodes or QR codes**, and no inline images or code/badge chips inside a paragraph
— a chip's text is exported with its own styling, but not its background.
- **Output is not byte-deterministic**: rendering twice does not produce identical files.

Multi-section documents are a separate case: `renderSections` is declared on the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.ImageNode;
import com.demcha.compose.document.node.PageBreakNode;
import com.demcha.compose.document.node.InlineTextRun;
import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.RowNode;
import com.demcha.compose.document.node.SectionNode;
Expand Down Expand Up @@ -295,16 +296,35 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node)
private void writeParagraph(XWPFDocument document, ParagraphNode node) {
XWPFParagraph para = document.createParagraph();
para.setAlignment(toAlignment(node.align()));
if (!node.inlineTextRuns().isEmpty()) {
node.inlineTextRuns().forEach(run -> {
XWPFRun docRun = para.createRun();
applyStyle(docRun, node.textStyle());
docRun.setText(run.text());
});
} else {
writeParagraphRuns(para, node);
}

/**
* Writes {@code node}'s text into {@code para}, one Word run per inline run.
*
* <p>A run's own style is used and the paragraph's is the fallback, which is the
* contract {@link InlineTextRun} states: its style
* "falls back to the paragraph style when null". Applying the fallback to every run
* regardless is what flattened a bold segment, an accent-coloured segment and plain
* text into one identical face.</p>
*
* <p>Runs win over {@code text} when both are present, matching how a paragraph is
* rendered elsewhere. Nothing is lost by preferring them: when {@code text} is left
* blank {@code ParagraphNode} fills it by concatenating exactly the runs
* {@code inlineTextRuns()} returns, highlight chips included.</p>
*/
private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node) {
List<InlineTextRun> runs = node.inlineTextRuns();
if (runs.isEmpty()) {
XWPFRun docRun = para.createRun();
applyStyle(docRun, node.textStyle());
docRun.setText(node.text() == null ? "" : node.text());
return;
}
for (InlineTextRun run : runs) {
XWPFRun docRun = para.createRun();
applyStyle(docRun, run.textStyle() == null ? node.textStyle() : run.textStyle());
docRun.setText(run.text() == null ? "" : run.text());
}
}

Expand Down Expand Up @@ -377,10 +397,9 @@ private void writeRow(XWPFDocument document, RowNode node) {

private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) {
if (child instanceof ParagraphNode paragraph) {
XWPFParagraph para = cell.addParagraph();
XWPFRun run = para.createRun();
applyStyle(run, paragraph.textStyle());
run.setText(paragraph.text() == null ? "" : paragraph.text());
// Same walk as writeParagraph: a cell paragraph keeps per-run styling
// instead of being flattened into the concatenated text in one style.
writeParagraphRuns(cell.addParagraph(), paragraph);
} else if (child instanceof SpacerNode) {
cell.addParagraph();
} else {
Expand Down Expand Up @@ -426,6 +445,8 @@ private void applyStyle(XWPFRun run, DocumentTextStyle style) {
}
case UNDERLINE ->
run.setUnderline(org.apache.poi.xwpf.usermodel.UnderlinePatterns.SINGLE);
case STRIKETHROUGH -> run.setStrikeThrough(true);
// DEFAULT carries no face of its own and is the only one left.
default -> {
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.demcha.compose.document.backend.semantic.docx;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.UnderlinePatterns;
import org.junit.jupiter.api.Test;

import java.io.ByteArrayInputStream;
import java.util.List;
import java.util.function.Consumer;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Per-run styling in the DOCX semantic backend.
*
* <p>An {@code InlineTextRun} carries its own style and falls back to the paragraph's
* only when it has none. Applying the paragraph style to every run instead produced a
* valid {@code .docx} in which a bold segment, an accent-coloured segment and plain text
* were indistinguishable — no error, no warning, just a document missing the emphasis it
* was told to carry. These pin the run's own style reaching the file, in a paragraph and
* inside a table cell.</p>
*/
class DocxRunStyleTest {

private static final DocumentTextStyle BASE = DocumentTextStyle.builder().size(11).build();
private static final DocumentTextStyle BOLD = DocumentTextStyle.builder()
.size(11).decoration(DocumentTextDecoration.BOLD).build();
private static final DocumentTextStyle ACCENT = DocumentTextStyle.builder()
.size(11).color(DocumentColor.rgb(192, 57, 43)).build();

@Test
void eachRunKeepsItsOwnStyleRatherThanTheParagraphFallback() throws Exception {
List<XWPFRun> runs = paragraphRuns(flow -> flow.addParagraph(paragraph -> paragraph
.textStyle(BASE)
.inlineText("plain ")
.inlineText("bold ", BOLD)
.inlineText("accent", ACCENT)));

assertThat(runs).hasSize(3);
assertThat(runs.get(0).getText(0)).isEqualTo("plain ");
assertThat(runs.get(0).isBold()).isFalse();

assertThat(runs.get(1).getText(0)).isEqualTo("bold ");
assertThat(runs.get(1).isBold()).isTrue();

assertThat(runs.get(2).getText(0)).isEqualTo("accent");
assertThat(runs.get(2).isBold()).isFalse();
assertThat(runs.get(2).getColor()).isEqualToIgnoringCase("C0392B");
}

@Test
void aRunWithoutItsOwnStyleTakesTheParagraphStyle() throws Exception {
// The fallback InlineTextRun documents — it must survive the fix that stopped
// applying it unconditionally.
List<XWPFRun> runs = paragraphRuns(flow -> flow.addParagraph(paragraph -> paragraph
.textStyle(BOLD)
.inlineText("inherits")));

assertThat(runs).hasSize(1);
assertThat(runs.get(0).isBold()).isTrue();
}

@Test
void strikethroughReachesTheDocument() throws Exception {
List<XWPFRun> runs = paragraphRuns(flow -> flow.addParagraph(paragraph -> paragraph
.textStyle(BASE)
.inlineText("struck", DocumentTextStyle.builder()
.size(11).decoration(DocumentTextDecoration.STRIKETHROUGH).build())));

assertThat(runs).hasSize(1);
assertThat(runs.get(0).isStrikeThrough()).isTrue();
}

@Test
void theRemainingDecorationsAlsoTravelPerRun() throws Exception {
List<XWPFRun> runs = paragraphRuns(flow -> flow.addParagraph(paragraph -> paragraph
.textStyle(BASE)
.inlineText("i", decorated(DocumentTextDecoration.ITALIC))
.inlineText("u", decorated(DocumentTextDecoration.UNDERLINE))
.inlineText("bi", decorated(DocumentTextDecoration.BOLD_ITALIC))));

assertThat(runs).hasSize(3);
assertThat(runs.get(0).isItalic()).isTrue();
assertThat(runs.get(0).isBold()).isFalse();
assertThat(runs.get(1).getUnderline()).isEqualTo(UnderlinePatterns.SINGLE);
assertThat(runs.get(2).isBold()).isTrue();
assertThat(runs.get(2).isItalic()).isTrue();
}

@Test
void aCodeChipCarriesItsOwnGlyphStyleRatherThanTheParagraphs() throws Exception {
// A chip is lowered to a text run holding the chip's style, so per-run styling
// means the chip's monospace face now reaches Word. Its background does not.
List<XWPFRun> runs = paragraphRuns(flow -> flow.addParagraph(paragraph -> paragraph
.textStyle(BASE)
.inlineText("call ")
.inlineCode("run()")));

assertThat(runs).hasSize(2);
assertThat(runs.get(0).getFontFamily()).isEqualTo("Helvetica");
assertThat(runs.get(1).getText(0)).isEqualTo("run()");
assertThat(runs.get(1).getFontFamily()).isEqualTo("Courier");
}

@Test
void aTableCellKeepsPerRunStylingAndLosesNoText() throws Exception {
byte[] docx = export(flow -> flow.addRow(row -> row
.addParagraph(paragraph -> paragraph
.textStyle(BASE)
.inlineText("plain ")
.inlineText("bold", BOLD))));

try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
assertThat(document.getTables()).hasSize(1);
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
List<XWPFRun> runs = cell.getParagraphs().get(0).getRuns();

assertThat(runs).hasSize(2);
assertThat(runs.get(0).isBold()).isFalse();
assertThat(runs.get(1).isBold()).isTrue();
// The cell used to be written from the concatenated text in one style.
// Splitting it into runs must not change what the cell says.
assertThat(cell.getText()).isEqualTo("plain bold");
}
}

private static DocumentTextStyle decorated(DocumentTextDecoration decoration) {
return DocumentTextStyle.builder().size(11).decoration(decoration).build();
}

private static List<XWPFRun> paragraphRuns(Consumer<com.demcha.compose.document.dsl.PageFlowBuilder> spec)
throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(export(spec)))) {
List<XWPFParagraph> paragraphs = document.getParagraphs().stream()
.filter(paragraph -> !paragraph.getRuns().isEmpty())
.toList();
assertThat(paragraphs).hasSize(1);
return paragraphs.get(0).getRuns();
}
}

private static byte[] export(Consumer<com.demcha.compose.document.dsl.PageFlowBuilder> spec)
throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(595, 842)
.margin(DocumentInsets.of(36))
.create()) {
com.demcha.compose.document.dsl.PageFlowBuilder flow = session.dsl().pageFlow().name("Flow");
spec.accept(flow);
flow.build();
return session.export(new DocxSemanticBackend());
}
}
}
Loading