diff --git a/CHANGELOG.md b/CHANGELOG.md index c6dd112c..bc46e28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 6fc1905d..2169cd98 100644 Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 3008b103..07ef4766 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -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) | ❌ | @@ -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 `` 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 diff --git a/render-docx/README.md b/render-docx/README.md index a98e423b..37b568dc 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -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. @@ -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 diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index a38c12b9..9139e22d 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -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; @@ -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. + * + *

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.

+ * + *

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.

+ */ + private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node) { + List 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()); } } @@ -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 { @@ -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 -> { } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java new file mode 100644 index 00000000..df0a93a0 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java @@ -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. + * + *

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.

+ */ +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 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 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 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 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 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 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 paragraphRuns(Consumer spec) + throws Exception { + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(export(spec)))) { + List paragraphs = document.getParagraphs().stream() + .filter(paragraph -> !paragraph.getRuns().isEmpty()) + .toList(); + assertThat(paragraphs).hasSize(1); + return paragraphs.get(0).getRuns(); + } + } + + private static byte[] export(Consumer 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()); + } + } +}