From 5099fda35f3bcd63e5fd0678a2881b6469d982fc Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 23 Sep 2026 20:52:02 +0100 Subject: [PATCH 1/2] fix(docx): align table cells where the page aligns them The export read neither half of a cell's textAnchor. Every column a template right-aligns or centres came out flush left, and since Word's default is the top of the cell while the engine's is the vertical middle, a single line beside a taller neighbour sat at the top of its row. The anchor, cascaded as the layout cascades it, is now w:vAlign on every cell and w:jc on a text cell's paragraph, with the engine's default when none is stated: middle left, or middle right for a right-to-left cell, and DEFAULT at the bottom left as the renderer draws it. A composed cell takes the vertical half only, as the engine places it. The committed word-export-companion.docx gains w:vAlign="center" on its 30 cells and nothing else. --- CHANGELOG.md | 12 ++ .../examples/word-export-companion.docx | Bin 9367 -> 9371 bytes .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 61 ++++++++ .../semantic/docx/DocxCellAnchorTest.java | 137 ++++++++++++++++++ 6 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af2b3492..2217acb4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A table cell's alignment reaches Word.** The DOCX export read neither half of a cell's + `textAnchor`: every column a template right-aligns or centres — amounts, quantities, + totals — came out flush left, and since Word's default is the top of the cell while the + engine's is the vertical middle, a single line beside a taller neighbour sat at the top of + its row. The anchor, cascaded as the layout cascades it, is now `w:vAlign` on every cell + and `w:jc` on a text cell's paragraph, with the engine's default when none is stated + (middle left, middle right for a right-to-left cell) and `DEFAULT` at the bottom left as + the renderer draws it. A composed cell takes the vertical half only, as the engine places + it. Measured in LibreOffice against the engine's render: a centred quantity, a + right-aligned amount and a bottom-right cell beside a three-line cell land as on the page, + and a right-to-left cell starts on the right. + - **A card is a card in Word.** The DOCX export painted a container's fill and borders onto each paragraph inside it, and Word has no element that wraps a run of paragraphs, so in an editor the card came apart: the accent bar broke beside every row and table inside it, the diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 1b1f416767795a16637dd203fa4c707e671b4c4d..aea32d9d7f399511c1408d56a579191d5670a5cd 100644 GIT binary patch delta 2805 zcmY+Gc{CK<8^>p+X-q<7Ut(sEZDb~6CzYfz3`3OtwT!VQ`}>L}2_fs)qO6%rrX(#S zW8c>rLQ+bIvG20|=$zkqd+#~-e(!yr@AKSy&i&_;fz8HpnBoqB`2bKT6yQiLqlv3g4 zF{>~O+C9?kJSuRcpvA0c$IR<~n6&hi;#%xP@^jyfne(OUjHr0>is5`8gm`~)@`Zur z#j+G^=4P8L-mp-nh|D=6i?Vi#*0DZm)p>NY?sQZRgYzCG=CipLp#jwy-PGE2F*5Ys zPM}(Pk9GF0U7_1F3PRs*{dv5UGLbj+ruj56DeH{0gTf(MZ?v?$wZp?!Qmi%Fg z=SoeKiYqG zVYAe!5Yc zs^WOisBjz%>U9l?oZ-AJ8oIi3I_CWY!zS?jjjl<~V+8z##*IhwJ0UunQ~!L;-P>{~ z6(A+U%J2Ba=RvNJ;uvcEnmWs2WPK{8Zo2Z4T(qY%oR|LXGJZQUi6DPC-!QyZYAxbo zAxkFHx0IW5<^o=7@q79$4|;rxbo*!!2U&U7F|g-BcK&Tq@VYdMkm zDHrF?CEo;|ixMEA$5uQ9m<)rAOgE*T=^IZ4IeXNIdpMX+!Wcu4S*A+<=cb&@RvYmq zvi;R^*7Vp}?@1n>qgF1e!*$Oo4$wHGy5)O;J2XwJ1&D3vvK=k?g^f4NfC6s-q{%( zkhrlty51AbZbv=uAwq1>H<2?_50`L`0h1HH`09m9k!2kDf{@w7^JGVB?a}&ufQR*HiC%y1IW3qf9 zzqVl?D@N$v@m2h@GnOxvp|`Io6i4htzG_uVrOMVVL8ww6A!u=(M&U)P6zQwliMZbK z;rds>1Z=`KgBTeN7;w{pKFR|<`b+838=z3#$|1BdZ=~yJR=jTvkRlep#|G{+0vpTr z)*4+fLoPNNS&~f+iw;j*7XmvVA-V+O_R*XvT2daNT2)PAU?QphN9HHm#H7J!2s}w8 z_H2ajnO()hh^%Q^+1)6a)$2zXPPP!`BF7H|p_Y0*A+2O%93xD^*PB|<8xVh`6_4vz zW=YWp0I*DONnKFI(G$&^a6!RliBZc1*f1!J100e@27%unCp|eEK2VDR4ClnWH0{(5 zY{n?K^wQedO1(o$zQ?*y+==|n{Bb`MtCV(v)>Fs^T_{GYxO42nl|`cJ7SK0OvAmrgydol!rP{Uuwi(6U>1 zNdh^5q7U7V_&Vse{H%j`D41W?+B|Herl@5*I@9=Xn#LeezT>pjdcO@!ea@+NQ%RZQ zn74(wEZJDs;JiV-bIL+&w_GmaVBcd+2`{d*m%d?p*`^PfqM=YRHR3hTGf zolzwx)Lk`PPefRC>2&(x`*@a^S_IOxSb0J{9p8fPK(l1=E0O*M2}uszaI(9G`DA7w zhil-7NVM}uLMH!=WBNfZ_e;#{hcy3AO-Q*tMy!0l`ul~dbxneQE25P_BPYFGM;6F@ zn{I5(fXfN6N2cDrlg0g+SG{L!Mt;<4CZNRzQ6${<>JlvjN*#P~l+!!&7-o2Cj z*{xrk%_QzYHOg29lQ8>{y_{Txy<`E2Mg!5 zq3C4jAZkCReons}}1xe+`k z(TqG%rmp(INTf}*3Uybw_jo3vY zMQ?>Gm7DcZmu^`7C@pzxHuZ*2>>c)&UtYwlhjxu=rK*0i?L#A=?!2zecLAkqx!H>Z z4zQMG`yv7TdIrEwbf+-idCr}Pawt^nVIM3N=2=5r?8YC{7RuWZ%@X{w- zOxRO`7y6Hp=Yve=?3-@(9o34q9m%tawyn-HV;h^xXF)fx=Xb>XR?iQK-D6{~^=s|t zMC~s}^L$a_vNH|vD{AyQ_@wU)|;P zimO_evV)IrYN4CcsOp-n8LRS)jkoCIT+3Yp$vY)Y>F0$ zt9&0u?AOK>%AwtXUzPvHbp`}=1b|clR_1-{_^Q_~u2${ldKpWNB55;7W@$x@L#W4q z0~zMJu`O{x3Cfw)VjFA#K=#%DLPEJCr~GfEi|3N$K0vz{;;z5Z?$lvHinJ}PXonA3ITy24uby!`$-G| delta 2780 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 same cascade the layout merges, and the same default when none states one: the + * engine's cell style sets text at the vertical middle, on the left — or on the right for + * a right-to-left cell, which the layout gives {@code CENTER_RIGHT} the same way. Word's + * default is the top left, so a cell left to it put a single line at the top of a row a + * taller neighbour had stretched, and an amount column the page right-aligns flush left.

+ */ + private DocumentTableTextAnchor resolveCellAnchor(TableNode node, TableGrid.Placement placement, + boolean rightToLeft) { + DocumentTableTextAnchor authored = resolveCellValue(node, placement, DocumentTableStyle::textAnchor); + if (authored != null) { + return authored; + } + return rightToLeft ? DocumentTableTextAnchor.CENTER_RIGHT : DocumentTableTextAnchor.CENTER_LEFT; + } + + /** The anchor for the vertical half, where direction does not matter. */ + private DocumentTableTextAnchor resolveCellAnchor(TableNode node, TableGrid.Placement placement) { + return resolveCellAnchor(node, placement, false); + } + + /** + * Writes where a cell's content sits vertically. + * + *

{@code DEFAULT} is the bottom edge, as the page places it: the engine maps it to + * {@code Anchor.defaultAnchor()}, whose vertical half the cell renderer and the composed + * cell both treat as the bottom. Top is Word's own default and is not written.

+ */ + private static void applyVerticalAnchor(XWPFTableCell cell, DocumentTableTextAnchor anchor) { + XWPFTableCell.XWPFVertAlign vertical = switch (anchor) { + case TOP_LEFT, TOP_RIGHT -> null; + case CENTER_LEFT, CENTER, CENTER_RIGHT -> XWPFTableCell.XWPFVertAlign.CENTER; + case BOTTOM_LEFT, BOTTOM_RIGHT, DEFAULT -> XWPFTableCell.XWPFVertAlign.BOTTOM; + }; + if (vertical != null) { + cell.setVerticalAlignment(vertical); + } + } + + /** + * The horizontal half of a cell's anchor, as the alignment the page draws a line with. + * {@code DEFAULT} is the left, as the cell renderer places it. + */ + private static TextAlign horizontalOf(DocumentTableTextAnchor anchor) { + return switch (anchor) { + case CENTER -> TextAlign.CENTER; + case CENTER_RIGHT, TOP_RIGHT, BOTTOM_RIGHT -> TextAlign.RIGHT; + case CENTER_LEFT, TOP_LEFT, BOTTOM_LEFT, DEFAULT -> TextAlign.LEFT; + }; + } + /** * Whether a cell's text runs right to left. * diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java new file mode 100644 index 000000000..7c6815d12 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java @@ -0,0 +1,137 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.node.TextDirection; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.document.table.DocumentTableTextAnchor; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A table cell's anchor — where the page places its content in the cell's box — reaches Word. + * + *

The export read neither half of it. Word's default is the top left and the engine's is + * the vertical middle on the left, so a single line beside a taller neighbour sat at the top + * of its row, and every column a template aligns right or centred — amounts, quantities, + * totals — came out flush left.

+ * + * @author Artem Demchyshyn + */ +class DocxCellAnchorTest { + + @Test + void aCellWithNoAnchorIsCentredVerticallyAndLeftAsOnThePage() throws Exception { + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .row("Plain"))); + + assertThat(cell.getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.CENTER); + assertThat(cell.getParagraphs().get(0).getCTP().getPPr() == null + || !cell.getParagraphs().get(0).getCTP().getPPr().isSetJc()) + .as("the left is where Word starts a line anyway") + .isTrue(); + } + + @Test + void eachAnchorMapsToItsWordAlignment() throws Exception { + assertAnchor(DocumentTableTextAnchor.CENTER_RIGHT, XWPFTableCell.XWPFVertAlign.CENTER, ParagraphAlignment.RIGHT); + assertAnchor(DocumentTableTextAnchor.CENTER, XWPFTableCell.XWPFVertAlign.CENTER, ParagraphAlignment.CENTER); + assertAnchor(DocumentTableTextAnchor.CENTER_LEFT, XWPFTableCell.XWPFVertAlign.CENTER, null); + assertAnchor(DocumentTableTextAnchor.TOP_LEFT, null, null); + assertAnchor(DocumentTableTextAnchor.TOP_RIGHT, null, ParagraphAlignment.RIGHT); + assertAnchor(DocumentTableTextAnchor.BOTTOM_LEFT, XWPFTableCell.XWPFVertAlign.BOTTOM, null); + assertAnchor(DocumentTableTextAnchor.BOTTOM_RIGHT, XWPFTableCell.XWPFVertAlign.BOTTOM, ParagraphAlignment.RIGHT); + // DEFAULT is Anchor.defaultAnchor() in the engine: the bottom left, as the renderer draws it. + assertAnchor(DocumentTableTextAnchor.DEFAULT, XWPFTableCell.XWPFVertAlign.BOTTOM, null); + } + + @Test + void theMostSpecificAnchorWins() throws Exception { + XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .columnStyle(1, DocumentTableStyle.builder().textAnchor(DocumentTableTextAnchor.CENTER_RIGHT).build()) + .row("Item", "120.00") + .rowCells(DocumentTableCell.text("Total"), + DocumentTableCell.text("150.00").withStyle(DocumentTableStyle.builder() + .textAnchor(DocumentTableTextAnchor.TOP_LEFT).build())))); + try (document) { + var table = document.getTables().get(0); + + assertThat(table.getRow(0).getCell(1).getParagraphs().get(0).getAlignment()) + .as("the column's") + .isEqualTo(ParagraphAlignment.RIGHT); + XWPFTableCell own = table.getRow(1).getCell(1); + assertThat(own.getVerticalAlignment()).as("the cell's own").isNull(); + assertThat(own.getParagraphs().get(0).getCTP().getPPr().isSetJc()).isFalse(); + } + } + + @Test + void aRightToLeftCellStartsOnTheRightAndAnAuthoredLeftIsItsEnd() throws Exception { + // Word reads jc left/right as the start and end of a bidi paragraph's flow. The + // engine's default for a right-to-left cell is the right, which is that flow's start. + XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().direction(TextDirection.RTL).build()) + .rowCells(DocumentTableCell.text("שלום"), + DocumentTableCell.text("עולם").withStyle(DocumentTableStyle.builder() + .textAnchor(DocumentTableTextAnchor.CENTER_LEFT).build())))); + try (document) { + var row = document.getTables().get(0).getRow(0); + + assertThat(row.getCell(0).getParagraphs().get(0).getCTP().getPPr().isSetJc()) + .as("the start of a right-to-left line is its default") + .isFalse(); + assertThat(row.getCell(1).getParagraphs().get(0).getAlignment()) + .as("the left edge is the end of a right-to-left line") + .isEqualTo(ParagraphAlignment.RIGHT); + } + } + + @Test + void aComposedCellTakesTheVerticalHalfOnly() throws Exception { + // The engine places a composed child vertically by the anchor and never across: the + // child is laid out at the cell's full inner width, so there is no slack to align in. + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().textAnchor(DocumentTableTextAnchor.BOTTOM_RIGHT).build()) + .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Composed").build())))); + + assertThat(cell.getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.BOTTOM); + assertThat(cell.getParagraphs().get(0).getAlignment()) + .as("the paragraph keeps its own alignment") + .isEqualTo(ParagraphAlignment.LEFT); + } + + private static void assertAnchor(DocumentTableTextAnchor anchor, + XWPFTableCell.XWPFVertAlign vertical, + ParagraphAlignment horizontal) throws Exception { + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().textAnchor(anchor).build()) + .row("Cell"))); + var properties = cell.getParagraphs().get(0).getCTP().getPPr(); + + assertThat(cell.getVerticalAlignment()).as(anchor + " vertically").isEqualTo(vertical); + if (horizontal == null) { + assertThat(properties == null || !properties.isSetJc()).as(anchor + " horizontally").isTrue(); + } else { + assertThat(cell.getParagraphs().get(0).getAlignment()).as(anchor + " horizontally").isEqualTo(horizontal); + } + } + + private static XWPFTableCell firstCell(Consumer content) throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, content)) { + return document.getTables().get(0).getRow(0).getCell(0); + } + } +} From 641cdf29e253abd82e3ad0d4bf81d4db260f2362 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 23 Sep 2026 21:20:03 +0100 Subject: [PATCH 2/2] fix(docx): read an auto cell's direction from the text the layout reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout flattens a break inside a cell's line to a space before it looks for the first strong character; the export read the raw line. A cell reading "123\nשלום" under TextDirection.AUTO was drawn right to left on the page and written left to right in Word, and its default alignment followed. The export now reads the lines the same way. Adds coverage for a row's anchor beating its column's, an anchor on every row a spanning cell covers, a right-to-left cell anchored at DEFAULT, and the auto direction above. Splits the recipe's Tables sentence in two. --- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 8 ++- .../semantic/docx/DocxCellAnchorTest.java | 67 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 6a3cc0440..7a6356e56 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -68,7 +68,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved | | 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. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it, and its `textAnchor` as `w:vAlign` and the paragraph's `w:jc` — the engine's default is the vertical middle on the left, where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned, and a cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one | +| Tables | Word tables, one cell per cell. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one | | 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 — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table — 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 | 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 0b7cdc11a..97e61f904 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 @@ -3419,7 +3419,13 @@ private boolean resolveCellDirection(TableNode node, TableGrid.Placement placeme if (declared == null) { return false; } - return ParagraphDirection.resolve(String.join("\n", lines), declared) == TextDirection.RTL; + // Read the lines as the layout reads them, a break inside a line flattened to a + // space, so the two decide the same direction from the same text. + List asLaidOut = new ArrayList<>(lines.size()); + for (String line : lines) { + asLaidOut.add(line == null ? "" : line.replace('\r', ' ').replace('\n', ' ')); + } + return ParagraphDirection.resolve(String.join("\n", asLaidOut), declared) == TextDirection.RTL; } /** diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java index 7c6815d12..42b674ca5 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellAnchorTest.java @@ -75,6 +75,73 @@ void theMostSpecificAnchorWins() throws Exception { } } + @Test + void aRowsAnchorBeatsItsColumns() throws Exception { + XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .columnStyle(0, DocumentTableStyle.builder().textAnchor(DocumentTableTextAnchor.CENTER_RIGHT).build()) + .rowStyle(0, DocumentTableStyle.builder().textAnchor(DocumentTableTextAnchor.TOP_LEFT).build()) + .row("Heading") + .row("120.00"))); + try (document) { + var table = document.getTables().get(0); + XWPFTableCell heading = table.getRow(0).getCell(0); + + assertThat(heading.getVerticalAlignment()).as("the row's top").isNull(); + assertThat(heading.getParagraphs().get(0).getCTP().getPPr().isSetJc()).as("the row's left").isFalse(); + assertThat(table.getRow(1).getCell(0).getParagraphs().get(0).getAlignment()) + .as("the column's, where no row says otherwise") + .isEqualTo(ParagraphAlignment.RIGHT); + } + } + + @Test + void aCellSpanningRowsCarriesItsAnchorOnEveryRowItCovers() throws Exception { + // Word takes a merged region's alignment from its first cell; the engine places the + // text in the whole region's height. Both covered positions say the same. + XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .rowCells(DocumentTableCell.text("Spans").rowSpan(2).withStyle(DocumentTableStyle.builder() + .textAnchor(DocumentTableTextAnchor.BOTTOM_LEFT).build()), + DocumentTableCell.text("First")) + .rowCells(DocumentTableCell.text("Second")))); + try (document) { + var table = document.getTables().get(0); + + assertThat(table.getRow(0).getCell(0).getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.BOTTOM); + assertThat(table.getRow(1).getCell(0).getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.BOTTOM); + } + } + + @Test + void aRightToLeftCellAnchoredAtDefaultSitsBottomLeftAsThePageDrawsIt() throws Exception { + // DEFAULT is authored, so the right-to-left default does not replace it, and the + // renderer puts its horizontal half on the physical left — the end of the flow. + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .direction(TextDirection.RTL) + .textAnchor(DocumentTableTextAnchor.DEFAULT).build()) + .row("שלום"))); + + assertThat(cell.getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.BOTTOM); + assertThat(cell.getParagraphs().get(0).getAlignment()).isEqualTo(ParagraphAlignment.RIGHT); + } + + @Test + void anAutoCellReadsItsDirectionFromTheTextAsTheLayoutDoes() throws Exception { + // The layout flattens a break inside a line to a space before it looks for the first + // strong character, so this line opens on digits and then Hebrew: right to left. + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().direction(TextDirection.AUTO).build()) + .rowCells(DocumentTableCell.lines("123\nשלום")))); + var properties = cell.getParagraphs().get(0).getCTP().getPPr(); + + assertThat(properties.isSetBidi()).as("a right-to-left paragraph").isTrue(); + assertThat(properties.isSetJc()).as("starting on the right, its default").isFalse(); + } + @Test void aRightToLeftCellStartsOnTheRightAndAnAuthoredLeftIsItsEnd() throws Exception { // Word reads jc left/right as the start and end of a bidi paragraph's flow. The