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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ follow semantic versioning; release dates are ISO 8601.
on the card's own paragraphs. A row placed directly in a bordered card keeps its cells shaded
and no longer draws the card's outline around the text of each cell.

- **Two tables in a row stay two tables in Word.** Word and LibreOffice join two tables
with nothing between them into one and lay the second one's rows on the first one's
column grid; measured in LibreOffice, a zebra table followed by a narrower one came out
at half its width with its text broken letter by letter — and a row (`addRow`) is carried
as a table, so two rows in a row were joined the same way. A paragraph a tenth of a point
tall (measured in LibreOffice; Word may hold it to its own minimum, still under a point) now
separates them, carrying the rest of the gap the layout keeps between them and a panel's
fill — not its borders, which would rule a line across the card — and keeping with the
table below it, so a block kept with that table stays kept. Space owed below a table no
longer lands on the paragraph above it: a card ending in a table opened its bottom padding
between its title and its table, and after a table nested in a cell the space now goes to
the paragraph that closes the cell.

- **A table of contents keeps its page numbers in Word, and they stay right.** The DOCX export
dropped every page reference, so `addTableOfContents(...)` reached Word with its entries and
no numbers, and `addPageReference(...)` wrote nothing. Each is now Word's own `PAGEREF`
Expand Down
2 changes: 1 addition & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 |
| 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 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, 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 @@ -3748,8 +3748,16 @@ private double nestedTableWidth() {
}

private XWPFTable newTable(XWPFDocument document, int rows, int columns) {
if (currentCell == null && endsWithATable(document.getBodyElements())) {
separateFromTheTableAbove(document);
}
// Word has no space above a table, so the paragraph before it has to carry it.
flushSpacingAfter();
// Nor can that paragraph carry the space below the table: it sits above it. Space
// owed once the table is written goes to whatever paragraph follows, as space above
// it — and nowhere, if nothing follows — rather than back above the table, which is
// where it used to land: a card's bottom padding opened a gap over its last table.
lastBodyParagraph = null;
if (currentCell == null) {
return document.createTable(rows, columns);
}
Expand All @@ -3758,10 +3766,59 @@ private XWPFTable newTable(XWPFDocument document, int rows, int columns) {
// it, so reading the cell back finds it. getTables() is unmodifiable on purpose —
// adding to it throws rather than quietly leaving the model and the XML disagreeing.
currentCell.insertTable(currentCell.getBodyElements().size(), nested);
currentCell.addParagraph();
// Word ends a cell with a paragraph, so one follows the nested table — and being below
// it, it is where space owed after the table goes: a padded card ending in a nested
// table keeps its bottom padding inside the cell, as the page does.
lastBodyParagraph = currentCell.addParagraph();
return nested;
}

private static boolean endsWithATable(List<IBodyElement> elements) {
return !elements.isEmpty() && elements.get(elements.size() - 1) instanceof XWPFTable;
}

/**
* Puts a paragraph between a table and the one about to follow it.
*
* <p>Two tables with nothing between them are one table to Word and to LibreOffice: the
* editor joins them, and the second table's rows are laid out on the first one's column
* grid. Measured in LibreOffice, a zebra table followed by a narrower one came out at half
* its width, its text broken letter by letter. A table, or a row carried as one, is often
* followed by another, and on the page there is a gap between them.</p>
*
* <p>So the gap is written as a paragraph: a tenth of a point tall, with the rest of the
* space the layout keeps between the two above it, so the second table starts where the
* page starts it. Measured in LibreOffice, a one-point separator put 0.9pt more between two
* touching tables than a tenth of a point does; below that the height stops mattering.
* Word may hold a line that short to its own minimum, which is still under a point.</p>
*
* <p>It belongs to the gap, not to either table, and is written that way: it takes a
* panel's fill so the band is not broken, but not the panel's borders, which on a lone
* paragraph between two tables would draw a rule across the card at every junction; and it
* keeps with the next table, so a page never breaks between the gap and what it opens — a
* block kept with the table below it stays kept, and a bookmark opened on the separator
* counts the page the table lands on.</p>
*/
private void separateFromTheTableAbove(XWPFDocument document) {
pendingSpacingAfter = Math.max(0, pendingSpacingAfter - SEPARATOR_POINTS);
XWPFParagraph separator = newBodyParagraph(document);
CTPPr properties = separator.getCTP().isSetPPr()
? separator.getCTP().getPPr()
: separator.getCTP().addNewPPr();
if (properties.isSetPBdr()) {
properties.unsetPBdr();
}
if (!properties.isSetKeepNext()) {
properties.addNewKeepNext();
}
CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing();
spacing.setLineRule(STLineSpacingRule.EXACT);
spacing.setLine(BigInteger.valueOf(Math.round(SEPARATOR_POINTS * POINT_TO_TWIP)));
}

/** How tall the paragraph keeping two tables apart is. */
private static final double SEPARATOR_POINTS = 0.1;

/**
* Writes one node into a cell, through the same writers that write it anywhere else.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package com.demcha.compose.document.backend.semantic.docx;

import com.demcha.compose.document.style.DocumentColor;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.junit.jupiter.api.Test;

import java.util.List;

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

/**
* Two tables in a row stay two tables in Word.
*
* <p>An editor joins two tables with nothing between them into one, laying the second one's
* rows on the first one's column grid — measured in LibreOffice, a zebra table followed by a
* narrower one came out at half its width, its text broken letter by letter. A tenth-of-a-point
* paragraph now keeps them apart.</p>
*
* @author Artem Demchyshyn
*/
class DocxAdjacentTablesTest {

@Test
void twoTablesInARowAreKeptApartByAParagraphATenthOfAPointTall() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addTable(t -> t.autoColumns(2).row("Item", "Amount"))
.addTable(t -> t.autoColumns(3).row("A", "B", "C")))) {
List<IBodyElement> body = document.getBodyElements();

assertThat(body).hasSize(3);
assertThat(body.get(0)).isInstanceOf(XWPFTable.class);
assertThat(body.get(1)).isInstanceOf(XWPFParagraph.class);
assertThat(body.get(2)).isInstanceOf(XWPFTable.class);
XWPFParagraph separator = (XWPFParagraph) body.get(1);
assertThat(separator.getText()).isEmpty();
assertThat(DocxTwips.of(separator.getCTP().getPPr().getSpacing().getLine()))
.as("a tenth of a point, so it keeps the tables apart without opening a line between them")
.isEqualTo(2L);
}
}

@Test
void twoRowsInARowAreTwoTablesToo() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addRow(r -> r.addParagraph(p -> p.text("Name")).addParagraph(p -> p.text("Role")))
.addRow(r -> r.addParagraph(p -> p.text("Ada")).addParagraph(p -> p.text("Engineer"))))) {
List<IBodyElement> body = document.getBodyElements();

assertThat(body).extracting(element -> element.getClass().getSimpleName())
.containsExactly("XWPFTable", "XWPFParagraph", "XWPFTable");
}
}

@Test
void theGapBetweenThemIsTheLayoutsLessTheSeparatorsPoint() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addTable(t -> t.autoColumns(1).row("Above"))
.addTable(t -> t.autoColumns(1).row("Below").margin(
com.demcha.compose.document.style.DocumentInsets.top(12))))) {
XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(1);

assertThat(DocxTwips.of(separator.getCTP().getPPr().getSpacing().getBefore()))
.as("12pt above the second table: 11.9pt of spacing and the separator's own tenth of a point")
.isEqualTo(238L);
}
}

@Test
void aParagraphAlreadyBetweenThemIsEnough() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addTable(t -> t.autoColumns(1).row("Above"))
.addParagraph(p -> p.text("Between"))
.addTable(t -> t.autoColumns(1).row("Below")))) {
assertThat(document.getBodyElements()).hasSize(3);
assertThat(document.getParagraphs()).extracting(XWPFParagraph::getText).containsExactly("Between");
}
}

@Test
void spaceOwedBelowATableGoesBelowItNotAboveIt() throws Exception {
// A card ending in a table, followed by a page break: the card's bottom padding is
// owed after its table, and used to be paid on the last paragraph written — the
// card's title, above the table, opening a gap between the title and the table.
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.padding(com.demcha.compose.document.style.DocumentInsets.of(10))
.addParagraph(p -> p.text("Title"))
.addTable(t -> t.autoColumns(1).row("Last")))
.addPageBreak(b -> { })
.addParagraph(p -> p.text("Next page")))) {
XWPFParagraph title = document.getParagraphs().get(0);
var spacing = title.getCTP().getPPr().getSpacing();

assertThat(spacing == null || !spacing.isSetAfter())
.as("the card's bottom padding is below its table, not between its title and the table")
.isTrue();
}
}

@Test
void theSeparatorTakesAPanelsFillButNotItsBorders() throws Exception {
// On a lone paragraph between two tables the panel's borders would draw a rule across
// the card at every junction.
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.fillColor(DocumentColor.rgb(238, 243, 249))
.stroke(com.demcha.compose.document.style.DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 1))
.addParagraph(p -> p.text("Title"))
.addTable(t -> t.autoColumns(1).row("Above"))
.addTable(t -> t.autoColumns(1).row("Below"))))) {
XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(2);

assertThat(separator.getCTP().getPPr().isSetShd()).isTrue();
assertThat(separator.getCTP().getPPr().isSetPBdr()).isFalse();
assertThat(document.getParagraphs().get(0).getCTP().getPPr().isSetPBdr())
.as("the panel's own paragraphs keep its borders")
.isTrue();
}
}

@Test
void theSeparatorKeepsWithTheTableItOpens() throws Exception {
// A block kept with what follows it, ending in a table: its last row keeps with the
// separator, so the separator has to keep with the next table or the chain breaks.
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.keepWithNext().addTable(t -> t.autoColumns(1).row("Kept")))
.addTable(t -> t.autoColumns(1).row("Next")))) {
XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(1);

assertThat(separator.getCTP().getPPr().isSetKeepNext()).isTrue();
}
}

@Test
void spaceOwedAfterANestedTableStaysInsideTheCell() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addTable(t -> t.autoColumns(1).rowCells(
com.demcha.compose.document.table.DocumentTableCell.node(
new com.demcha.compose.document.dsl.SectionBuilder()
.padding(com.demcha.compose.document.style.DocumentInsets.of(8))
.addParagraph(p -> p.text("Totals"))
.addTable(inner -> inner.autoColumns(1).row("100"))
.build()))))) {
var cell = document.getTables().get(0).getRow(0).getCell(0);
List<XWPFParagraph> paragraphs = cell.getParagraphs();
XWPFParagraph afterNested = paragraphs.get(paragraphs.size() - 1);

assertThat(DocxTwips.of(afterNested.getCTP().getPPr().getSpacing().getAfter()))
.as("the padding below the nested table is written below it, inside the cell")
.isEqualTo(8 * 20L);
}
}

@Test
void theSeparatorInsideAPanelKeepsTheBandUnbroken() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.fillColor(DocumentColor.rgb(238, 243, 249))
.addTable(t -> t.autoColumns(1).row("Above"))
.addTable(t -> t.autoColumns(1).row("Below"))))) {
XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(1);

assertThat(separator.getCTP().getPPr().isSetShd()).isTrue();
}
}
}
Loading