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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A table or a row keeps its own margins in DOCX.** Its left margin is now written into its
indent (`w:tblInd`), as a paragraph's is: it was dropped everywhere, and `PaymentsInvoice`'s
bank details stood 36.7pt left of the page's in Word. A table indented in a cell takes that
margin out of the width it is given there, rather than running past the cell. A table that
opens a layer of a stack written as a band starts where the band resumes and keeps its top
margin below that, held by a hairline line at the top of a cell. `VioletGrid`'s education lines, a table beside a badge
held 51.3pt clear of it and 2.3pt down, started under the badge and stood 2.4pt high; they
are at most 0.07pt off now. Lines more than 2pt off in Word fall from 5 to 2 for `VioletGrid`.
- **Text hanging past a block takes its place out of the space above a stack that follows in
DOCX.** A stack written as a band opens at the space the page measures above its first block,
and that block forgot what the text above it already hung into that space: a band's or a
Expand Down
7 changes: 5 additions & 2 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,11 @@ paragraph a point tall after it: Word writes one there in any case, and its own
text tall.

The horizontal half is carried as an indent: outside any panel, every paragraph by each enclosing container's
margin and padding, a row or a table by the same amount as `w:tblInd` — see "What a panel
keeps and loses".
margin and padding, a row or a table by the same amount as `w:tblInd`, its own left margin
added unless it is a row's column, whose cell already starts past it — see "What a panel
keeps and loses". A table that opens a layer of a stack written as a band, or of a column
layer, starts where the layer resumes, its own top margin below that; at the top of a cell an
empty hairline line above it holds that space, as it does for a row.

A right-to-left paragraph has its two indents written the other way round. In a `w:bidi`
paragraph Word and LibreOffice read `w:ind`'s `left` and `right` as the start and end of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1779,9 +1779,9 @@ cv-violet_grid 0:999ddb26bed6 21.16 redesignedkeypatientflowsincludi
cv-violet_grid 0:073d15633c0b 21.16 systemandcomponentlibraryusedacr
cv-violet_grid 0:68bf0109c6f8 21.41 education
cv-violet_grid 0:6cb574fa10b6 21.41 languages
cv-violet_grid 0:0cf7506cf94a 19.08 bacheloroffineartsingraphicdesig
cv-violet_grid 0:00ab7a6aa5f1 19.09 californiacollegeofthearts
cv-violet_grid 0:7e2c21bb41db 19.08 sanfrancisco,ca|2014-2018
cv-violet_grid 0:0cf7506cf94a 21.48 bacheloroffineartsingraphicdesig
cv-violet_grid 0:00ab7a6aa5f1 21.49 californiacollegeofthearts
cv-violet_grid 0:7e2c21bb41db 21.48 sanfrancisco,ca|2014-2018
cv-violet_grid 0:b6234d2ea0d6 20.73 english
cv-violet_grid 0:bef32d2c315a 37.47 native
cv-violet_grid 0:bef32d2c315a#1 5.21 native
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 3.27 3.87 86
cv-teal_pulse 1 1 57 57 0.22 0.64 0
cv-terracotta_rail 1 1 102 102 0.44 0.94 0
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
cv-violet_grid 1 2 80 76 5.21 21.31 39
cv-violet_grid 1 2 80 76 5.21 21.41 39
invoice-classic 1 1 56 56 0.12 0.30 0
invoice-classic_long 2 2 166 166 0.20 0.27 0
invoice-consulting 1 1 92 92 0.67 0.96 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1685,9 +1685,9 @@ cv-violet_grid 0:999ddb26bed6 21.16 redesignedkeypatientflowsincludi
cv-violet_grid 0:073d15633c0b 21.16 systemandcomponentlibraryusedacr
cv-violet_grid 0:68bf0109c6f8 21.41 education
cv-violet_grid 0:6cb574fa10b6 21.41 languages
cv-violet_grid 0:0cf7506cf94a 19.08 bacheloroffineartsingraphicdesig
cv-violet_grid 0:00ab7a6aa5f1 19.09 californiacollegeofthearts
cv-violet_grid 0:7e2c21bb41db 19.08 sanfrancisco,ca|2014-2018
cv-violet_grid 0:0cf7506cf94a 21.48 bacheloroffineartsingraphicdesig
cv-violet_grid 0:00ab7a6aa5f1 21.49 californiacollegeofthearts
cv-violet_grid 0:7e2c21bb41db 21.48 sanfrancisco,ca|2014-2018
cv-violet_grid 0:b6234d2ea0d6 20.73 english
cv-violet_grid 0:bef32d2c315a 37.47 native
cv-violet_grid 0:bef32d2c315a#1 5.21 native
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 3.27 3.87 86
cv-teal_pulse 1 1 57 57 0.22 0.64 0
cv-terracotta_rail 1 1 102 102 0.44 0.94 0
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
cv-violet_grid 1 2 80 76 5.21 21.31 39
cv-violet_grid 1 2 80 76 5.21 21.41 39
invoice-classic 1 1 56 56 0.17 0.32 0
invoice-classic_long 2 2 166 166 0.22 0.27 0
invoice-consulting 1 1 92 92 0.67 1.01 0
Expand Down
6 changes: 3 additions & 3 deletions qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -1778,9 +1778,9 @@ cv-violet_grid 0:999ddb26bed6 -0.33 redesignedkeypatientflowsincludi
cv-violet_grid 0:073d15633c0b -0.29 systemandcomponentlibraryusedacr
cv-violet_grid 0:68bf0109c6f8 -0.07 education
cv-violet_grid 0:6cb574fa10b6 -0.07 languages
cv-violet_grid 0:0cf7506cf94a -2.47 bacheloroffineartsingraphicdesig
cv-violet_grid 0:00ab7a6aa5f1 -2.43 californiacollegeofthearts
cv-violet_grid 0:7e2c21bb41db -2.44 sanfrancisco,ca|2014-2018
cv-violet_grid 0:0cf7506cf94a -0.07 bacheloroffineartsingraphicdesig
cv-violet_grid 0:00ab7a6aa5f1 -0.03 californiacollegeofthearts
cv-violet_grid 0:7e2c21bb41db -0.04 sanfrancisco,ca|2014-2018
cv-violet_grid 0:b6234d2ea0d6 -1.38 english
cv-violet_grid 0:bef32d2c315a -1.37 native
cv-violet_grid 0:bef32d2c315a#1 -1.29 native
Expand Down
2 changes: 1 addition & 1 deletion qa/src/test/resources/docx-fidelity/word-windows.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 85 0.53 1.18 0
cv-teal_pulse 1 1 57 57 0.11 0.38 0
cv-terracotta_rail 1 1 102 102 0.42 0.87 0
cv-timeline_minimal 2 2 89 86 0.36 0.48 0
cv-violet_grid 1 1 80 77 0.23 1.30 5
cv-violet_grid 1 1 80 77 0.22 1.09 2
invoice-classic 1 1 56 56 0.22 0.36 0
invoice-classic_long 2 2 166 166 0.27 0.34 0
invoice-consulting 1 1 92 92 0.70 1.02 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7366,8 +7366,9 @@ private static boolean startsWith(byte[] bytes, int... signature) {
* paragraph after. Both go through the debt every other gap goes through
* ({@link #owePendingSpacingAfter}), so a table between two paragraphs reads the same as
* two paragraphs with a gap between them. With nothing above it — opening the body, or a
* row opening any cell but a table's — a paragraph a tenth of a point tall holds that edge
* ({@link #holdTheSpaceAboveATable}); a table opening a cell still loses it.</p>
* row opening any cell but a table's, or a table opening a cell where a band or column layer
* resumes — a paragraph a tenth of a point tall holds that edge
* ({@link #holdTheSpaceAboveATable}); any other table opening a cell still loses it.</p>
*/
private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node)
throws Exception {
Expand All @@ -7384,11 +7385,21 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
borderBelow = 0;
forgetTheHang();
}
// A table a band's or a column's layer opens is that layer's first block, so it starts
// where the layer resumes, as a paragraph does, and its own top margin comes after:
// VioletGrid's education lines, beside a badge, lost their 2.3pt and stood 2.4pt high.
// A row is not a first block: the band measures to the first one inside it, and the
// row's cells write what stands above that block.
boolean resumed = node instanceof TableNode && !Double.isNaN(resumeSpacing);
if (resumed) {
resumeHere();
}
owePendingSpacingAfter(node.margin().top() + node.padding().top());
if (onANewPage) {
holdTheSpaceAboveOnItsPage(document);
}
if (node instanceof RowNode && currentCell != null && !tablesCells.contains(currentCell.getCTTc())) {
if ((node instanceof RowNode || resumed) && currentCell != null
&& !tablesCells.contains(currentCell.getCTTc())) {
// At the top of a cell nothing above holds that space: MerchantInvoice's due-date
// row lost its 16.7pt of top padding and stood against the card's top edge, once the
// card held the page's height, and PaymentsInvoice's metadata grid the 6.2pt its
Expand All @@ -7398,10 +7409,23 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
// 6pt below the page's.
holdTheSpaceAboveATable(document);
}
if (node instanceof RowNode row) {
writeRow(document, row);
} else {
writeTable(document, (TableNode) node);
// Its side margins hold it in from the edges it is written between, as a paragraph's do:
// VioletGrid's education lines, a table held 51.3pt clear of the badge beside them,
// started under the badge, and PaymentsInvoice's bank details 36.7pt left of the page's.
double outerLeft = insetLeft;
double outerRight = insetRight;
// A row's column already starts past the margin of the block the layout placed in it.
insetLeft += node == leftMarginInCell ? 0 : node.margin().left();
insetRight += node.margin().right();
try {
if (node instanceof RowNode row) {
writeRow(document, row);
} else {
writeTable(document, (TableNode) node);
}
} finally {
insetLeft = outerLeft;
insetRight = outerRight;
}
owePendingSpacingAfter(node.margin().bottom() + node.padding().bottom());
}
Expand Down Expand Up @@ -10062,7 +10086,8 @@ private static double usableWidthOf(XWPFTableCell cell, int column, int span) {
* Word to about one character per line, which is not a document anybody can read.</p>
*/
private double nestedTableWidth() {
return currentCellWidth;
// Less what the table is held in by, its own margins included (writeTableWithItsOwnSpacing).
return currentCellWidth - Math.max(0, insetLeft) - Math.max(0, insetRight);
}

private XWPFTable newTable(XWPFDocument document, int rows, int columns) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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.dsl.SectionBuilder;
import com.demcha.compose.document.dsl.ShapeContainerBuilder;
import com.demcha.compose.document.dsl.TableBuilder;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.LayerAlign;
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.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.junit.jupiter.api.Test;

import java.util.List;

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

/**
* A table keeps its own margins: its left margin holds it in from the edge it is written from,
* and its top margin stands above it, in a band's layer as anywhere else.
*
* <p>VioletGrid sets each education entry's lines in a table held 51.3pt clear of a badge
* drawn beside them, and 2.3pt down. Neither margin was written: in Word the lines started
* under the badge and stood 2.4pt high. A table or a row was never indented by its left
* margin, anywhere — PaymentsInvoice's bank details stood 36.7pt left of the page's.</p>
*/
class DocxTableMarginsTest {

private static final double INSET = 51.3;
private static final double DOWN = 2.3;

@Test
void aTablesLeftMarginIndentsIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page
.addParagraph("Above")
.add(lines()))) {
assertThat(indentOf(document.getTables().get(0))).as("the table stands its margin in")
.isEqualTo(Math.round(INSET * 20));
}
}

@Test
void aRowsLeftMarginIndentsIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page
.addParagraph("Above")
.addRow(row -> row.name("Split").margin(new DocumentInsets(0, 0, 0, 30))
.addSection("Left", left -> left.addParagraph("Left"))
.addSection("Right", right -> right.addParagraph("Right"))))) {
assertThat(indentOf(document.getTables().get(0))).as("the row stands its margin in")
.isEqualTo(600);
}
}

@Test
void aTableBesideABadgeKeepsItsMarginsInTheBand() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> {
page.addParagraph("Above");
body(page);
})) {
XWPFTable lines = document.getTables().get(0);
assertThat(lines.getText()).contains("Degree");
assertThat(indentOf(lines)).as("clear of the badge, as the page sets it")
.isEqualTo(Math.round(INSET * 20));
XWPFParagraph above = document.getParagraphs().get(0);
assertThat(above.getText()).isEqualTo("Above");
assertThat(above.getSpacingAfter()).as("and its top margin stands above it")
.isEqualTo((int) Math.round(DOWN * 20));
}
}

@Test
void aTableOpeningACellBesideABadgeKeepsItsTopMarginOverIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page
.addParagraph("Above")
.addRow(row -> row.name("Columns")
.addSection("Education", this::bodyIn)
.addSection("Other", other -> other.addParagraph("Other"))))) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
List<IBodyElement> elements = cell.getBodyElements();
assertThat(elements.get(0)).as("nothing above the table in its cell holds the space")
.isInstanceOf(XWPFParagraph.class);
XWPFParagraph hairline = (XWPFParagraph) elements.get(0);
assertThat(hairline.getText()).isEmpty();
assertThat(hairline.getSpacingBefore()).as("so a hairline over it holds its top margin")
.isEqualTo((int) Math.round(DOWN * 20));
XWPFTable lines = (XWPFTable) elements.get(1);
assertThat(indentOf(lines)).isEqualTo(Math.round(INSET * 20));
}
}

@Test
void aTableARowPlacesAsAColumnIsNotIndentedTwice() throws Exception {
// The row's cell starts where the layout placed the table, past its margin already.
// RowBuilder refuses a table, and a row node built by hand holds one.
DocumentNode row = new com.demcha.compose.document.node.RowNode("Columns",
List.of(lines(), new SectionBuilder().name("Other").addParagraph("Other").build()),
List.of(1.0, 1.0), 0, DocumentInsets.zero(), DocumentInsets.zero(), null, null,
com.demcha.compose.document.style.DocumentCornerRadius.ZERO, null, List.of());
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page.add(row))) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
XWPFTable lines = cell.getTables().get(0);
assertThat(lines.getText()).contains("Degree");
assertThat(indentOf(lines)).as("the column holds the margin, not the table").isZero();
}
}

@Test
void aTableIndentedInACellStillEndsInsideIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page
.addRow(row -> row.name("Columns")
.addSection("Left", section -> section.add(new TableBuilder().name("Auto")
.margin(new DocumentInsets(0, 0, 0, 20))
.columns(DocumentTableColumn.auto(), DocumentTableColumn.auto())
.row("A rather longer cell of text", "Another rather long cell")
.build()))
.addSection("Right", right -> right.addParagraph("Right"))))) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
XWPFTable nested = cell.getTables().get(0);
long cellWidth = ((Number) cell.getCTTc().getTcPr().getTcW().getW()).longValue();
long tableWidth = ((Number) nested.getCTTbl().getTblPr().getTblW().getW()).longValue();
assertThat(indentOf(nested)).isEqualTo(400);
assertThat(indentOf(nested) + tableWidth).as("its margin comes out of its width, not past the cell")
.isLessThanOrEqualTo(cellWidth);
}
}

@Test
void aTableOpeningACellOutsideABandHoldsNoHairline() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page
.addRow(row -> row.name("Columns")
.addSection("Education", section -> section.add(lines()))
.addSection("Other", other -> other.addParagraph("Other"))))) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
assertThat(cell.getBodyElements().get(0)).as("nothing resumes there, so nothing is held")
.isInstanceOf(XWPFTable.class);
}
}

private void bodyIn(SectionBuilder section) {
section.addLayerStack(stack -> stack.name("Body")
.layer(lines(), LayerAlign.TOP_LEFT, 0)
.position(badge(), 0, 0, LayerAlign.TOP_LEFT, 1));
}

private void body(PageFlowBuilder page) {
page.addSection("Education", this::bodyIn);
}

private static DocumentNode lines() {
return new TableBuilder().name("Lines")
.margin(new DocumentInsets(DOWN, 0, 0, INSET)).width(150)
.columns(DocumentTableColumn.fixed(150))
.defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.zero())
.stroke(DocumentStroke.of(DocumentColor.WHITE, 0)).build())
.rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Degree").build()))
.rowCells(DocumentTableCell.node(new ParagraphBuilder().text("School").build()))
.build();
}

private static DocumentNode badge() {
return new ShapeContainerBuilder().name("Badge").circle(40)
.fillColor(DocumentColor.rgb(200, 180, 240))
.center(new SectionBuilder().name("Glyph").spacer(10, 10).build())
.build();
}

private static long indentOf(XWPFTable table) {
var properties = table.getCTTbl().getTblPr();
if (properties == null || !properties.isSetTblInd()) {
return 0;
}
return ((Number) properties.getTblInd().getW()).longValue();
}
}
Loading