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

### Public API

- **A row's cell whose last line hangs below the row takes it out of the gap under the row in
DOCX.** A line held to an icon reaches as far below the page's line as the icon's ink, and
gives that room back out of the space above what follows. The last line of a cell had
nothing following it there: Word made the row that much taller than the page's.
`TimelineMinimal`'s last contact line, its icon lowered beside the text, ended 1.8pt below
the header row, and moved the whole page under the header down that much. That reach now comes out of the
cell's own space under it first, and what passes the row comes out of the gap under the row.
`TimelineMinimal`'s median drift falls from 1.44 to 0.36pt in Word and from 1.40 to 0.45pt in
LibreOffice, its cover letter's from 1.30 to 0.29pt and 1.32 to 0.33pt.
- **A spacer is as tall in DOCX as on the page.** A spacer is written as an empty paragraph a
tenth of a point tall, which Word keeps (measured: ten such lines between eleven lines of
text added 0.96pt), and its whole height was then owed below it as well. Every spacer stood a
Expand Down
6 changes: 4 additions & 2 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,10 @@ page.addParagraph(p -> p
holds. A paragraph
of several lines, a line holding only a picture — not seated
on the page's baseline, having no text — a list item, a table's text cell and a line set
beside another are grown as above. At the end of a cell, the ink below the last line is
not taken from the cell's bottom: the row is that much taller. LibreOffice stands such a picture on
beside another are grown as above. At the end of a cell, the ink below the last line comes
out of the space the cell holds under it. In a row, what passes that, less the room the row
leaves the cell on the page, makes Word's row taller and comes out of the gap under the row;
in a panel or a stack written as columns it still makes the table taller. LibreOffice stands such a picture on
the baseline, higher than the page does, and cuts what passes the line's top: a lowered
icon stands up to its drop too high there, its top cut, where its text is in place.
- **What an icon is.** An SVG icon — an emoji among them — is drawn into a transparent
Expand Down
290 changes: 145 additions & 145 deletions qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ cv-sidebar_portrait 1 1 49 49 2.78 3.02 49
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 1.40 1.40 0
cv-violet_grid 1 2 80 76 5.71 21.81 40
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
cv-violet_grid 1 2 80 76 5.21 21.31 39
invoice-classic 1 1 56 56 0.47 1.17 0
invoice-classic_long 2 2 166 166 0.47 0.87 0
invoice-consulting 1 1 92 92 0.79 1.41 3
Expand Down Expand Up @@ -55,7 +55,7 @@ letter-monogram_sidebar 1 1 12 12 0.23 0.37 0
letter-nordic_clean 1 1 15 15 0.16 0.38 0
letter-panel 1 1 13 13 0.15 0.21 0
letter-sidebar_portrait 1 1 12 12 0.43 0.51 0
letter-timeline_minimal 1 1 15 15 1.32 1.52 0
letter-timeline_minimal 1 1 15 15 0.33 0.53 0
proposal-editorial 2 2 132 129 43.44 45.33 72
proposal-indigo 1 1 72 70 0.27 0.65 0
proposal-modern 1 1 44 44 0.41 0.75 0
Expand Down
292 changes: 146 additions & 146 deletions qa/src/test/resources/docx-fidelity/word-windows-lines.tsv

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions qa/src/test/resources/docx-fidelity/word-windows.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ cv-sidebar_portrait 1 1 49 49 0.08 0.27 0
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 1.44 1.57 0
cv-violet_grid 1 1 80 77 0.35 0.81 2
cv-timeline_minimal 2 2 89 86 0.36 0.48 0
cv-violet_grid 1 1 80 77 0.23 1.30 5
invoice-classic 1 1 56 56 0.48 1.22 0
invoice-classic_long 2 2 166 166 0.51 0.88 0
invoice-consulting 1 1 92 92 0.91 1.41 3
Expand Down Expand Up @@ -55,7 +55,7 @@ letter-monogram_sidebar 1 1 12 12 0.14 0.20 0
letter-nordic_clean 1 1 15 15 0.16 0.35 0
letter-panel 1 1 13 13 0.17 0.25 0
letter-sidebar_portrait 1 1 12 12 0.41 0.52 0
letter-timeline_minimal 1 1 15 15 1.30 1.63 0
letter-timeline_minimal 1 1 15 15 0.29 0.53 0
proposal-editorial 2 2 132 130 0.50 1.81 11
proposal-indigo 1 1 72 70 0.40 0.62 0
proposal-modern 1 1 44 44 0.35 0.73 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ public final class DocxSemanticBackend implements SemanticBackend<byte[]> {
private double hangingBelow;
private XWPFParagraph hangingOver;
private double hangingOverBy;
// How far the last line of the cell written last hangs below everything the cell writes
// under it, in points: Word's cell is that much taller than its content on the page (see
// writeInCell). The row it stands in reads it.
private double cellOverhang;

/**
* The tables whose first row text stands above, by cell — see {@link #standsAboveItsCell}.
Expand Down Expand Up @@ -8575,6 +8579,7 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception {
// MerchantInvoice's due-date text stood against its card's top without it.
boolean inAPanel = surfaceBehind != null && panelCell != null;
com.demcha.compose.document.layout.PlacedNode placedRow = layout.placement(node);
double rowOverhang = 0;
// A row has no fill of its own: inside a panel it is a table nested in the panel's
// cell, and a cell with no shading shows the panel's through it.
for (int i = 0; i < node.children().size(); i++) {
Expand All @@ -8587,13 +8592,16 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception {
currentCellWidth = usableWidthOf(cell, i, 1);
leftMarginInCell = placedColumns ? child : null;
cellHang = -hang;
cellOverhang = 0;
try {
writeRowCellChild(cell, child);
} finally {
currentCellWidth = previous;
leftMarginInCell = previousHeld;
cellHang = 0;
}
rowOverhang = Math.max(rowOverhang, cellOverhang - roomBelowInItsRow(child, placedRow, node));
cellOverhang = 0;
applyRowVerticalAlign(cell, node.verticalAlign());
}
giveTheLastCellItsInk(table, node, placedColumns);
Expand All @@ -8608,6 +8616,33 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception {
holdRowAtLeast(row, placedRow.placementHeight() - node.padding().top() - node.padding().bottom());
}
indentTable(table);
// A cell's last line hanging past the row's foot makes Word's row that much taller, and
// takes as much of the gap under it, as text hanging below a band does (writeLinePair):
// TimelineMinimal's last contact line, held to its icon, stood 1.8pt below the row, and
// the page under the header with it.
// A card's border below another cell (borderBelow) makes the row taller too: Word's row
// is as tall as the taller of the two, so only what passes the border is added to it.
if (rowOverhang - borderBelow > 0) {
hangingBelow = Math.max(hangingBelow, rowOverhang - borderBelow);
}
}

/**
* How much shorter a row's child is than the row's content box on the page, in points: room a
* line hanging below the child has before it makes Word's row taller. {@code +∞} when the
* layout does not say, so nothing is taken for it.
*/
private double roomBelowInItsRow(DocumentNode child, com.demcha.compose.document.layout.PlacedNode placedRow,
RowNode row) {
com.demcha.compose.document.layout.PlacedNode placed = layout.placement(child);
if (placedRow == null || placed == null || placedRow.startPage() != placedRow.endPage()
|| placed.startPage() != placed.endPage()) {
return Double.POSITIVE_INFINITY;
}
// The row's room less the child's height, wherever the row aligns it: Word grows a row
// only when a cell holds more than the row, however the cell sets its content in it.
return Math.max(0, placedRow.placementHeight() - row.padding().top() - row.padding().bottom()
- placed.placementHeight() - child.margin().top() - child.margin().bottom());
}

/**
Expand Down Expand Up @@ -10350,6 +10385,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
insetRight = 0;
cellTextShift = cellHang;
cellHang = 0;
double overhang = 0;
try {
content.write();
// A cell of space and nothing written — padding round a drawing the export keeps
Expand All @@ -10359,6 +10395,14 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
&& carriedSpacingBefore + pendingSpacingAfter > 0 && cell.getBodyElements().isEmpty()) {
holdToHairline(newBodyParagraph(cell.getXWPFDocument()));
}
// A line hanging below the cell's last block takes the space the cell owes under it
// first, as it takes the gap above the next block in the flow; what it reaches past
// that makes Word's cell taller than its content on the page.
double hang = hangingBelow;
// What a pull keeps from being written is no room for it (flushSpacingAfter).
double taken = Math.min(Math.max(0, pendingSpacingAfter - pullBelow), hang);
pendingSpacingAfter -= taken;
overhang = hang - taken;
// A cell ends where it ends: its last gap cannot land on whatever the body
// writes next, and the body's cannot land inside it.
flushSpacingAfter();
Expand All @@ -10379,6 +10423,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
cellTextShift = previousTextShift;
tableCloser = previousCloser;
}
cellOverhang = overhang;
}

/** Whether anything under a node was written in an earlier layer's stand-in. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.demcha.compose.document.backend.semantic.docx;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.svg.SvgIcon;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.junit.jupiter.api.Test;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;

import java.io.ByteArrayInputStream;
import java.util.function.Consumer;

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

/**
* A cell's last line hanging past its row's foot in Word takes that much of the gap under the row.
*
* <p>A line held to its icon reaches as far below the page's line as the icon's ink does, and
* gives that room back out of the space above what follows ({@link DocxSemanticBackend}'s
* {@code hangingBelow}). The last line of a cell had no "what follows" there: the cell ended,
* Word made the row that much taller than the page's, and {@code TimelineMinimal}'s page under
* its header, the last contact line's icon lowered beside it, stood 1.8pt low.</p>
*
* @author Artem Demchyshyn
*/
class DocxRowOverhangTest {

private static final SvgIcon ICON = SvgIcon.parse("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>"
+ "<circle cx='12' cy='12' r='10' fill='#1A5694'/></svg>");
private static final DocumentTextStyle SMALL = DocumentTextStyle.DEFAULT.withSize(7);
private static final double GAP = 10;

@Test
void theTallestCellsHangingLineTakesItsReachOutOfTheGapUnderTheRow() throws Exception {
Exported exported = export(left -> left.addParagraph(p -> p.textStyle(SMALL).text("Name")),
DocxRowOverhangTest::contact);
try (XWPFDocument document = exported.document()) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(1);
XWPFParagraph iconed = cell.getParagraphs().get(cell.getParagraphs().size() - 1);
long down = reachBelow(iconed, exported.pageLine());

assertThat(iconed.getCTP().getPPr().getSpacing().getLineRule()).as("held to its icon")
.isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT);
assertThat(down).as("the line reaches below the page's").isPositive();
assertThat(before(after(document))).as("the gap less that reach").isEqualTo(Math.round(GAP * 20) - down);
}
}

@Test
void aHangingLineWithRoomUnderItInItsRowTakesNothing() throws Exception {
// The cell beside it is taller: the line hangs into the row's own room, and Word's row
// is as tall as the page's.
try (XWPFDocument document = export(DocxRowOverhangTest::contact,
right -> {
for (int i = 0; i < 4; i++) {
right.addParagraph(p -> p.textStyle(SMALL).text("Line"));
}
}).document()) {
assertThat(before(after(document))).isEqualTo(Math.round(GAP * 20));
}
}

@Test
void aShorterCellSetAtTheRowsFootHasItsRoomAllTheSame() throws Exception {
// Set at the row's foot, the shorter cell's line hangs past the row on the page; Word sets
// the cell's content at the foot too, and the row is as tall as its taller cell.
try (XWPFDocument document = export(DocxRowOverhangTest::contact,
right -> {
for (int i = 0; i < 4; i++) {
right.addParagraph(p -> p.textStyle(SMALL).text("Line"));
}
}, com.demcha.compose.document.node.RowVerticalAlign.BOTTOM).document()) {
assertThat(before(after(document))).isEqualTo(Math.round(GAP * 20));
}
}

@Test
void aHangingLineTakesItsReachOutOfItsCellsPaddingFirst() throws Exception {
// The cell holds 5pt under its last line: the line hangs into it, which is written that
// much shorter, and the row is as tall as the page's.
Exported exported = export(left -> left.addParagraph(p -> p.textStyle(SMALL).text("Name")),
right -> {
right.padding(DocumentInsets.bottom(5));
contact(right);
});
try (XWPFDocument document = exported.document()) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(1);
XWPFParagraph iconed = cell.getParagraphs().get(cell.getParagraphs().size() - 1);
long down = reachBelow(iconed, exported.pageLine());
CTSpacing spacing = iconed.getCTP().getPPr().getSpacing();

assertThat(spacing.isSetAfter() ? DocxTwips.of(spacing.getAfter()) : 0)
.as("the padding less the reach").isEqualTo(5 * 20 - down);
assertThat(before(after(document))).isEqualTo(Math.round(GAP * 20));
}
}

/** A contact stack whose last line holds an icon lowered as TimelineMinimal's are. */
private static void contact(SectionBuilder section) {
section.spacing(3);
section.addParagraph(p -> p.textStyle(SMALL).text("London"));
section.addParagraph(p -> p.name("Iconed").textStyle(SMALL).inlineText("GitHub ", SMALL)
.inlineSvgIcon(ICON, 10.5, InlineImageAlignment.CENTER, -1.35, null));
}

/** The export, and the height the layout gives the icon line. */
private record Exported(XWPFDocument document, double pageLine) {
}

private static Exported export(Consumer<SectionBuilder> left, Consumer<SectionBuilder> right) throws Exception {
return export(left, right, com.demcha.compose.document.node.RowVerticalAlign.TOP);
}

private static Exported export(Consumer<SectionBuilder> left, Consumer<SectionBuilder> right,
com.demcha.compose.document.node.RowVerticalAlign align) throws Exception {
try (DocumentSession session = GraphCompose.document().pageSize(400, 400)
.margin(DocumentInsets.of(20)).create()) {
session.pageFlow(page -> page
.spacing(GAP)
.addRow(row -> row.verticalAlign(align).addSection("Left", left).addSection("Right", right))
.addParagraph(p -> p.textStyle(SMALL).text("After")));
double pageLine = session.layoutGraph().nodes().stream()
.filter(node -> "Iconed".equals(node.semanticName()))
.findFirst().orElseThrow().placementHeight();
return new Exported(new XWPFDocument(new ByteArrayInputStream(session.export(new DocxSemanticBackend()))),
pageLine);
}
}

/** How far a line held to its icon reaches below the page's line, in twips: line less page and up. */
private static long reachBelow(XWPFParagraph iconed, double pageLine) {
CTSpacing spacing = iconed.getCTP().getPPr().getSpacing();
long up = 3 * 20 - (spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0);
long line = DocxTwips.of(spacing.getLine());
return line - up - Math.round(pageLine * 20);
}

private static XWPFParagraph after(XWPFDocument document) {
return document.getParagraphs().stream().filter(p -> p.getText().equals("After")).findFirst().orElseThrow();
}

private static long before(XWPFParagraph paragraph) {
CTSpacing spacing = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getSpacing();
return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore());
}
}
Loading