From 9ada0e7d6e5a937fca6893ce2f9e62b8a6192b11 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 9 Aug 2026 20:59:06 +0100
Subject: [PATCH] fix(docx): paint a table the way it was styled
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DocumentTableStyle carries a fill and a stroke, and neither reached the file: a
zebra body, a header band and a ruled grid all exported on Word's defaults. The
fill maps to w:shd and the stroke to w:tcBorders, and the cascade that already
resolved a cell's text style now resolves every field on its own — so a
table-wide rule survives a row that only overrides the fill.
A merged cell is painted on every position it covers: a w:vMerge continuation
draws its own shading, and leaving it unpainted would stripe the region.
A stroke of no width is this codebase's way of saying "no border", and the
fixed-layout handler reads the same predicate as draw-nothing. Writing nothing
would leave the cell on the grid Word puts on a table by default, so a
deliberately borderless design — MintEditorial uses exactly this — would export
ruled. The cell now says it has none. A table that says nothing about borders
still keeps Word's default: the look belongs to Word where the document did not
claim it.
What a fill loses is its opacity. w:shd is opaque, and blending it needs a
background this backend does not resolve.
The Word companion example styles its table, so the feature ships with a render
behind it. Both of its previews move — the DOCX for the new markup, the PDF
because the fixed-layout backend paints a style it was never given before.
---
CHANGELOG.md | 16 ++
README.md | 2 +-
.../examples/word-export-companion.docx | Bin 8011 -> 8181 bytes
.../readme/examples/word-export-companion.pdf | Bin 12915 -> 12969 bytes
.../architecture/backend-capability-matrix.md | 2 +-
.../features/docx/WordExportExample.java | 12 +-
render-docx/README.md | 17 +-
.../semantic/docx/DocxSemanticBackend.java | 102 +++++++++-
.../semantic/docx/DocxTablePaintTest.java | 176 ++++++++++++++++++
9 files changed, 312 insertions(+), 15 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTablePaintTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c1cd196..f697f8bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -115,6 +115,22 @@ follow semantic versioning; release dates are ISO 8601.
position at fault, where before it was drawn wrong. That is the rule the layout pipeline
already applied, so a document the PDF backend refuses is no longer one DOCX accepts.
+- **A DOCX table is painted the way it was styled.** `DocumentTableStyle` carries a fill and
+ a stroke, and neither reached the file: a zebra body, a header band and a ruled grid all
+ exported on Word's defaults. The fill maps to `w:shd` and the stroke to `w:tcBorders`, and
+ the cascade that already resolved a cell's text style now resolves every field on its own,
+ so a table-wide rule survives a row that only overrides the fill. A merged cell is painted
+ on every position it covers, since a `w:vMerge` continuation draws its own shading and
+ would otherwise stripe the region. A stroke of no width — how this codebase says "no
+ border", and what a shipped CV preset uses — writes that instruction rather than omitting
+ it, so a borderless design no longer inherits the grid Word puts on a table by default.
+ What a fill loses is its opacity: `w:shd` is opaque, and blending it needs a background
+ Word owns rather than the backend.
+
+ The Word companion example styles its table, so the feature ships with a render behind it
+ — and both of its committed previews move, the DOCX for the new markup and the PDF because
+ the fixed-layout backend paints the same style it was never given before.
+
- **A DOCX image is the size it asked for, in the shape it asked for.** The drawn box came
from the node's literal `width` and `height` and fell back to a hardcoded 100 × 100 pt
when either was absent, so an image sized only by `scale` — or by one dimension with the
diff --git a/README.md b/README.md
index e5c66710..7864d3da 100644
--- a/README.md
+++ b/README.md
@@ -335,7 +335,7 @@ See [CONTRIBUTING](./CONTRIBUTING.md) for the branch-routing table and the full
| Format | Status | Notes |
|---|---|---|
| PDF | Production | Fixed-layout backend on PDFBox 3.0. Full DSL coverage. |
-| DOCX | Partial | Semantic export via Apache POI — paragraphs, lists, block images, tables and metadata. Word owns the flow, so drawing nodes (`shape`, `line`, `ellipse`, `barcode`) are dropped, one logged warning per kind. Tables keep their `colSpan`/`rowSpan` and images their fit mode; **hyperlinks, bookmarks and headers/footers are not implemented**, and table fill and border paint are dropped — see [render-docx](./render-docx/README.md#what-it-maps-and-what-it-does-not). |
+| DOCX | Partial | Semantic export via Apache POI — paragraphs, lists, block images, tables and metadata. Word owns the flow, so drawing nodes (`shape`, `line`, `ellipse`, `barcode`) are dropped, one logged warning per kind. Tables keep their `colSpan`/`rowSpan`, their fill and their borders, and images their fit mode; **hyperlinks, bookmarks and headers/footers are not implemented** — see [render-docx](./render-docx/README.md#what-it-maps-and-what-it-does-not). |
| PPTX | Beta | Fixed-layout export via Apache POI from the same resolved layout — one page per editable slide with native shapes and text frames; clipped regions land as pixel-exact pictures. First shipped in 2.1, marked `@Beta` while the API shape settles. |
### Text & internationalization
diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx
index 87b71a6ed7a36016b02d556e5c4815e998b06a92..3f0c4bb44c967749880ae8433efcac3793083514 100644
GIT binary patch
delta 2446
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
Nk`idn3FPnA{{V%)Q+faZ
delta 2210
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
delta 1596
zcmbu*>p#;A0D$qzTysk#sWX{Tt;EbWWMa&0Z)b$sI<{7@}AT?BxQZ@
z_cNdQogms|4ib9JxCK8((@nCxYmv)M1syLXhHgh4`Be{v)Hdj?kJI(dc1z=}Sht{_%MjyhXw6LDVUJrXW1M26%D(L%H;0-@FHd
zZqjf+)^Iu@eLgZ9p7hN4Y|?|(z~z;Tp$kpj^4^fIPl4MW8nUId4Y`9P8SIQV=EF(d
z6SfwQO7S(@Tv{dXk#9f5E{3F6{v6I9@lp$c=*uOhvN*KR)Bu-xwayIpLQuVdJ&+O@
zF4-Md5I1&o5^X0}JP66DOu&Rm9EEQp*6=nfMcp(Q0KB)#rQ-(D!co@+R<7r15Xro}
zXNoin5Kf_yK4(;bq&xVIQ~|lOl+Xh%@{$km?cK(fc^&K5X=jBs8f-JNh5H%yX=-Ek
z3_zl^cgl^Q{eu(9z3|6J8`@182g@vr7Ird)7-nkS2Ed8eMV9#pqfI%US
z{&;K%32ilTmIq=JtK6w~0?2B2`QEekKraeMU;o`pb>3A*AoZL{-kWP5v8>Tg#&wbZhXye
zt}lTz&r7?KQ&q)@5mqNAhMGz6ZXqN)b1?;2|YXt{F5*x=zJ3YMRQ9UBKZw
zeS+d{@%S?s|A!gq`e%8!%J{NwMB<3>8@LB7l&ya(Nq!5@ZDIJ
zq@LyhVXzJXt6|#QD%E-(F#bCds?vAk!b2)(z^Ol?_%T*p##zq1Oud0jmuAS_c_)V;
zAcarsxL5K^jWar}X(P5U|1Nibvz(dmh3Q!3xTq#_@pzun1(%!%n^%ny5@IRiiVmj!
z>8JVWFpX$tntUZE{F+<*u_kK!%z1`~Ms+jt(g>zn!QrIJZ{1)Zq_!-x`m@UJViBF<
zrffu@Pjj-a9CPR}FibBtEFgN>pB;l}Ll?%ZpK%eE1#DncsWD3uI^(?(r2
zZx%lHQ7M3JaB5?6YQcZDP~6&d{xB{@S9Or-P!meUVHgmflw#AyAzZ*rHFI&
zVNlTk{VSw=Nq4)}Nzq5$i#O7CrM6WKw*;ZHN)EWXdhNa0=i+bM-oG`0bv*Hh^U&+Q
zUG&QnJHbQ4#*s=W{3G;1Go{Pf-YTS*0f5$t0x9ya+Lz0QuM@vEC2
zJ>9ygaZ<(CYGRUziiF!G+H2^OepSR2)=nZ0K1pyq8QN5E+?sT(oKG
z2>4m4`o?><5^w)shRqfPiVRvu(mW9FWLmMbx|HL9h1+iNo*3K9^W|~`hi>gZ5elm(
zWG38Kobxi9Iybo{qo-z3nb}BNZqa+m(Tc);>@8k4`8`W(&OBAEE7qzbj=tWor)%@5
zbLyvl3qUS7kiwJXux08)E9;3)39o8b72RQLk!*KMFn~fhJ6oesC<}xW8sP-DwzhC~
zvOrm4|1{hNi?p(~Mxw0XIJlD|9EnDu;f`=etQ8hvX@NyJBjJ`d7^8jvhXDO+uxBiN
z_v?v;s^=|qL9gFZ`^$|;`0DJOeDae+?(cvz>eK76fhIFXMC9Auu3!)SyroPn_C6dS>#e
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index c8c47ebf..35e72e7b 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -69,7 +69,7 @@ Payload records live in `core` under
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ |
-| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; fill and border paint are not applied, and a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty) |
+| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders`; a composed cell writes paragraphs and their wrappers only — one built from an image or a list lands empty, and a fill's opacity is dropped since `w:shd` is opaque) |
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ |
diff --git a/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java b/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java
index 3413d57f..f97bfe87 100644
--- a/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java
+++ b/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java
@@ -13,6 +13,7 @@
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableStyle;
import com.demcha.compose.document.table.DocumentTableColumn;
import com.demcha.compose.font.FontName;
import com.demcha.examples.support.ExampleOutputPaths;
@@ -133,11 +134,20 @@ public static Path generate() throws Exception {
.addItem("Two spaces of indent per depth in Word", l2 -> l2
.addItem("Custom markers survive the export"))))
- .addParagraph("Tables stay tables", heading)
+ .addParagraph("Tables stay tables, and keep their paint", heading)
.addTable(t -> t
.columns(DocumentTableColumn.auto(),
DocumentTableColumn.auto(),
DocumentTableColumn.auto())
+ // The rule reaches Word as w:tcBorders and the header band as
+ // w:shd, so the exported table reads the way it was designed
+ // rather than on Word's defaults.
+ .defaultCellStyle(DocumentTableStyle.builder()
+ .stroke(new DocumentStroke(DocumentColor.rgb(205, 212, 219), 0.75))
+ .build())
+ .rowStyle(0, DocumentTableStyle.builder()
+ .fillColor(DocumentColor.rgb(232, 238, 243))
+ .build())
.headerRow("Quarter", "Revenue", "Profit")
.row("Q1", "42", "12")
.row("Q2", "55", "17")
diff --git a/render-docx/README.md b/render-docx/README.md
index 7b45593b..d9345b0a 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -57,12 +57,17 @@ picture.
What maps only in part:
-- **Table cells keep their structure, not their paint.** `colSpan` and `rowSpan` map to
- Word's own `w:gridSpan` and `w:vMerge`, and a cell's text takes the most specific style
- in the table / column / row / cell cascade. Still dropped: the fill and border paint of a
- `DocumentTableStyle`, so a merged, styled table exports with the right shape on Word's
- default rules. A composed cell writes the shapes a cell can hold — paragraphs, and the
- wrappers around them — so one built from an image or a list still lands empty.
+- **Table cells keep their structure and their paint.** `colSpan` and `rowSpan` map to
+ Word's own `w:gridSpan` and `w:vMerge`; a cell's fill maps to `w:shd` and its stroke to
+ `w:tcBorders`; and text, fill and stroke each take the most specific value in the
+ table / column / row / cell cascade, resolved per field, so a table-wide rule survives a
+ row that only overrides the fill. A stroke of no width is read as "no border" and says so
+ in the file, so a deliberately borderless design does not inherit the grid Word puts on a
+ table; a table that says nothing about borders keeps that grid, Word owning the look it
+ was not given. What a fill loses is its opacity — `w:shd` is opaque,
+ and blending it would need a background Word owns rather than this backend. A composed
+ cell writes the shapes a cell can hold — paragraphs, and the wrappers around them — so
+ one built from an image or a list still lands empty.
These are **not implemented** even though Word itself can express them — check the list
before you promise a `.docx` to a reader:
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 2712236c..8d82794e 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
@@ -27,6 +27,8 @@
import com.demcha.compose.document.node.SpacerNode;
import com.demcha.compose.document.node.TableNode;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.table.DocumentTableCell;
import com.demcha.compose.document.table.DocumentTableStyle;
@@ -42,11 +44,16 @@
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -58,6 +65,7 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import java.util.function.Function;
import java.util.concurrent.atomic.AtomicBoolean;
/**
@@ -535,6 +543,11 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception
TableGrid.Placement placement = physical.get(i);
XWPFTableCell cell = row.getCell(i);
applySpans(cell, placement, rowIdx);
+ // The covered positions of a merge take the paint too, so a merged
+ // region reads as one cell rather than as a striped run of them.
+ applyCellPaint(cell,
+ resolveCellValue(node, placement, DocumentTableStyle::fillColor),
+ resolveCellValue(node, placement, DocumentTableStyle::stroke));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
continue;
@@ -549,9 +562,7 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r
if (placement.colSpan() == 1 && placement.rowSpan() == 1) {
return;
}
- CTTcPr properties = cell.getCTTc().isSetTcPr()
- ? cell.getCTTc().getTcPr()
- : cell.getCTTc().addNewTcPr();
+ CTTcPr properties = cellProperties(cell);
if (placement.colSpan() > 1) {
properties.addNewGridSpan().setVal(BigInteger.valueOf(placement.colSpan()));
}
@@ -561,6 +572,72 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r
}
}
+ /**
+ * Paints a cell with the fill and the edges its style asks for.
+ *
+ * A {@link DocumentTableStyle} carries a {@code fillColor} and a {@code stroke}, and
+ * neither reached the file: a zebra body, a header band and a ruled grid all exported on
+ * Word's defaults, which is to say with no fill and no borders. Word owns both —
+ * {@code w:shd} for the fill and {@code w:tcBorders} for the four edges — so this is
+ * mapping rather than approximation.
+ *
+ * What does not survive is transparency. A {@code w:shd} fill is opaque, so a colour
+ * carrying an opacity below 1 lands at full strength; the alternative would be blending it
+ * against a background this backend does not resolve, Word owning the flow.
+ */
+ private void applyCellPaint(XWPFTableCell cell, DocumentColor fill, DocumentStroke stroke) {
+ if (fill == null && stroke == null) {
+ return;
+ }
+ CTTcPr properties = cellProperties(cell);
+ if (fill != null) {
+ CTShd shading = properties.isSetShd() ? properties.getShd() : properties.addNewShd();
+ shading.setVal(STShd.CLEAR);
+ shading.setFill(toHexColor(fill.color()));
+ }
+ if (stroke != null) {
+ CTTcBorders borders = properties.isSetTcBorders()
+ ? properties.getTcBorders()
+ : properties.addNewTcBorders();
+ if (stroke.width() > 0) {
+ // w:sz counts eighths of a point, and rounds to at least one so a hairline
+ // the author asked for stays a line rather than disappearing.
+ BigInteger eighths = BigInteger.valueOf(
+ Math.max(1, Math.round(stroke.width() * 8.0)));
+ String colour = toHexColor(stroke.color().color());
+ paintEdge(borders.addNewTop(), STBorder.SINGLE, eighths, colour);
+ paintEdge(borders.addNewBottom(), STBorder.SINGLE, eighths, colour);
+ paintEdge(borders.addNewLeft(), STBorder.SINGLE, eighths, colour);
+ paintEdge(borders.addNewRight(), STBorder.SINGLE, eighths, colour);
+ } else {
+ // A stroke of no width is how this codebase says "no border" — the fixed-layout
+ // handler reads the same predicate as draw-nothing. Writing nothing here would
+ // leave the cell on the table's default grid, so a deliberately borderless
+ // design would export ruled. The cell says none of its own instead.
+ paintEdge(borders.addNewTop(), STBorder.NIL, null, null);
+ paintEdge(borders.addNewBottom(), STBorder.NIL, null, null);
+ paintEdge(borders.addNewLeft(), STBorder.NIL, null, null);
+ paintEdge(borders.addNewRight(), STBorder.NIL, null, null);
+ }
+ }
+ }
+
+ private static void paintEdge(CTBorder edge, STBorder.Enum kind, BigInteger eighths, String colour) {
+ edge.setVal(kind);
+ if (eighths != null) {
+ edge.setSz(eighths);
+ }
+ if (colour != null) {
+ edge.setColor(colour);
+ }
+ }
+
+ private static CTTcPr cellProperties(XWPFTableCell cell) {
+ return cell.getCTTc().isSetTcPr()
+ ? cell.getCTTc().getTcPr()
+ : cell.getCTTc().addNewTcPr();
+ }
+
private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, TableNode node)
throws Exception {
DocumentTableCell source = placement.cell();
@@ -590,14 +667,27 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
* column's, then the row's, then the cell's own.
*/
private DocumentTextStyle resolveCellTextStyle(TableNode node, TableGrid.Placement placement) {
- DocumentTextStyle resolved = null;
+ return resolveCellValue(node, placement, DocumentTableStyle::textStyle);
+ }
+
+ /**
+ * Resolves one field of a cell's style, most specific wins.
+ *
+ * The cascade the layout pipeline merges in — the table's default, then the column's,
+ * then the row's, then the cell's own — applied per field rather than per style object, so
+ * a table-wide border survives a row that only overrides the fill.
+ */
+ private T resolveCellValue(TableNode node, TableGrid.Placement placement,
+ Function field) {
+ T resolved = null;
for (DocumentTableStyle candidate : List.of(
orEmpty(node.defaultCellStyle()),
orEmpty(node.columnStyles().get(placement.column())),
orEmpty(node.rowStyles().get(placement.row())),
orEmpty(placement.cell().style()))) {
- if (candidate.textStyle() != null) {
- resolved = candidate.textStyle();
+ T value = field.apply(candidate);
+ if (value != null) {
+ resolved = value;
}
}
return resolved;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTablePaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTablePaintTest.java
new file mode 100644
index 00000000..5b5acc60
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTablePaintTest.java
@@ -0,0 +1,176 @@
+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.TableBuilder;
+import com.demcha.compose.document.node.TableNode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+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.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
+
+import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Cell fill and borders in the DOCX semantic backend.
+ *
+ * A {@code DocumentTableStyle} carries a {@code fillColor} and a {@code stroke}, and neither
+ * reached the file: a zebra body, a header band and a ruled grid all exported on Word's
+ * defaults, which is to say with no fill and no borders. Word owns both — {@code w:shd} and
+ * {@code w:tcBorders} — so what these pin is a mapping, not an approximation.
+ */
+class DocxTablePaintTest {
+
+ private static final DocumentColor BAND = DocumentColor.rgb(20, 80, 95);
+ private static final DocumentColor RULE = DocumentColor.rgb(180, 40, 40);
+
+ @Test
+ void aCellFillReachesWordAsShading() throws Exception {
+ XWPFTable table = firstTable(new TableBuilder()
+ .name("Filled")
+ .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto())
+ .rowCells(DocumentTableCell.text("banded")
+ .withStyle(DocumentTableStyle.builder().fillColor(BAND).build()),
+ DocumentTableCell.text("plain"))
+ .build());
+
+ assertThat(shadingFill(table.getRow(0).getCell(0))).isEqualToIgnoringCase("14505F");
+ // A cell nothing painted keeps Word's default rather than being filled with black.
+ assertThat(table.getRow(0).getCell(1).getCTTc().getTcPr()).isNull();
+ }
+
+ @Test
+ void aStrokeBecomesFourBordersInEighthsOfAPoint() throws Exception {
+ XWPFTable table = firstTable(new TableBuilder()
+ .name("Ruled")
+ .columns(DocumentTableColumn.auto())
+ .rowCells(DocumentTableCell.text("ruled").withStyle(DocumentTableStyle.builder()
+ .stroke(new DocumentStroke(RULE, 1.0)).build()))
+ .build());
+
+ CTTcBorders borders = table.getRow(0).getCell(0).getCTTc().getTcPr().getTcBorders();
+ assertThat(borders).isNotNull();
+ for (var edge : List.of(borders.getTop(), borders.getBottom(),
+ borders.getLeft(), borders.getRight())) {
+ assertThat(edge.getVal()).isEqualTo(STBorder.SINGLE);
+ assertThat(edge.getSz()).isEqualTo(BigInteger.valueOf(8));
+ assertThat(hex(edge.getColor())).isEqualToIgnoringCase("B42828");
+ }
+ }
+
+ @Test
+ void theCascadeResolvesPerFieldRatherThanPerStyle() throws Exception {
+ // The table rules every cell; the row bands one. A per-object cascade would let the
+ // row's style replace the table's outright and the border would vanish with it.
+ XWPFTable table = firstTable(new TableBuilder()
+ .name("Cascade")
+ .columns(DocumentTableColumn.auto())
+ .defaultCellStyle(DocumentTableStyle.builder()
+ .stroke(new DocumentStroke(RULE, 1.0)).build())
+ .rowStyle(0, DocumentTableStyle.builder().fillColor(BAND).build())
+ .rowCells(DocumentTableCell.text("both"))
+ .build());
+
+ XWPFTableCell cell = table.getRow(0).getCell(0);
+ assertThat(shadingFill(cell)).isEqualToIgnoringCase("14505F");
+ assertThat(cell.getCTTc().getTcPr().getTcBorders()).isNotNull();
+ }
+
+ @Test
+ void aMergedCellIsPaintedOnEveryPositionItCovers() throws Exception {
+ // A vMerge continuation cell draws its own shading, so leaving it unpainted would
+ // stripe the merged region.
+ XWPFTable table = firstTable(new TableBuilder()
+ .name("MergedPaint")
+ .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto())
+ .rowCells(DocumentTableCell.text("tall")
+ .rowSpan(2)
+ .withStyle(DocumentTableStyle.builder().fillColor(BAND).build()),
+ DocumentTableCell.text("top"))
+ .rowCells(DocumentTableCell.text("bottom"))
+ .build());
+
+ assertThat(shadingFill(table.getRow(0).getCell(0))).isEqualToIgnoringCase("14505F");
+ assertThat(shadingFill(table.getRow(1).getCell(0))).isEqualToIgnoringCase("14505F");
+ }
+
+ @Test
+ void aHairlineStaysALineRatherThanRoundingAway() throws Exception {
+ XWPFTable table = firstTable(new TableBuilder()
+ .name("Hairline")
+ .columns(DocumentTableColumn.auto())
+ .rowCells(DocumentTableCell.text("thin").withStyle(DocumentTableStyle.builder()
+ .stroke(new DocumentStroke(RULE, 0.05)).build()))
+ .build());
+
+ // 0.05 pt is under an eighth; rounding it to zero would ask Word for a border of no
+ // width, which is a border nobody sees.
+ assertThat(table.getRow(0).getCell(0).getCTTc().getTcPr().getTcBorders().getTop().getSz())
+ .isEqualTo(BigInteger.ONE);
+ }
+
+ @Test
+ void aStrokeOfNoWidthSaysTheCellHasNoBorder() throws Exception {
+ // DocumentStroke.of(colour, 0) is how this codebase says "no border", and a shipped
+ // CV preset uses it. Writing nothing would leave the cell on the table grid POI
+ // creates, so a deliberately borderless design would export ruled — the opposite of
+ // what the fixed-layout backend draws from the same input.
+ XWPFTable table = firstTable(new TableBuilder()
+ .name("Borderless")
+ .columns(DocumentTableColumn.auto())
+ .rowCells(DocumentTableCell.text("clean").withStyle(DocumentTableStyle.builder()
+ .stroke(new DocumentStroke(DocumentColor.WHITE, 0)).build()))
+ .build());
+
+ CTTcBorders borders = table.getRow(0).getCell(0).getCTTc().getTcPr().getTcBorders();
+ assertThat(borders).isNotNull();
+ for (var edge : List.of(borders.getTop(), borders.getBottom(),
+ borders.getLeft(), borders.getRight())) {
+ assertThat(edge.getVal()).isEqualTo(STBorder.NIL);
+ }
+ }
+
+ private static String shadingFill(XWPFTableCell cell) {
+ return hex(cell.getCTTc().getTcPr().getShd().getFill());
+ }
+
+ /** XmlBeans hands an ST_HexColor back as bytes, so read it as the colour it encodes. */
+ private static String hex(Object value) {
+ if (value instanceof byte[] bytes) {
+ StringBuilder text = new StringBuilder();
+ for (byte part : bytes) {
+ text.append(String.format("%02X", part));
+ }
+ return text.toString();
+ }
+ return String.valueOf(value);
+ }
+
+ private static XWPFTable firstTable(TableNode node) throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ session.add(node);
+ docx = session.export(new DocxSemanticBackend());
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ List tables = document.getTables();
+ assertThat(tables).hasSize(1);
+ return tables.get(0);
+ }
+ }
+}