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