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 zcmZ8jc{tQ-8=f)N#$;s6XqXIR$}UktvV<_Ul6_)qgTdH2@iQ%st;7^UgvOq-WI477 zgRvY%iA1s$W1q4_U-_=w2&IdEV!Kp68GEkNak5v(CdTOd#xnAZ~7M(20DW zN|+lM@(Zhj!AB1QYBJlv?+5BNh!oI<`>X_y#kn%s}oYYcGVcpb_y?Xg76 zBDp%`P-^^>AEklHnx!AE{QN-=4T)N?4}Ya|ry6Q$8U*%waq7;5LPsvkOie_myxj6> zbv@iritKcCEPagQI`;r!ijyawZdJ0UyOa@iLi!6bG@&r%Ik}#kXMGI+$_9v}efLfb zOq)-y(zCf(nQEM|J%k;&RA5L0U`&*fy=x-U9%E+;-R@C|y}K`V6oZaM zL_|zz{mZ+FmAvb3M>kgx22PJgXoriB!t^in7)sR55Z1#h9iRH^YcV|2?X(Bi%R&D@cp?NK^USK7yV&kq-%4ZaFLp-?|8vB)$zZstyL*7vWC&IuLYmIgS0T=;@&ecC=CG%fIv6dK%jqp7&ZBr zFcr%I104DT$JO91VTCI(pJ*cE_XX{09~gIaoy2?{XA7PEEV2RUCdx#*|Z1 zw6;k?p>db~apT)NyjGSfr01ftxg13_o`|z|2E3kn@3goaPcAuoFF_{29~ELMAI$nY z*9eJ~7gguv?n=RzT}DKk8cff+4P%1Y%w(YKepKcY^`L}Efg6EJ@T4%66syuq!(ldF zj9_m#3ErT1Q#ATy*calDt%oVbL8b+1B5M@Zk8h`dXvJnXW`$`v#k0gkx2EZxCL%}{ zILf*vr)kzVq3p5D8{oZE!XHc?HGDm%H=x^`dRIx*_z|DY;)?;7(1KjPUMa$9!eA|) zSlWI{RF-(AVSo1Oy12yz8X`}S6u<>WeU{3?nYm98^}XpFahE?#B0&KSs(12}>NIH$ zpCbF3zqw<1MdRWGw1qfFUkKP@&2(LWfZm&XaRW2msSb~qax!FvCKrRlviDxtORDlr z<*w@3j&Zp&>vd#VL)*(sMaX%eS$FHQF)8BS4xd<{s~4|G@Q}(`lz*yGjP+1K$Gyem zR%T0kht+K%GzhAE#MfV)<6K#8_VA!u>Z|_GZFdh@J(aLI+~{Lgs)%0YOS-THsGK`N zS1Y_d=Xxr;-O9^=_nn*D2k zOD2K=>r&IG${Sv-SVKwMO*?l3ED0cm5ihkYyx@0FH2sak%LlL_=HtLtY~_X3$)6Zc zG~u{QkCVx==aA%^`*UmIVr@N))#at0dm=rppP-F z&e4l>bN}tS_nRU#lV@_x9t2;SKKsbc9gxJG#@(oMUue;c-mcnp)0XL+=~5s4_8omlk=vlHzm-zO9vq~jxRoJ;c4pi0 z2aml8L?wK1h%Gv={)0{g_8Gj5_6lt*?1+!_rA5oq3Y`+wR@V(#k++;!q<9*ZbS>JD zwoUShHt)UtLY#K-A=)>_io|h=wk?!}Z+5g@wA3iQ|C@E^#ay~DWlo^A=E&4J{?)yG zUkO=PeMbE8r~$XW%EzsId*uqYHX#3qPfpKb>IV5Qb`^9prGe=@o|68i3X_<2EY5VO za2hF5xsmF8hBav5{t~(DcFk$d z*>dkaMbqn~l0;Yy8s6osL7>0wY+|{7)S#zjwHM*!eG}v))B|L*pW89Y-rk*)Q4-8K zvqY3+GeqSn16#>U1O!w7l zmKm1tHEF&2IkUT)V&TMZU<4c2SD$G2j@U7?FgZ;q2j2I)GLq%@WpQ9*ny||o0&l9={p~h9GiV_gjhDl9l zozlBelZ9&RIka1rj6bcQevSxwr9m2JvkEb2?_6!2>im0G2J_?irE$OKzKtUR&+>O` zXXi=K%I_zQ{T#B#YfVF*-RQ5SC6e&djCbz=1NFqpYzHmIIrS7ad+{j^-ENtRnhGX2yzkn+r@yC#rNf^0>#f&je=NOq{{k z+xqzxM?*+`{Z(O*fVXLHu4NI*!b8&+e~PZPPreJDnB3_Mv%x;4DCIuLiCxitQd#S0 zuT@eXE~eW^b_^pWP4h^V#x)kU7vhU!C#}*oE{&|?5X(KP&j%Xot>%#;_HUef@%wwx z`x}^_b45-};FqXzaTCwi!m2qzAU?JK6S-A0muX;Wh1UmLl9%vg% z^?{1ZS(t#?1VR6^OHR{64qnrIAW)_h2qg5c1}+$NQXG9SwJh`d{lH(v>%Z>5*UKTP 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); + } + } +}