Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A table cell keeps the space the document put inside its edges.** It was never written,
so Word used its own — 5.4pt at each side and *nothing* above or below. A row's height in
Word is its content's box, so every row came out shorter than the page draws it: measured
on the probe corpus through LibreOffice, each row of a five-row table sat 8.1pt short, and
the table's last row 40pt above where the page puts it. The padding a cell resolves to —
the table's default, then the column's, then the row's, then the cell's own — is now
written as `w:tcMar`, and the same table's rows land within 0.1pt of the page.
<br><br>
All four sides are written, and written even when they are zero, because Word's default is
not zero: a table that asked for no padding would otherwise export with Word's side
margins and read wider than it is. A table that states no padding at all is written with
the padding the engine lays it out with rather than with Word's, and a test pins the two
together so the export cannot quietly draw rows of a height nothing asked for.

- **A gap between two blocks in an exported Word file is one gap.** It was written from both
sides — `w:after` on the block above and `w:before` on the one below — which is the same
distance only in an editor that adds them. LibreOffice takes the larger: measured on the
Expand Down
Binary file modified assets/readme/examples/word-export-companion.docx
Binary file not shown.
2 changes: 1 addition & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ PDF never pull POI.
|---|---|
| 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. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" |
| 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. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" |
| 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, 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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentBorders;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
Expand Down Expand Up @@ -2027,6 +2028,7 @@
applyCellPaint(cell,
resolveCellValue(node, placement, DocumentTableStyle::fillColor),
resolveCellValue(node, placement, DocumentTableStyle::stroke));
applyCellPadding(cell, resolveCellPadding(node, placement));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
continue;
Expand Down Expand Up @@ -2111,6 +2113,70 @@
}
}

/**
* Keeps a cell's own space clear inside its edges.
*
* <p>A table's rows came out shorter than the page draws them: measured on the probe
* corpus, every row of a five-row table was 8.1pt short, because the cell padding the
* engine lays out with was never written and Word used its own — 5.4pt at each side and
* <em>nothing</em> above or below. Word holds this natively as {@code w:tcMar}, so it
* is a mapping rather than an approximation.</p>
*
* <p>All four sides are written, and written even when they are zero, because Word's
* default is not zero: a table that asked for no padding would otherwise export with
* Word's side margins and read wider than it is. The vertical pair is what a reader
* sees as the row's height, since Word grows a row to fit its content and this is part
* of that content's box.</p>
*/
private static void applyCellPadding(XWPFTableCell cell, DocumentInsets padding) {
CTTcPr properties = cellProperties(cell);
CTTcMar margins = properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar();
setCellMargin(margins.isSetTop() ? margins.getTop() : margins.addNewTop(), padding.top());
setCellMargin(margins.isSetBottom() ? margins.getBottom() : margins.addNewBottom(),
padding.bottom());
setCellMargin(margins.isSetLeft() ? margins.getLeft() : margins.addNewLeft(), padding.left());
setCellMargin(margins.isSetRight() ? margins.getRight() : margins.addNewRight(),
padding.right());
}

/**
* The padding a cell resolves to, most specific wins, with the engine's own default
* underneath.
*
* <p>The default is the last step of the same cascade the layout pipeline runs, where
* {@code TableCellLayoutStyle.DEFAULT} sits under the authored styles — so a table that
* states no padding is laid out with 4pt and has to be written with 4pt.
* {@code DocxCellPaddingTest} pins the two together, because the engine's copy is
* internal and cannot be read from here.</p>
*/
private DocumentInsets resolveCellPadding(TableNode node, TableGrid.Placement placement) {
DocumentInsets authored = resolveCellValue(node, placement, DocumentTableStyle::padding);
return authored != null ? authored : DocumentInsets.of(ENGINE_DEFAULT_CELL_PADDING_POINTS);
}

/** What the engine lays a cell out with when nothing states otherwise. */
static final double ENGINE_DEFAULT_CELL_PADDING_POINTS = 4.0;

/** The left and right margins written on a cell, or Word's own default for an unwritten one. */
private static double horizontalMarginsOf(XWPFTableCell cell) {
CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : null;
CTTcMar margins = properties != null && properties.isSetTcMar() ? properties.getTcMar() : null;
if (margins == null) {
return 2 * WORD_DEFAULT_CELL_MARGIN_POINTS;
}
return marginPoints(margins.isSetLeft() ? margins.getLeft() : null)
+ marginPoints(margins.isSetRight() ? margins.getRight() : null);
}

private static double marginPoints(CTTblWidth margin) {
return margin == null || margin.getW() == null
? WORD_DEFAULT_CELL_MARGIN_POINTS
: Long.parseLong(String.valueOf(margin.getW())) / POINT_TO_TWIP;
}

/** Word keeps this much clear inside every cell edge unless a table says otherwise. */
private static final double WORD_DEFAULT_CELL_MARGIN_POINTS = 5.4;

private static CTTcPr cellProperties(XWPFTableCell cell) {
return cell.getCTTc().isSetTcPr()
? cell.getCTTc().getTcPr()
Expand Down Expand Up @@ -2687,14 +2753,12 @@
* @param columns column count of the first row
* @return the created table, already attached where it belongs
*/
/** Word keeps this much clear inside every cell edge unless a table says otherwise. */
private static final double DEFAULT_CELL_MARGIN_POINTS = 5.4;

/**
* How wide content can be inside one cell: the columns it spans, less Word's margins.
* How wide content can be inside one cell: the columns it spans, less its own margins.
*
* <p>Read back from the grid this export just wrote rather than recomputed, so a cell
* cannot disagree with the table it is in.</p>
* <p>Both are read back from what this export just wrote — the grid for the width and
* {@code w:tcMar} for the margins — so a cell cannot disagree with the table it is in,
* and a table whose padding the document stated is not measured against Word's.</p>
*
* @return the usable width in points, or {@code NaN} when the table has no written grid
*/
Expand All @@ -2708,7 +2772,7 @@
for (int index = placement.column(); index < last; index++) {
twips += Long.parseLong(String.valueOf(grid.getGridColArray(index).getW()));
}
double points = twips / POINT_TO_TWIP - 2 * DEFAULT_CELL_MARGIN_POINTS;
double points = twips / POINT_TO_TWIP - horizontalMarginsOf(cell);
return points > 0 ? points : Double.NaN;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.demcha.compose.document.backend.semantic.docx;

import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.style.DocumentInsets;
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.engine.components.content.table.TableCellLayoutStyle;
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.CTTblWidth;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar;

import java.util.function.Consumer;

import static org.assertj.core.api.Assertions.assertThat;

/**
* A table cell keeps the space the document put inside its edges.
*
* <p>It was never written, so Word used its own — 5.4pt at each side and <em>nothing</em>
* above or below. Measured on the probe corpus, every row of a five-row table came out
* 8.1pt short of the page, because a row's height is its content's box and the padding is
* part of that box.</p>
*
* <p>Word holds this as {@code w:tcMar}, so it is a mapping. All four sides are written,
* and written even when they are zero: Word's default is not zero, so a table that asked
* for no padding would otherwise export with Word's side margins.</p>
*
* @author Artem Demchyshyn
*/
class DocxCellPaddingTest {

private static final double TWIPS_PER_POINT = 20.0;

@Test
void aStatedPaddingReachesAllFourSides() throws Exception {
XWPFTableCell cell = firstCell(page -> page.addTable(t -> t
.columns(DocumentTableColumn.auto())
.defaultCellStyle(DocumentTableStyle.builder()
.padding(new DocumentInsets(9, 7, 5, 3))
.build())
.row("Padded")));

assertThat(margins(cell)).containsExactly(180L, 140L, 100L, 60L);
}

@Test
void aTableThatStatesNothingIsWrittenWithTheEnginesOwnDefault() throws Exception {
// Not Word's 5.4pt a side and nothing above: the page is laid out with 4pt all
// round, and the file has to say the same or the rows come out shorter than drawn.
XWPFTableCell cell = firstCell(page -> page.addTable(t -> t
.columns(DocumentTableColumn.auto())
.row("Plain")));

long four = Math.round(4 * TWIPS_PER_POINT);
assertThat(margins(cell)).containsExactly(four, four, four, four);
}

@Test
void theDefaultIsTheOneTheEngineLaysOutWith() throws Exception {
// The engine's copy is internal and cannot be read from the backend, so the two
// are pinned together here: if the layout's default moves, this fails rather than
// the export quietly drawing rows of a height nothing asked for.
assertThat(DocxSemanticBackend.ENGINE_DEFAULT_CELL_PADDING_POINTS)
.isEqualTo(TableCellLayoutStyle.DEFAULT.padding().top())
.isEqualTo(TableCellLayoutStyle.DEFAULT.padding().bottom())
.isEqualTo(TableCellLayoutStyle.DEFAULT.padding().left())
.isEqualTo(TableCellLayoutStyle.DEFAULT.padding().right());
}

@Test
void aCellsOwnPaddingBeatsTheRowsAndTheTablesTest() throws Exception {
// The same cascade the layout pipeline merges in, applied per field.
XWPFTable table = firstTable(page -> page.addTable(t -> t
.columns(DocumentTableColumn.auto(), DocumentTableColumn.auto())
.defaultCellStyle(DocumentTableStyle.builder()
.padding(DocumentInsets.of(2))
.build())
.rowStyle(0, DocumentTableStyle.builder().padding(DocumentInsets.of(6)).build())
.rowCells(DocumentTableCell.text("row wins"),
DocumentTableCell.text("cell wins").withStyle(
DocumentTableStyle.builder().padding(DocumentInsets.of(11)).build()))));

assertThat(margins(table.getRow(0).getCell(0))[0]).isEqualTo(Math.round(6 * TWIPS_PER_POINT));
assertThat(margins(table.getRow(0).getCell(1))[0]).isEqualTo(Math.round(11 * TWIPS_PER_POINT));
}

@Test
void aTableThatAsksForNoPaddingGetsNoneRatherThanWords() throws Exception {
XWPFTableCell cell = firstCell(page -> page.addTable(t -> t
.columns(DocumentTableColumn.auto())
.defaultCellStyle(DocumentTableStyle.builder()
.padding(DocumentInsets.zero())
.build())
.row("Tight")));

assertThat(margins(cell)).containsExactly(0L, 0L, 0L, 0L);
}

/** Top, right, bottom, left in twips. */
private static long[] margins(XWPFTableCell cell) {
CTTcMar mar = cell.getCTTc().getTcPr().getTcMar();
return new long[]{width(mar.getTop()), width(mar.getRight()),
width(mar.getBottom()), width(mar.getLeft())};
}

private static long width(CTTblWidth margin) {
return margin == null || margin.getW() == null
? -1
: Long.parseLong(String.valueOf(margin.getW()));
}

private static XWPFTableCell firstCell(Consumer<PageFlowBuilder> content) throws Exception {
return firstTable(content).getRow(0).getCell(0);
}

private static XWPFTable firstTable(Consumer<PageFlowBuilder> content) throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, content)) {
return document.getTables().get(0);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,9 @@ void aNestedTableIsGivenTheWidthOfTheColumnItSitsIn() throws Exception {
assertThat(cell.getTables().get(0).getCTTbl().getTblPr().getTblW().getType().toString())
.as("stated, not left to Word")
.isEqualTo("dxa");
// The column less the margins Word keeps inside every cell edge: 5.4pt a side.
assertThat(nested).isEqualTo(column - 2 * 108);
// The column less the margins the cell itself states: the engine's own default
// cell padding, 4pt a side, which this export writes as w:tcMar.
assertThat(nested).isEqualTo(column - 2 * 80);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ void readingWhatIsUnderAChipPaintsNothing() throws Exception {
.as("nothing underneath it, so the page's white")
.isEqualTo("EFF1F3");
assertThat(document.getTables().get(0).getRow(0).getTableCells().stream()
.noneMatch(cell -> cell.getCTTc().isSetTcPr()))
.noneMatch(cell -> cell.getCTTc().getTcPr().isSetShd()))
.as("a cell nobody painted stays unpainted")
.isTrue();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ void aCellFillReachesWordAsShading() throws Exception {
.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();
// A cell nothing painted is not painted. It still carries properties — every cell
// states its own margins — so the question is whether it has shading, not whether
// it has a w:tcPr.
assertThat(table.getRow(0).getCell(1).getCTTc().getTcPr().isSetShd())
.as("a cell nothing painted keeps Word's default rather than being filled with black")
.isFalse();
}

@Test
Expand Down
Loading