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 87b71a6e..3f0c4bb4 100644 Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ diff --git a/assets/readme/examples/word-export-companion.pdf b/assets/readme/examples/word-export-companion.pdf index d49d1c5b..4010f491 100644 Binary files a/assets/readme/examples/word-export-companion.pdf and b/assets/readme/examples/word-export-companion.pdf differ 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); + } + } +}