From b27d9d70d0a3a65780d0945ba6db3bc75139a0d7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 15:35:34 +0100 Subject: [PATCH 1/2] feat(docx): keep an inline chip's fill in the exported Word file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 20 ++ .../document/node/InlineHighlightRun.java | 6 +- .../compose/document/node/InlineRun.java | 3 +- .../document/style/InlineBackground.java | 4 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 29 +++ .../semantic/docx/DocxSemanticBackend.java | 202 +++++++++++++++-- .../docx/DocxInlineBackgroundTest.java | 211 ++++++++++++++++++ 8 files changed, 452 insertions(+), 25 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b38bf383e..947041411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **An inline chip keeps its fill in the exported Word file.** 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, and the document + lost the part of itself that was doing the talking. 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 quantizes to sixteen named colours, which is + not a brand palette. +

+ 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 record of loss that lists what did not lose anything cannot be read. +

+ A `w:shd` fill 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, so 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, so the two agree rather than one of them guessing. + - **An exported Word document has an outline.** A reader opened a twenty-page export and found one flat run of paragraphs: nothing in the Navigation Pane, nothing in the outline view, and a generated table of contents that came back empty. Word builds all three from diff --git a/core/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java b/core/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java index b3676c923..0c4ee91d5 100644 --- a/core/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java +++ b/core/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java @@ -11,8 +11,10 @@ * a GitHub-style inline {@code code} span or a status badge. * *

Unlike the image/shape/SVG runs it is text: it wraps with the - * surrounding line. The background is a PDF decoration — text-only backends keep - * the text and drop the fill (see {@link ParagraphNode#inlineTextRuns()}).

+ * surrounding line. Its background is drawn where a backend can draw one and + * written as run shading where a backend has that instead; the reduction to text + * runs keeps only what every backend can write, which is the text + * (see {@link ParagraphNode#inlineTextRuns()}).

* * @param text visible text for the run * @param textStyle style for the glyphs; falls back to the paragraph style when {@code null} diff --git a/core/src/main/java/com/demcha/compose/document/node/InlineRun.java b/core/src/main/java/com/demcha/compose/document/node/InlineRun.java index 9fe17454c..a31f99eef 100644 --- a/core/src/main/java/com/demcha/compose/document/node/InlineRun.java +++ b/core/src/main/java/com/demcha/compose/document/node/InlineRun.java @@ -67,7 +67,8 @@ static String plainText(List runs) { * {@link #plainText(List)}, which concatenates a chip's text as authored.

* * @param runs runs in source order; {@code null} reads as empty - * @return the text-carrying runs, chips degraded to plain runs + * @return the text-carrying runs, chips reduced to their text — a caller that + * can carry a chip's fill reads it from the authored run instead * @since 2.4.0 */ static List textRuns(List runs) { diff --git a/core/src/main/java/com/demcha/compose/document/style/InlineBackground.java b/core/src/main/java/com/demcha/compose/document/style/InlineBackground.java index d98f23064..cf1e9ecab 100644 --- a/core/src/main/java/com/demcha/compose/document/style/InlineBackground.java +++ b/core/src/main/java/com/demcha/compose/document/style/InlineBackground.java @@ -6,7 +6,9 @@ * Background "chip" behind an inline run: a rounded, padded fill drawn beneath * the glyphs on the text baseline — e.g. a GitHub-style inline {@code code} * highlight. Backend-neutral: the PDF backend paints it as a filled rounded - * rectangle; a future text backend (DOCX) keeps the text and drops the fill. + * rectangle; the DOCX export writes the fill as Word's run shading, which has + * neither corners nor padding, and flattens a translucent one against what sits + * under it because that shading is opaque. * *

Horizontal padding widens the run's advance (it reserves space and counts * toward line wrapping). Vertical padding expands the chip outside the diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a0f01a7cf..bb34b8145 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -56,7 +56,7 @@ Payload records live in `core` under |---|---|---|---| | 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; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's | | List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used | -| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | +| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — which is the composite the PDF makes of the same chip | | 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) | ❌ | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback; gradient paints use their primary colour) | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 4f7c1b865..13e3e49be 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -45,6 +45,7 @@ PDF never pull POI. | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with, and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | +| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below | | Images | Embedded pictures at the node's declared size | | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children) | @@ -198,6 +199,34 @@ Not representable, and left undone rather than approximated: - **A table inside a painted container.** The table keeps its own cell fills and borders rather than inheriting the band. +## What a chip keeps and loses + +A chip is a fill behind a phrase, and Word has one: `w:shd` on the run, taking any RGB. +So a status badge still reads as a badge and an inline `code()` span still reads as code, +in a paragraph and inside a list item alike. + +```java +page.addParagraph(p -> p + .inlineText("Invoice ") + .inlineChip("overdue", DocumentColor.WHITE, accent) // fill lands + .inlineText(" — settle by Friday.")); +``` + +What Word has no way to say is the chip's *shape*. Shading covers the glyph box, so: + +- **The corner radius** is square in Word. +- **The padding** that widens the run on the page is not in the file, so the fill hugs + the glyphs and the line is fractionally shorter than the PDF's. + +Both are recorded as `APPROXIMATED` in the export report, per chip, so a caller can see +which phrase lost what. + +A `w:shd` fill is opaque, so a translucent chip — `inlineCode(...)` is a fifth-opacity +grey — is flattened first against what the export wrote underneath it: the paragraph's own +shading, the cell's, or the page. That is the same composite the PDF makes, so the two +agree instead of one of them guessing; written at full strength the default code chip +would be a solid slab where the page has a tint. + ## What falls back - **A document the engine cannot lay out → the same export, without measured geometry.** 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 17f73513a..7708b586b 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 @@ -42,6 +42,7 @@ import com.demcha.compose.document.style.DocumentRowColumn; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.InlineBackground; import com.demcha.compose.document.table.DocumentTableCell; import com.demcha.compose.document.table.DocumentTableStyle; import com.demcha.compose.font.FontFamilyDefinition; @@ -810,7 +811,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style, applyStyle(leading, style); leading.setText(" ".repeat(depth) + (marker.isRich() ? "" : marker.prefix())); if (marker.isRich()) { - writeInlineTextRuns(para, style, marker.runs()); + writeInlineTextRuns(para, style, marker.runs(), path); // The gap after a marker is markerGap, which is geometry and so not // available here; a space is what separates a marker from its item on // the text path, and it separates them here for the same reason. @@ -821,7 +822,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style, } } if (item.isRich()) { - writeInlineTextRuns(para, style, item.runs()); + writeInlineTextRuns(para, style, item.runs(), path); } else { XWPFRun label = para.createRun(); applyStyle(label, style); @@ -831,14 +832,21 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style, /** * Appends one Word run per text-carrying inline run, each in its own style - * and falling back to {@code style} when it has none. + * and falling back to {@code style} when it has none — and, for a chip, on the + * fill it was given: a badge inside a list item is a badge for the same reason + * it is one inside a paragraph. */ private void writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle style, - List runs) { - for (InlineTextRun run : com.demcha.compose.document.node.InlineRun.textRuns(runs)) { + List runs, String path) { + for (InlineRun run : runs) { + InlineTextRun text = textOf(run); + if (text == null) { + continue; + } XWPFRun docRun = para.createRun(); - applyStyle(docRun, run.textStyle() == null ? style : run.textStyle()); - docRun.setText(run.text() == null ? "" : run.text()); + applyStyle(docRun, text.textStyle() == null ? style : text.textStyle()); + applyInlineBackground(docRun, backgroundOf(run), path); + docRun.setText(text.text() == null ? "" : text.text()); } } @@ -1508,29 +1516,183 @@ private static void applyDirection(XWPFParagraph para, boolean rightToLeft) { * *

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.

+ * blank {@code ParagraphNode} fills it by concatenating exactly the runs that carry + * text, highlight chips included. A paragraph whose only runs carry no text — an image, + * a shape — still falls back to {@code text}, which is the whole of what it reads.

+ * + *

The runs are walked as the document authored them rather than as the reduction to + * text runs hands them back: the reduction answers what to write, and a chip is more + * than its text. Its fill is read from the authored run beside the reduced one.

*/ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean rightToLeft) { warnDroppedInlineRuns(node); - List runs = node.inlineTextRuns(); - if (runs.isEmpty()) { + String path = layout.pathOf(node); + boolean wroteARun = false; + for (InlineRun run : node.inlineRuns()) { + InlineTextRun text = textOf(run); + if (text == null) { + continue; + } + // A run's own link wins over the paragraph's: a sentence with one linked phrase + // in it is the ordinary case, and the paragraph's link is the fallback for the + // rest of that sentence rather than something the phrase overrides away. + DocumentLinkTarget target = text.linkTarget() != null ? text.linkTarget() : node.linkTarget(); + XWPFRun docRun = newRun(para, target); + applyStyle(docRun, text.textStyle() == null ? node.textStyle() : text.textStyle()); + applyRunDirection(docRun, rightToLeft); + applyInlineBackground(docRun, backgroundOf(run), path); + docRun.setText(text.text() == null ? "" : text.text()); + wroteARun = true; + } + if (!wroteARun) { XWPFRun docRun = newRun(para, node.linkTarget()); applyStyle(docRun, node.textStyle()); applyRunDirection(docRun, rightToLeft); docRun.setText(node.text() == null ? "" : node.text()); + } + } + + /** + * The text-carrying form of one inline run, or null for a run that carries no text. + * + *

Asks the one reduction — {@link InlineRun#textRuns} — about a single run rather + * than repeating its rules here, so a chip's text arrives normalized exactly as it is + * everywhere else. The runs are walked in their authored form because the reduction + * answers what to write and drops what only the chip knows: its fill.

+ */ + private static InlineTextRun textOf(InlineRun run) { + List lowered = InlineRun.textRuns(List.of(run)); + return lowered.isEmpty() ? null : lowered.get(0); + } + + /** The chip behind a run, or null for a run that is not one. */ + private static InlineBackground backgroundOf(InlineRun run) { + return run instanceof InlineHighlightRun highlight ? highlight.background() : null; + } + + /** + * Shades a run with the chip its author put behind it. + * + *

An inline {@code code} span and a status badge both exported as bare text: the + * reduction to text runs keeps the glyphs and drops the fill, and nothing downstream + * put it back. A chip that carries meaning — a red badge reading "overdue" — came out + * the same colour as the sentence around it.

+ * + *

Word shades a run with {@code w:shd}, which takes any RGB. Its highlighter pen + * ({@code w:highlight}) is the other candidate and takes one of sixteen named colours, + * which no brand palette is a member of — a chip written with it is whichever of the + * sixteen was nearest, and reads as text someone marked up rather than as design.

+ * + *

A {@code w:shd} fill is opaque, and the chip this sugar reaches for most — + * {@code code(...)} — is a fifth-opacity grey. Written at full strength it is a solid + * slab where the page has a tint, so a translucent fill is flattened first against what + * Word paints underneath it: the paragraph's own shading, the cell's, or the page. That + * is the same composite the PDF makes, so the two agree rather than one of them + * guessing.

+ * + *

What Word cannot express is the chip's shape. Shading covers the glyph + * box, so the rounded corners and the padding that widens the run on the page are not + * in the file. Both are recorded rather than quietly approximated.

+ */ + private void applyInlineBackground(XWPFRun run, InlineBackground background, String path) { + if (background == null || background.fill() == null) { return; } - for (InlineTextRun run : runs) { - // A run's own link wins over the paragraph's: a sentence with one linked phrase - // in it is the ordinary case, and the paragraph's link is the fallback for the - // rest of that sentence rather than something the phrase overrides away. - DocumentLinkTarget target = run.linkTarget() != null ? run.linkTarget() : node.linkTarget(); - XWPFRun docRun = newRun(para, target); - applyStyle(docRun, run.textStyle() == null ? node.textStyle() : run.textStyle()); - applyRunDirection(docRun, rightToLeft); - docRun.setText(run.text() == null ? "" : run.text()); + CTRPr properties = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); + // Run properties are a repeating choice in the schema, so this is an array and + // addNewShd() appends rather than replacing: asked twice, a run would carry two + // shadings and Word would read whichever it met first. + CTShd shading = properties.sizeOfShdArray() > 0 + ? properties.getShdArray(0) + : properties.addNewShd(); + shading.setVal(STShd.CLEAR); + shading.setColor("auto"); + shading.setFill(toHexColor(flatten(background.fill().color(), colourUnder(run)))); + String lost = chipShapeLost(background); + if (lost != null) { + if (warnedNodeKinds.add("inline-background")) { + LOG.warn("DocxSemanticBackend: an inline chip keeps its fill as run shading, " + + "but Word shades the glyph box — {}. (One warning per export.)", lost); + } + report.add(DocxExportReport.Severity.APPROXIMATED, "inline chip", path, + "the fill is written as run shading; " + lost); + } + } + + /** What a chip loses on the way to run shading, or null when the mapping is exact. */ + private static String chipShapeLost(InlineBackground background) { + List lost = new ArrayList<>(2); + if (background.cornerRadius() > 0) { + lost.add("its rounded corners are square"); + } + if (background.padding() != null + && (background.padding().horizontal() > 0 || background.padding().vertical() > 0)) { + lost.add("its padding is not in the file"); + } + return lost.isEmpty() ? null : String.join(", ", lost); + } + + /** + * The colour Word will paint under {@code run} — the shading this export itself wrote + * on the run's paragraph or on the cell holding it, and otherwise the page's white. + * + *

Read back from the file being written rather than tracked in a field, so it is + * whatever was actually written and cannot drift from it.

+ */ + private java.awt.Color colourUnder(XWPFRun run) { + XWPFParagraph para = run.getParagraph(); + java.awt.Color paragraphFill = para == null || !para.getCTP().isSetPPr() + ? null + : hexFillOf(para.getCTP().getPPr().isSetShd() ? para.getCTP().getPPr().getShd() : null); + if (paragraphFill != null) { + return paragraphFill; + } + java.awt.Color cellFill = currentCell == null + ? null + : hexFillOf(cellProperties(currentCell).isSetShd() + ? cellProperties(currentCell).getShd() : null); + return cellFill != null ? cellFill : java.awt.Color.WHITE; + } + + /** + * A shading's fill as a colour, or null when it is unset or Word's own {@code auto}. + * + *

The schema's hex colour is a union, and XmlBeans hands a written one back as the + * three bytes rather than as the string it was set from — read as text it is an array's + * identity, which parses as no colour at all and silently flattens against white.

+ */ + private static java.awt.Color hexFillOf(CTShd shading) { + Object fill = shading == null ? null : shading.getFill(); + if (fill == null) { + return null; + } + String hex = fill instanceof byte[] bytes + ? java.util.HexFormat.of().formatHex(bytes) + : String.valueOf(fill).trim(); + if (!hex.matches("(?i)[0-9a-f]{6}")) { + return null; } + return new java.awt.Color(Integer.parseInt(hex, 16)); + } + + /** + * Composites a colour over what sits beneath it, so a translucent fill survives a + * format that has no alpha. An opaque colour is returned untouched. + */ + private static java.awt.Color flatten(java.awt.Color colour, java.awt.Color under) { + int alpha = colour.getAlpha(); + if (alpha >= 255) { + return colour; + } + double weight = alpha / 255.0; + return new java.awt.Color( + blend(colour.getRed(), under.getRed(), weight), + blend(colour.getGreen(), under.getGreen(), weight), + blend(colour.getBlue(), under.getBlue(), weight)); + } + + private static int blend(int over, int under, double weight) { + return (int) Math.round(over * weight + under * (1 - weight)); } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java new file mode 100644 index 000000000..4f50ced9b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java @@ -0,0 +1,211 @@ +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.dsl.PageFlowBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.node.DocumentLinkOptions; +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.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An inline chip is a fill behind a phrase, and Word has one. + * + *

An inline {@code code} span and a status badge both exported as bare text: the + * reduction to text runs keeps the glyphs and drops the chip, so a red badge reading + * "overdue" came out the same colour as the sentence around it — a document losing the + * part of itself that was doing the talking.

+ * + *

Word shades a run with {@code w:shd}, which takes any RGB. What it cannot express is + * the chip's shape: shading covers the glyph box, so the rounded corners and the padding go + * and the export says so. A {@code w:shd} fill is also opaque, so a translucent chip is + * flattened against what Word paints beneath it first.

+ * + * @author Artem Demchyshyn + */ +class DocxInlineBackgroundTest { + + private static final DocumentColor BADGE = DocumentColor.rgb(214, 56, 56); + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + + @Test + void aChipsFillBecomesTheRunsShading() throws Exception { + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p + .inlineText("Status: ") + .inlineHighlight("overdue", DocumentTextStyle.DEFAULT, BADGE, + 0, DocumentInsets.zero())))) { + + assertThat(fillOf(runReading(document, "overdue"))) + .as("an opaque fill is written exactly as the document gave it") + .isEqualTo("D63838"); + assertThat(fillOf(runReading(document, "Status: "))) + .as("the sentence around it is not shaded") + .isNull(); + } + } + + @Test + void aTranslucentChipIsFlattenedAgainstThePage() throws Exception { + // The chip this sugar reaches for most is a fifth-opacity grey, and w:shd has no + // alpha. Written at full strength it is a solid slab where the page has a tint. + // 175/184/193 at 20% over white is 239/241/243 — the same composite the PDF makes. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()")))) { + + assertThat(fillOf(runReading(document, "render()"))).isEqualTo("EFF1F3"); + } + } + + @Test + void aChipInsideAShadedCardIsFlattenedAgainstTheCard() throws Exception { + // Flattening against the page would be flattening against something that is not + // under it: this export writes the card's fill as the paragraph's own shading, and + // Word paints the run's over that. + try (XWPFDocument document = exported(page -> page + .addSection("Card", card -> card + .fillColor(SURFACE) + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()"))))) { + + // 175/184/193 at 20% over 238/243/249 is 225/231/238. + assertThat(fillOf(runReading(document, "render()"))).isEqualTo("E1E7EE"); + } + } + + @Test + void aChipKeepsItsTextAndItsStyle() throws Exception { + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p + .inlineText("Run ") + .inlineHighlight("build", DocumentTextStyle.builder() + .color(DocumentColor.WHITE).size(9).build(), + BADGE, 0, DocumentInsets.zero()) + .inlineText(" first")))) { + + XWPFRun chip = runReading(document, "build"); + assertThat(chip.getColor()).as("the chip's own ink survives beside its fill").isEqualTo("FFFFFF"); + assertThat(document.getParagraphs().get(0).getText()) + .as("the sentence still reads as one") + .isEqualTo("Run build first"); + } + } + + @Test + void aLinkedChipIsStillALink() throws Exception { + // The chip and the link are written by different parts of the run path, and the + // one that knows about the fill must not be the one that forgets the address. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.inlineHighlight("docs", DocumentTextStyle.DEFAULT, + BADGE, 0, DocumentInsets.zero(), + new DocumentLinkOptions("https://graphcompose.dev"))))) { + + assertThat(fillOf(runReading(document, "docs"))).isEqualTo("D63838"); + assertThat(document.getParagraphs().get(0).getCTP().getHyperlinkList()) + .as("a chip carrying a link is exported as a hyperlink") + .hasSize(1); + } + } + + @Test + void aChipInsideAListItemIsAChip() throws Exception { + // A badge in a bulleted list is a badge for the same reason it is one in a + // paragraph: the list path writes its own runs and used to write them plain. + try (XWPFDocument document = exported(page -> page + .addList(list -> list + .bullet() + .addItem(rich -> rich.plain("Invoice ").highlight("overdue", + DocumentTextStyle.DEFAULT, BADGE, 0, DocumentInsets.zero()))))) { + + assertThat(fillOf(runReading(document, "overdue"))).isEqualTo("D63838"); + } + } + + @Test + void theShapeAChipLosesIsRecorded() throws Exception { + DocxExportReport report = reportOf(page -> page + .addParagraph(p -> p.inlineText("Call ").inlineCode("render()"))); + + assertThat(report.count(DocxExportReport.Severity.DROPPED)).isZero(); + assertThat(report.bySubject()).containsKey("inline chip"); + DocxExportReport.Note note = report.bySubject().get("inline chip").get(0); + assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + assertThat(note.detail()) + .as("the fill is written; what goes is the shape around it") + .contains("rounded corners") + .contains("padding"); + } + + @Test + void aSquareChipWithNoPaddingLosesNothingAndSaysNothing() throws Exception { + // The report is a record of loss. A chip Word can hold exactly must not appear in + // it, or a caller reading the report cannot tell the two cases apart. + DocxExportReport report = reportOf(page -> page + .addParagraph(p -> p.inlineHighlight("overdue", DocumentTextStyle.DEFAULT, + BADGE, 0, DocumentInsets.zero()))); + + assertThat(report.isEmpty()).isTrue(); + } + + @Test + void aParagraphWhoseRunsCarryNoTextStillReadsAsItsText() throws Exception { + // The runs are walked in their authored form now, and an image run carries no text + // at all. The paragraph's own text is what it reads, and it must still be written. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p.text("The whole line")))) { + + assertThat(document.getParagraphs().get(0).getText()).isEqualTo("The whole line"); + } + } + + private static String fillOf(XWPFRun run) { + if (!run.getCTR().isSetRPr() || run.getCTR().getRPr().sizeOfShdArray() == 0) { + return null; + } + CTShd shading = run.getCTR().getRPr().getShdArray(0); + // XmlBeans hands a written hex colour back as its three bytes, not as the string + // it was set from. + Object fill = shading.getFill(); + return fill instanceof byte[] bytes + ? java.util.HexFormat.of().withUpperCase().formatHex(bytes) + : null; + } + + private static XWPFRun runReading(XWPFDocument document, String text) { + for (XWPFParagraph para : document.getParagraphs()) { + for (XWPFRun run : para.getRuns()) { + if (text.equals(run.text())) { + return run; + } + } + } + throw new AssertionError("no run reading '" + text + "' among " + + document.getParagraphs().stream().flatMap(p -> p.getRuns().stream()) + .map(r -> "'" + r.text() + "'").toList()); + } + + private static XWPFDocument exported(Consumer content) throws Exception { + return DocxExports.withLayout(400, 600, 20, content); + } + + private static DocxExportReport reportOf(Consumer content) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 600) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(content::accept); + session.export(new DocxSemanticBackend(captured::set)); + } + return captured.get(); + } +} From 15d734d854eda373b72d33c033e061e4a828b7fb Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 20:29:26 +0100 Subject: [PATCH 2/2] review-fix(docx): read the colour under a chip without painting the cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 9 +- .../semantic/docx/DocxSemanticBackend.java | 54 +++++---- .../docx/DocxInlineBackgroundTest.java | 103 +++++++++++++++++- 5 files changed, 146 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 947041411..d87d160a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ follow semantic versioning; release dates are ISO 8601. A `w:shd` fill 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, so 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, so the two agree rather than one of them guessing. + underneath it: the paragraph's shading, the cell's, or the page. The chip then agrees + with the file it is in, which on a white page is the composite the PDF makes of the same + chip. What it stops being is translucent — recolour what is under it in Word and the + chip no longer follows — so that is recorded too. - **An exported Word document has an outline.** A reader opened a twenty-page export and found one flat run of paragraphs: nothing in the Navigation Pane, nothing in the outline diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index bb34b8145..7da0b78b9 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -56,7 +56,7 @@ Payload records live in `core` under |---|---|---|---| | 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; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's | | List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used | -| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — which is the composite the PDF makes of the same chip | +| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest | | 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) | ❌ | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback; gradient paints use their primary colour) | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 13e3e49be..32a8ec03c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -223,9 +223,12 @@ which phrase lost what. A `w:shd` fill is opaque, so a translucent chip — `inlineCode(...)` is a fifth-opacity grey — is flattened first against what the export wrote underneath it: the paragraph's own -shading, the cell's, or the page. That is the same composite the PDF makes, so the two -agree instead of one of them guessing; written at full strength the default code chip -would be a solid slab where the page has a tint. +shading, the cell's, or the page. Written at full strength the default code chip would be +a solid slab where the page has a tint; flattened, it is the colour the PDF shows. The +chip agrees with the file it is in rather than with the page the PDF drew — a translucent +*container* fill lands opaque too, and a chip on it composites over that. And the chip +stops being translucent: shade that paragraph another colour in Word and it keeps the +tint it was flattened to. Recorded, like the other two. ## What falls back 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 7708b586b..323b47732 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 @@ -1586,29 +1586,31 @@ private static InlineBackground backgroundOf(InlineRun run) { *

A {@code w:shd} fill is opaque, and the chip this sugar reaches for most — * {@code code(...)} — is a fifth-opacity grey. Written at full strength it is a solid * slab where the page has a tint, so a translucent fill is flattened first against what - * Word paints underneath it: the paragraph's own shading, the cell's, or the page. That - * is the same composite the PDF makes, so the two agree rather than one of them - * guessing.

+ * Word paints underneath it: the paragraph's own shading, the cell's, or the page. The + * chip then agrees with the file it is in — including where that file already differs + * from the page, since a translucent container fill lands opaque too. What it + * stops being is translucent: recoloured underneath in Word, the chip no longer + * follows.

* *

What Word cannot express is the chip's shape. Shading covers the glyph * box, so the rounded corners and the padding that widens the run on the page are not - * in the file. Both are recorded rather than quietly approximated.

+ * in the file. All three are recorded rather than quietly approximated.

*/ private void applyInlineBackground(XWPFRun run, InlineBackground background, String path) { - if (background == null || background.fill() == null) { + if (background == null) { return; } CTRPr properties = run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(); - // Run properties are a repeating choice in the schema, so this is an array and - // addNewShd() appends rather than replacing: asked twice, a run would carry two - // shadings and Word would read whichever it met first. + // w:shd sits in a repeating choice in the schema, so the accessor is an array and + // addNewShd() appends rather than replaces — a run carrying two shadings leaves + // Word reading whichever it meets first. CTShd shading = properties.sizeOfShdArray() > 0 ? properties.getShdArray(0) : properties.addNewShd(); shading.setVal(STShd.CLEAR); shading.setColor("auto"); shading.setFill(toHexColor(flatten(background.fill().color(), colourUnder(run)))); - String lost = chipShapeLost(background); + String lost = chipLost(background); if (lost != null) { if (warnedNodeKinds.add("inline-background")) { LOG.warn("DocxSemanticBackend: an inline chip keeps its fill as run shading, " @@ -1620,15 +1622,21 @@ private void applyInlineBackground(XWPFRun run, InlineBackground background, Str } /** What a chip loses on the way to run shading, or null when the mapping is exact. */ - private static String chipShapeLost(InlineBackground background) { - List lost = new ArrayList<>(2); + private static String chipLost(InlineBackground background) { + List lost = new ArrayList<>(3); if (background.cornerRadius() > 0) { lost.add("its rounded corners are square"); } - if (background.padding() != null - && (background.padding().horizontal() > 0 || background.padding().vertical() > 0)) { + if (background.padding().horizontal() > 0 || background.padding().vertical() > 0) { lost.add("its padding is not in the file"); } + if (background.fill().color().getAlpha() < 255) { + // The colour on the page is right. What is gone is the translucency itself: + // shade the paragraph a different colour in Word and a chip that was a tint + // over it stays the tint it was flattened to. + lost.add("its fill is flattened against what sits under it, because run " + + "shading is opaque"); + } return lost.isEmpty() ? null : String.join(", ", lost); } @@ -1637,20 +1645,26 @@ private static String chipShapeLost(InlineBackground background) { * on the run's paragraph or on the cell holding it, and otherwise the page's white. * *

Read back from the file being written rather than tracked in a field, so it is - * whatever was actually written and cannot drift from it.

+ * whatever was actually written and cannot drift from it. Read, and only read: + * {@code cellProperties} would create the {@code w:tcPr} it cannot find, so an + * unstyled cell holding a chip would come away carrying an empty one.

*/ private java.awt.Color colourUnder(XWPFRun run) { XWPFParagraph para = run.getParagraph(); - java.awt.Color paragraphFill = para == null || !para.getCTP().isSetPPr() + CTPPr paragraphProperties = para == null || !para.getCTP().isSetPPr() ? null - : hexFillOf(para.getCTP().getPPr().isSetShd() ? para.getCTP().getPPr().getShd() : null); + : para.getCTP().getPPr(); + java.awt.Color paragraphFill = shadingFillOf( + paragraphProperties != null && paragraphProperties.isSetShd() + ? paragraphProperties.getShd() : null); if (paragraphFill != null) { return paragraphFill; } - java.awt.Color cellFill = currentCell == null + CTTcPr cellProperties = currentCell == null || !currentCell.getCTTc().isSetTcPr() ? null - : hexFillOf(cellProperties(currentCell).isSetShd() - ? cellProperties(currentCell).getShd() : null); + : currentCell.getCTTc().getTcPr(); + java.awt.Color cellFill = shadingFillOf( + cellProperties != null && cellProperties.isSetShd() ? cellProperties.getShd() : null); return cellFill != null ? cellFill : java.awt.Color.WHITE; } @@ -1661,7 +1675,7 @@ private java.awt.Color colourUnder(XWPFRun run) { * three bytes rather than as the string it was set from — read as text it is an array's * identity, which parses as no colour at all and silently flattens against white.

*/ - private static java.awt.Color hexFillOf(CTShd shading) { + private static java.awt.Color shadingFillOf(CTShd shading) { Object fill = shading == null ? null : shading.getFill(); if (fill == null) { return null; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java index 4f50ced9b..43e5f8f61 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java @@ -6,13 +6,22 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.TextDirection; import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; 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.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.junit.jupiter.api.Test; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -82,6 +91,40 @@ void aChipInsideAShadedCardIsFlattenedAgainstTheCard() throws Exception { } } + @Test + void aChipInsideAShadedCellIsFlattenedAgainstTheCell() throws Exception { + // The cell carries the fill, not the paragraph inside it, so this is the branch + // that would silently flatten against white and look almost right. + try (XWPFDocument document = exported(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Call"), + DocumentTableCell.node(codeParagraph()) + .withStyle(DocumentTableStyle.builder().fillColor(SURFACE).build()))))) { + + // 175/184/193 at 20% over 238/243/249 is 225/231/238. + assertThat(fillOf(runReading(document, "render()"))).isEqualTo("E1E7EE"); + } + } + + @Test + void readingWhatIsUnderAChipPaintsNothing() throws Exception { + // POI's cell-properties accessor creates the w:tcPr it cannot find, so asking an + // unpainted cell what colour it is would leave an empty one behind. + try (XWPFDocument document = exported(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Call"), + DocumentTableCell.node(codeParagraph()))))) { + + assertThat(fillOf(runReading(document, "render()"))) + .as("nothing underneath it, so the page's white") + .isEqualTo("EFF1F3"); + assertThat(document.getTables().get(0).getRow(0).getTableCells().stream() + .noneMatch(cell -> cell.getCTTc().isSetTcPr())) + .as("a cell nobody painted stays unpainted") + .isTrue(); + } + } + @Test void aChipKeepsItsTextAndItsStyle() throws Exception { try (XWPFDocument document = exported(page -> page @@ -120,9 +163,13 @@ void aLinkedChipIsStillALink() throws Exception { void aChipInsideAListItemIsAChip() throws Exception { // A badge in a bulleted list is a badge for the same reason it is one in a // paragraph: the list path writes its own runs and used to write them plain. + // hangingIndent, because an item made of runs is laid out only with the marker + // column — without it this document cannot be laid out at all, and the assertion + // would be proving the chip on the export's no-layout fallback instead. try (XWPFDocument document = exported(page -> page .addList(list -> list .bullet() + .hangingIndent(true) .addItem(rich -> rich.plain("Invoice ").highlight("overdue", DocumentTextStyle.DEFAULT, BADGE, 0, DocumentInsets.zero()))))) { @@ -145,6 +192,37 @@ void theShapeAChipLosesIsRecorded() throws Exception { .contains("padding"); } + @Test + void aFlattenedFillIsRecordedEvenWhenTheColourIsRight() throws Exception { + // The colour on the page is right, so it is tempting to call this lossless. What + // is gone is the translucency: shade that paragraph another colour in Word and a + // chip that was a tint over it stays the tint it was flattened to. + DocxExportReport report = reportOf(page -> page + .addParagraph(p -> p.inlineHighlight("overdue", DocumentTextStyle.DEFAULT, + BADGE.withOpacity(0.3), 0, DocumentInsets.zero()))); + + assertThat(report.bySubject().get("inline chip").get(0).detail()) + .contains("flattened"); + } + + @Test + void aChipInARightToLeftParagraphKeepsBothItsFillAndItsDirection() throws Exception { + // The direction and the shading are written into the same w:rPr by two different + // calls, and the second must not be the one that discards the first. + try (XWPFDocument document = exported(page -> page + .addParagraph(p -> p + .direction(TextDirection.RTL) + .inlineHighlight("דחוף", DocumentTextStyle.DEFAULT, BADGE, + 0, DocumentInsets.zero())))) { + + XWPFRun chip = runReading(document, "דחוף"); + assertThat(fillOf(chip)).isEqualTo("D63838"); + assertThat(chip.getCTR().getRPr().sizeOfRtlArray()) + .as("the run is still declared right-to-left") + .isPositive(); + } + } + @Test void aSquareChipWithNoPaddingLosesNothingAndSaysNothing() throws Exception { // The report is a record of loss. A chip Word can hold exactly must not appear in @@ -167,6 +245,14 @@ void aParagraphWhoseRunsCarryNoTextStillReadsAsItsText() throws Exception { } } + /** A paragraph carrying the default code chip, for a cell to be built from. */ + private static com.demcha.compose.document.node.DocumentNode codeParagraph() { + return new com.demcha.compose.document.dsl.ParagraphBuilder() + .name("Call") + .inlineCode("render()") + .build(); + } + private static String fillOf(XWPFRun run) { if (!run.getCTR().isSetRPr() || run.getCTR().getRPr().sizeOfShdArray() == 0) { return null; @@ -181,7 +267,7 @@ private static String fillOf(XWPFRun run) { } private static XWPFRun runReading(XWPFDocument document, String text) { - for (XWPFParagraph para : document.getParagraphs()) { + for (XWPFParagraph para : everyParagraph(document)) { for (XWPFRun run : para.getRuns()) { if (text.equals(run.text())) { return run; @@ -189,10 +275,23 @@ private static XWPFRun runReading(XWPFDocument document, String text) { } } throw new AssertionError("no run reading '" + text + "' among " - + document.getParagraphs().stream().flatMap(p -> p.getRuns().stream()) + + everyParagraph(document).stream().flatMap(p -> p.getRuns().stream()) .map(r -> "'" + r.text() + "'").toList()); } + /** Body paragraphs and the ones inside table cells, which the body list leaves out. */ + private static List everyParagraph(XWPFDocument document) { + List paragraphs = new ArrayList<>(document.getParagraphs()); + for (XWPFTable table : document.getTables()) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + paragraphs.addAll(cell.getParagraphs()); + } + } + } + return paragraphs; + } + private static XWPFDocument exported(Consumer content) throws Exception { return DocxExports.withLayout(400, 600, 20, content); }