From 93edc838a4c706f2c0a93bbb029aee208fa54498 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 12 Oct 2026 00:05:05 +0100
Subject: [PATCH 1/2] fix(docx): raise a cell's first line into its padding,
and a row's out of the space above it
- A line opening a cell has no space above it of its own, and under
half a point one Word sets low was left there: a panel's padding and
a table cell's are the cell's margin, and a row's columns have their
space above written above the row's table. ClassicInvoice's "TOTAL
DUE" stood 0.18pt low in its panel in Word and 0.2pt in LibreOffice.
- Under half a point, what the line's space above cannot give comes out
of the top padding of the cell it opens: a panel's, less what its top
border takes, the panel's row held as much taller; a table's composed
cell's, in a row whose margins even. The line owes as much below it,
so what follows stays.
- A row of columns' first row is lifted out of the space above its
table by the most its cells' first lines call for, each line rising
by its own and the space under it taking that back. Not a row the
layout moves to a new page, held to a height, or whose column opens
with a panel or a table.
- A paragraph in a table's composed cell is raised into the space
above it as anywhere else: its row is kept on one page.
- Half a point and more stays the position's.
- A row's margins even only where every cell opens with a paragraph:
a row of columns, a painted container and a shape are written as a
table first too, and keep their margin. The paragraph above a lifted
table is an empty separator only where it holds no picture.
- Docs: the CHANGELOG entry, the recipe's line height row and the
capability matrix's paragraph row.
- Fidelity: the Word and LibreOffice on Windows baselines rewritten for
the documents whose lines moved.
- Examples: the Word export companion's committed DOCX re-rendered, its
row of columns lifted 4 twips.
- Tests: DocxFirstLineSeatTest covers a panel's line and its top border,
a line half a point low and more, a composed cell's line and the cell
beside it, a line under the first in a composed cell, a row's lift, a
cell calling for less, one Word sets high, the space given back above
the next line, a call under a tenth, a lift out of the foot of the
row above, a cell beside a badge, a split panel, a picture above a
row, and rows on a new page, held or opening with a panel. Tests that pin a panel's or a cell's top margin,
or the space above a row, count the default line's raise.
---
CHANGELOG.md | 35 ++
.../examples/word-export-companion.docx | Bin 10221 -> 10225 bytes
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../libreoffice-windows-lines.tsv | 438 +++++++-------
.../docx-fidelity/libreoffice-windows.tsv | 32 +-
.../docx-fidelity/word-windows-lines.tsv | 560 +++++++++---------
.../resources/docx-fidelity/word-windows.tsv | 36 +-
.../semantic/docx/DocxSemanticBackend.java | 308 ++++++++--
.../semantic/docx/DocxComposedCellTest.java | 6 +-
.../semantic/docx/DocxContainerPaintTest.java | 6 +-
.../docx/DocxContainerSpacingTest.java | 10 +-
.../backend/semantic/docx/DocxExports.java | 4 +-
.../semantic/docx/DocxFirstLineSeatTest.java | 432 ++++++++++++++
.../semantic/docx/DocxPanelTailTest.java | 27 +-
.../docx/DocxSpaceOnANewPageTest.java | 6 +-
.../semantic/docx/DocxStandInMoveTest.java | 9 +-
.../docx/DocxVerticalSpacingTest.java | 3 +-
18 files changed, 1325 insertions(+), 591 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 568c15476..b9112872d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,41 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export stands the first line of a panel, a table's composed cell and a row's column where
+ the page sets it, where nothing was written above the line to raise it into.** Word and LibreOffice
+ stand an exact line's baseline four fifths of the way down it, and a line the page sets higher is
+ moved up into the space written above it; under half a point, a line with no such space was left
+ as low as Word set it. A line opening a cell has none of its own: a panel's padding and a table
+ cell's are the cell's margin, and a row's columns have their space above written above the row's
+ table. By the export's own measure of Word's seat, 250 lines of the corpus stood so, a median
+ 0.22pt low; measured, `ClassicInvoice`'s "TOTAL DUE" stood 0.18pt low in its panel in Word and
+ 0.2pt in LibreOffice. Under half a point, a line opening a cell now takes what its space above
+ cannot give from the top padding of the cell it opens — a panel's, less what its top border takes,
+ the panel's row held as much taller, as Word adds the margin left to the height a row is held at;
+ a table's composed cell's, in a row whose margins even, the cells beside it taking the margin back
+ above their first lines — and a row of columns' first row is lifted out of the space above its
+ table by the most its cells' first lines call for, each line rising by its own and the space under
+ it taking that back, so what follows stays. Not a row the layout moves to a new page, whose space
+ above stays on the page above, a row held to a height, or one whose column opens with a panel or a
+ table, which would rise whole. A paragraph in a table's composed cell, in a row the layout placed,
+ is raised into the space above it as anywhere else: the cell has no placement of its own, but its
+ row is kept on one page; one in a table nested in that cell is not yet.
+ Half a point and more is the position's, as before: taken from the padding, `CobaltRota`'s
+ masthead, whose position LibreOffice moves further than it says, came out 0.5 to 0.7pt lower there.
+ LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel held taller
+ than its text — a drawing set where the page puts it — comes out taller there by the raise.
+
+ `ClassicInvoice`'s header, "BILL TO", "FROM" and "TOTAL DUE" moved up 0.24 to 0.36pt in Word and
+ 0.2 to 0.4pt in LibreOffice, `ConsultingInvoice`'s line items 0.24 to 0.36pt and 0.35pt,
+ `ModernProfessional`'s entries 0.24pt and 0.2pt, everything under them where it was. By median,
+ 12 documents moved nearer the page in Word and none further, 10 in LibreOffice on Windows and 9 in
+ LibreOffice 24.2, one further in each: `LumaStudioInvoice`, from 0.12pt to 0.14pt, whose line-items
+ table stands 0.13 to 0.16pt high in LibreOffice at its header's fill and its rules, which its
+ first lines, set low, made up for. In Word, a text cell beside a composed one that rose can stand a
+ step of Word's 0.12pt grid off, either way, its share of the row's margin written as space above
+ it. `ClassicInvoice`'s notes and payment terms still stand 0.2 to 0.26pt low in their rows: their
+ columns are ruled down their left side, panels in their cells, with no padding above their lines.
+
- **A DOCX export stands a table text cell's text where the page sets it by the height of its
lines, where its row's margins leave less than half a point of what it calls for.** Word and
LibreOffice stand an exact line's baseline four fifths of the way down it, and the page sets a
diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx
index f4c27f4816d852c3dfbbaa115c6060c1888f6580..fe15fa70aa8d84c0905980d232e9582b127e3b36 100644
GIT binary patch
delta 3490
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
Klz+o71pW`ok%<)m
delta 3468
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 {
*/
private final java.util.Map> raisedRows =
new java.util.LinkedHashMap<>();
+ /**
+ * The tables whose first row's cells open with a line Word sets low, by cell — see
+ * {@link #noteAFirstLineLow}. In the order they were written, as {@link #raisedRows}.
+ */
+ private final java.util.Map> firstLinesUnderTheRow =
+ new java.util.LinkedHashMap<>();
// The spacers that keep the place of another column layer's content, left out while the
// layer stack they sit in is written as columns (DocxLayerColumns).
private final java.util.Set standIns =
@@ -420,6 +426,26 @@ public final class DocxSemanticBackend implements SemanticBackend {
// How much of the space above the first paragraph of panelCell its top border takes, in twips
// (writePanelPiece): no room to raise that paragraph's line into (raiseIntoTheSpaceAbove).
private long panelsFirstLineKeeps;
+ // How much of the top margin of panelCell its top border takes, in twips (writePanelPiece):
+ // no room to raise its first line into (paddingAbove).
+ private long panelsMarginKeeps;
+ // Whether panelCell's first line may be raised into its top margin: the panel is written whole,
+ // and its row holds its height (writePanelPiece).
+ private boolean panelsPaddingGives;
+ // How much of panelCell's top margin its first line was raised into, in twips: the panel's row
+ // is held as much taller (writePanelPiece).
+ private long panelRaisedFromItsPadding;
+ // The rows of a table the layout placed, and Word keeps whole, before breakRowsWhereTheLayoutDoes
+ // marks them so: their paragraphs are on one page (inARowKeptWhole).
+ private final java.util.Set rowsPlacedWhole =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ // The tables written for a row of columns whose first row raiseRows may lift for its cells'
+ // first lines (noteAFirstLineLow): not opening a page.
+ private final java.util.Set rowsLiftedForTheirLines =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ // Whether the row of columns being written is a block the layout moves to a new page
+ // (writeTableWithItsOwnSpacing): the space above its table stays on the page above.
+ private boolean rowOpeningItsPage;
// The text style the document is mostly written in, promoted to Word's Normal style.
// Null until an export computes it, and when the graph carries no text at all.
private DocumentTextStyle documentDefaultStyle;
@@ -819,6 +845,13 @@ private byte[] write(List sections, Path outputFile) throws Exc
cellTextShift = 0;
paragraphOpeningItsPage = null;
panelsFirstLineKeeps = 0;
+ panelsMarginKeeps = 0;
+ panelsPaddingGives = false;
+ panelRaisedFromItsPadding = 0;
+ rowsPlacedWhole.clear();
+ firstLinesUnderTheRow.clear();
+ rowsLiftedForTheirLines.clear();
+ rowOpeningItsPage = false;
lastRaise = 0;
blocksAtTheLastRaise = -1;
nextDrawingId = 100_000;
@@ -5420,14 +5453,27 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
// first paragraph (takeTheTopBorderInside, below): that much of it is no room to raise a
// line into.
long outerKeptAbove = panelsFirstLineKeeps;
+ long outerMarginKept = panelsMarginKeeps;
+ boolean outerGives = panelsPaddingGives;
+ long outerRaised = panelRaisedFromItsPadding;
panelsFirstLineKeeps = Math.max(0, toTwips(topNotTaken) - cellMargin(cell, true));
+ // What the padding takes of it is no room either (paddingAbove). A shape's layers are
+ // centred in the outline it holds whole, and its first line rises into nothing of it.
+ panelsMarginKeeps = Math.min(cellMargin(cell, true), toTwips(topNotTaken));
+ panelsPaddingGives = first && last && !(node instanceof ShapeContainerNode);
+ panelRaisedFromItsPadding = 0;
+ long raisedFromThePadding;
try {
writeInCell(cell, content);
} finally {
+ raisedFromThePadding = panelRaisedFromItsPadding;
surfaceBehind = outerSurface;
currentCellWidth = outerCellWidth;
panelCell = outerPanelCell;
panelsFirstLineKeeps = outerKeptAbove;
+ panelsMarginKeeps = outerMarginKept;
+ panelsPaddingGives = outerGives;
+ panelRaisedFromItsPadding = outerRaised;
}
if (cell.getParagraphs().isEmpty()) {
// A panel with nothing Word can hold inside is its padding tall on the page, not a
@@ -5454,7 +5500,11 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
// Its height is the page's: what makes a panel taller than its text — an icon drawn
// where the page puts it, a fixed outline — is not in the cell. MerchantInvoice's
// due-date card closed from 59.4pt to its text's 26, and its calendar hung below it.
- holdThePanelsHeight(table.getRow(0), placed.placementHeight() - fromTheContent, padding, heldWhole, false);
+ // A first line raised out of its padding is held that much taller: Word adds the
+ // margin left to the height a row is held at, and the panel held at its content's
+ // would come out that much shorter, all under it higher.
+ holdThePanelsHeight(table.getRow(0), placed.placementHeight() - fromTheContent
+ + raisedFromThePadding / POINT_TO_TWIP, padding, heldWhole, false);
} else if (first && last && layout.placement(node) == null && node instanceof ShapeContainerNode shape
&& shape.outline().height() > 0) {
// Composed in a table cell, it has no placement; its outline states its height, as it
@@ -7090,10 +7140,33 @@ private void standsAboveItsCell(double points) {
* two tables apart and holds none of it, by the foot of the table above
* ({@link #takeFromTheFoot}). Every other cell of the row then starts that much lower inside,
* and the cell whose text stood out by less than the lift starts lower by the difference.
+ *
+ * A row of columns whose cells open with a line Word sets low, by what neither the space
+ * above it nor a padding could take ({@link #noteAFirstLineLow}), is lifted by the most any
+ * calls for too, and only that line rises: the space under it takes back what it rose
+ * ({@link #liftEachCell}). {@code ModernProfessional}'s entries, a title beside its dates in a
+ * row of their own, rose 0.24pt in Word and 0.2pt in LibreOffice, nearer the page by as much,
+ * all under them where it was (measured).
*/
private void raiseRows() {
- for (java.util.Map.Entry> noted : raisedRows.entrySet()) {
- XWPFTable table = noted.getKey();
+ java.util.Set tables = new java.util.LinkedHashSet<>(raisedRows.keySet());
+ tables.addAll(firstLinesUnderTheRow.keySet());
+ for (XWPFTable table : tables) {
+ java.util.Map standing = raisedRows.getOrDefault(table, java.util.Map.of());
+ java.util.Map firsts = firstLinesUnderTheRow.getOrDefault(table, java.util.Map.of());
+ if (!firsts.isEmpty() && !liftsForItsFirstLines(table)) {
+ firsts = java.util.Map.of();
+ }
+ if (standing.isEmpty() && firsts.isEmpty()) {
+ continue;
+ }
+ double most = 0;
+ for (double points : standing.values()) {
+ most = Math.max(most, points);
+ }
+ for (FirstLine first : firsts.values()) {
+ most = Math.max(most, first.low());
+ }
List around = table.getBody().getBodyElements();
int index = around.indexOf(table);
if (index <= 0 || !(around.get(index - 1) instanceof XWPFParagraph above)) {
@@ -7105,21 +7178,21 @@ private void raiseRows() {
}
CTSpacing gap = properties.getSpacing();
long after = gap.isSetAfter() ? twipsOf(gap.getAfter()) : 0;
- // An empty paragraph between two blocks is a separator, except a rule: its space
- // above is where the page draws the line.
- boolean separator = above.getText().isEmpty() && !properties.isSetPBdr();
+ // An empty paragraph between two blocks is a separator, except a rule, whose space
+ // above is where the page draws the line, and a picture set in its line, which the
+ // space above places — a drawing anchored to the page may hang on it all the same.
+ boolean separator = above.getText().isEmpty() && !properties.isSetPBdr() && !takesRoom(above);
long before = separator && gap.isSetBefore() ? twipsOf(gap.getBefore()) : 0;
- long wanted = toTwips(java.util.Collections.max(noted.getValue().values()));
+ long wanted = toTwips(most);
long lift = Math.min(after + before, wanted);
- // The paragraph keeping two tables apart takes no room — a drawing anchored to the page
- // may hang on it — and keeps with the table after.
- boolean betweenTables = separator && !takesRoom(above) && properties.isSetKeepNext()
+ // The paragraph keeping two tables apart keeps with the table after.
+ boolean betweenTables = separator && properties.isSetKeepNext()
&& index >= 2 && around.get(index - 2) instanceof XWPFTable;
if (lift <= 0 && betweenTables && around.get(index - 2) instanceof XWPFTable previous) {
// Between two tables the gap is the one above's: its cells end with it.
lift = takeFromTheFoot(previous, wanted);
if (lift > 0) {
- liftEachCell(table, noted.getValue(), lift);
+ liftEachCell(table, standing, firsts, lift);
}
continue;
}
@@ -7133,23 +7206,65 @@ private void raiseRows() {
if (lift > fromAfter) {
gap.setBefore(BigInteger.valueOf(before - (lift - fromAfter)));
}
- liftEachCell(table, noted.getValue(), lift);
+ liftEachCell(table, standing, firsts, lift);
}
raisedRows.clear();
+ firstLinesUnderTheRow.clear();
}
/**
* Starts each cell of a lifted table's first row that much lower inside, less what its own
* text stood above the row: the cell whose text stood out by the whole lift keeps its place.
+ * A cell whose first line Word set low rises by as much of the lift as it called for, and what
+ * follows that line keeps its place.
*/
- private void liftEachCell(XWPFTable table, java.util.Map standing, long lift) {
+ private void liftEachCell(XWPFTable table, java.util.Map standing,
+ java.util.Map firsts, long lift) {
for (XWPFTableCell cell : table.getRow(0).getTableCells()) {
long own = Math.min(lift, toTwips(standing.getOrDefault(cell, 0.0)));
- if (lift - own > 0 && !cell.getBodyElements().isEmpty()
+ FirstLine low = firsts.get(cell);
+ long under = low == null ? 0 : Math.min(lift - own, toTwips(low.low()));
+ if (lift - own - under > 0 && !cell.getBodyElements().isEmpty()
&& cell.getBodyElements().get(0) instanceof XWPFParagraph first) {
- addSpacing(first, (lift - own) / POINT_TO_TWIP, 0);
+ addSpacing(first, (lift - own - under) / POINT_TO_TWIP, 0);
+ }
+ if (under > 0) {
+ giveBackBelow(low.paragraph(), under);
+ }
+ }
+ }
+
+ /**
+ * Whether the first row of a row of columns' table may be lifted for its cells' first lines
+ * ({@link #noteAFirstLineLow}): not held to a height, which the space its cells take would not
+ * make taller in Word, so that what follows would stand higher by the lift; and every cell
+ * opening with a paragraph to take the lift back above: a column opening with a panel or a
+ * table would rise whole. Nothing of such a row is drawn — a row painted or framed is written
+ * as a panel round its table ({@link #writePaintedRow}), a column painted as a panel in its
+ * cell — so no box the reader sees moves.
+ */
+ private static boolean liftsForItsFirstLines(XWPFTable table) {
+ XWPFTableRow row = table.getRow(0);
+ if (row.getCtRow().isSetTrPr() && row.getCtRow().getTrPr().sizeOfTrHeightArray() > 0) {
+ return false;
+ }
+ for (XWPFTableCell cell : row.getTableCells()) {
+ if (cell.getBodyElements().isEmpty() || !(cell.getBodyElements().get(0) instanceof XWPFParagraph)) {
+ return false;
}
}
+ return true;
+ }
+
+ /** Puts {@code twips} of space below a paragraph: above the paragraph after it, or after it where none follows. */
+ private static void giveBackBelow(XWPFParagraph paragraph, long twips) {
+ List around = paragraph.getBody().getBodyElements();
+ int at = around.indexOf(paragraph);
+ if (at + 1 < around.size() && around.get(at + 1) instanceof XWPFParagraph next) {
+ addSpacingTwips(next, twips, 0);
+ } else {
+ addSpacingTwips(paragraph, 0, twips);
+ }
}
/**
@@ -9102,22 +9217,32 @@ private void applyLineGap(XWPFParagraph target, double gap, int lines, java.util
*/
private static boolean everyMarginEvens(List row) {
for (TableGrid.Placement placement : row) {
- if (placement.rowSpan() > 1 || opensWithATable(placement.cell().content())) {
+ if (placement.rowSpan() > 1 || !opensWithAParagraph(placement.cell().content())) {
return false;
}
}
return true;
}
- /** Whether writing a node writes a table first: the node is one, or its first child does. */
- private static boolean opensWithATable(DocumentNode node) {
- for (DocumentNode current = node; current != null;
- current = current.children().isEmpty() ? null : current.children().get(0)) {
- if (current instanceof TableNode) {
+ /**
+ * Whether writing a cell's content writes a paragraph first: a text cell's, a paragraph, a
+ * list, or a section or container painting nothing whose first child does. A cell opening with
+ * anything else may open with a table — a table, a row of columns, a painted container or a
+ * shape, each written as one — and keeps its margin, which then evens the row's
+ * ({@link #canMoveItsPadding}): a paragraph beside it given its padding would rise into a margin
+ * Word does not set. Read off the content as authored, before any of the row is written.
+ */
+ private static boolean opensWithAParagraph(DocumentNode node) {
+ for (DocumentNode current = node; ; current = current.children().get(0)) {
+ if (current == null || current instanceof ParagraphNode
+ || current instanceof com.demcha.compose.document.node.ListNode) {
return true;
}
+ if (!(current instanceof SectionNode || current instanceof ContainerNode)
+ || !paintOf(current).isEmpty() || current.children().isEmpty()) {
+ return false;
+ }
}
- return false;
}
/**
@@ -9727,7 +9852,11 @@ private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, d
// off by its position, not four tenths.
boolean moved = Math.abs(shift) >= LEAST_BASELINE_SHIFT_POINTS || cutToFit;
if (ownsItsLine && !cutToFit) {
- shift -= raiseIntoTheSpaceAbove(para, node, spacing, shift);
+ // Under half a point the position moves nothing, so the cell's padding may.
+ shift -= raiseIntoTheSpaceAbove(para, node, spacing, shift, !moved);
+ if (!moved) {
+ noteAFirstLineLow(para, shift);
+ }
}
return moved ? shift : 0;
}
@@ -9763,37 +9892,135 @@ private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, d
* ({@link #holdAParagraphsTopEdgeOnItsPage}), and what the line owed would stand all below it
* lower. Not a paragraph the layout breaks over a page: its lines on the next page start at
* that page's top, where the space above moves none of them, and what it owed would stand
- * what follows them lower. Not the space above a panel's first paragraph that the panel's top
- * border takes ({@link #takeTheTopBorderInside}): raised into it, the line would give it back
- * and the panel grow by what it owed. Less than {@link #LEAST_RAISE_INTO_THE_SPACE_ABOVE} is
- * not moved.
- *
- * @param para the Word paragraph
- * @param node the paragraph it is written for
- * @param spacing the paragraph's spacing, its space above already written
- * @param raise how far Word sets the line below the page's, in points
+ * what follows them lower. A paragraph in a cell of a row Word keeps whole is on one page
+ * wherever the row lands, though a table's composed cell has no placement of its own to say
+ * so ({@link #inARowKeptWhole}). Not the space above a panel's first paragraph that the
+ * panel's top border takes ({@link #takeTheTopBorderInside}): raised into it, the line would
+ * give it back and the panel grow by what it owed. Less than
+ * {@link #LEAST_RAISE_INTO_THE_SPACE_ABOVE} is not moved.
+ *
+ * A paragraph opening a cell has no space above it where the cell's padding is its own
+ * margin, and its line was left as low as Word set it under half a point. What its space above
+ * cannot give, it takes from that padding, where the position would move nothing
+ * ({@link #paddingAbove}): {@code ConsultingInvoice}'s line items, composed cells whose first
+ * lines Word set 0.33pt low, moved up 0.35pt in both LibreOffice builds and 0.24 to 0.36pt on
+ * Word's grid (measured). Half a point and more is the position's, as
+ * before: taken from the padding, the 3.4pt raise of {@code CobaltRota}'s masthead, whose
+ * position LibreOffice moves further than it is raised, came out 0.5 to 0.7pt lower there
+ * (measured).
+ *
+ * @param para the Word paragraph
+ * @param node the paragraph it is written for
+ * @param spacing the paragraph's spacing, its space above already written
+ * @param raise how far Word sets the line below the page's, in points
+ * @param intoThePadding whether the cell's padding may give what the space above cannot
* @return how far the line was raised, in points
*/
private double raiseIntoTheSpaceAbove(XWPFParagraph para, ParagraphNode node, CTSpacing spacing,
- double raise) {
+ double raise, boolean intoThePadding) {
// A band and the text laid over the flow are overlays too (writeOverlayBand, writeOverTheFlow).
if (!(raise >= LEAST_RAISE_INTO_THE_SPACE_ABOVE) || overlayDepth > 0
- || node == paragraphOpeningItsPage || !layout.onOnePage(node)) {
+ || node == paragraphOpeningItsPage || !layout.onOnePage(node) && !inARowKeptWhole()) {
return 0;
}
long room = twipsOf(spacing.isSetBefore() ? spacing.getBefore() : null)
- (opensThePanel(para) ? panelsFirstLineKeeps : 0);
- long raised = Math.min(room, Math.round(raise * POINT_TO_TWIP));
+ long wanted = Math.round(raise * POINT_TO_TWIP);
+ long fromAbove = Math.max(0, Math.min(room, wanted));
+ long fromTheCell = intoThePadding ? Math.min(wanted - fromAbove, paddingAbove(para)) : 0;
+ long raised = fromAbove + fromTheCell;
if (raised < Math.round(LEAST_RAISE_INTO_THE_SPACE_ABOVE * POINT_TO_TWIP)) {
return 0;
}
- spacing.setBefore(BigInteger.valueOf(twipsOf(spacing.getBefore()) - raised));
+ if (fromAbove > 0) {
+ spacing.setBefore(BigInteger.valueOf(twipsOf(spacing.getBefore()) - fromAbove));
+ }
+ if (fromTheCell > 0) {
+ setCellMarginTwips(currentCell, true, cellMargin(currentCell, true) - fromTheCell);
+ if (currentCell == panelCell) {
+ panelRaisedFromItsPadding += fromTheCell;
+ }
+ }
owePendingSpacingAfter(raised / POINT_TO_TWIP);
lastRaise = raised / POINT_TO_TWIP;
blocksAtTheLastRaise = blocksWritten;
return raised / POINT_TO_TWIP;
}
+ /**
+ * Whether the cell being written is in a row Word keeps whole on one page: a row the layout
+ * placed, kept so ({@link #breakRowsWhereTheLayoutDoes}, {@link #rowsPlacedWhole}), or one
+ * already marked so.
+ */
+ private boolean inARowKeptWhole() {
+ return currentCell != null && (currentCell.getTableRow().isCantSplitRow()
+ || rowsPlacedWhole.contains(currentCell.getTableRow().getCtRow()));
+ }
+
+ /**
+ * How much of the top padding of the cell a paragraph opens a raise may take, in twips
+ * ({@link #raiseIntoTheSpaceAbove}).
+ *
+ * A table's cell gives its padding where every margin of its row evens
+ * ({@link #takeFromTheTopOfItsCell}): the row then gives each cell the least top margin and
+ * the rest as space above its first paragraph ({@link #evenTheRowsMargins}), so the cells
+ * beside one raised keep their places — in Word to a step of its 0.12pt grid, set from the
+ * top, the middle or the bottom alike (measured). A panel written whole gives what of its padding its
+ * top border does not take ({@link #panelsMarginKeeps}), and its row is held as much taller
+ * ({@link #writePanelPiece}), as Word adds the margin left to the height a row is held at.
+ * LibreOffice 24.2 counts the margins inside that height ({@link #seatTheLabelByItsLine}): a
+ * panel held taller than its text — a drawing set where the page puts it — comes out taller
+ * there by the raise, under half a point. A shape's layers are centred in it, and none
+ * rises.
+ */
+ private long paddingAbove(XWPFParagraph para) {
+ if (panelsPaddingGives && opensThePanel(para)) {
+ return Math.max(0, cellMargin(panelCell, true) - panelsMarginKeeps);
+ }
+ if (opensTheCell(para) && cellsGivingTheirPadding.contains(currentCell.getCTTc())) {
+ return cellMargin(currentCell, true);
+ }
+ return 0;
+ }
+
+ /** Whether a paragraph is the first thing written in the cell being written. */
+ private boolean opensTheCell(XWPFParagraph para) {
+ return currentCell != null && !currentCell.getBodyElements().isEmpty()
+ && currentCell.getBodyElements().get(0) instanceof XWPFParagraph first
+ && first.getCTP() == para.getCTP();
+ }
+
+ /**
+ * Notes a cell's first line that Word still sets low after its raise, for {@link #raiseRows} to
+ * lift its row out of the space above its table.
+ *
+ * A cell of a row of columns has no padding of its own above its first paragraph: the
+ * columns' space above is the row's, written above its table. Only a first row the export
+ * wrote for a row of columns ({@link #rowsLiftedForTheirLines}), and only where its line is not
+ * laid over the flow, whose space owed below it would be lost.
+ *
+ * @param low how far Word still sets the line below the page's, in points
+ */
+ private void noteAFirstLineLow(XWPFParagraph para, double low) {
+ if (!(low >= LEAST_RAISE_INTO_THE_SPACE_ABOVE) || overlayDepth > 0 || !opensTheCell(para)) {
+ return;
+ }
+ XWPFTable table = currentCell.getTableRow().getTable();
+ if (table.getRow(0) == currentCell.getTableRow() && rowsLiftedForTheirLines.contains(table)) {
+ firstLinesUnderTheRow.computeIfAbsent(table, key -> new java.util.LinkedHashMap<>())
+ .put(currentCell, new FirstLine(para, low));
+ }
+ }
+
+ /**
+ * A cell's first paragraph, and how far Word sets its line below the page's, in points.
+ *
+ * @param paragraph the cell's first paragraph
+ * @param low how far below, in points
+ */
+ private record FirstLine(XWPFParagraph paragraph, double low) {
+ }
+
/** Whether a paragraph is the first thing written in the panel's cell being written. */
private boolean opensThePanel(XWPFParagraph para) {
return panelCell != null && currentCell == panelCell && !panelCell.getBodyElements().isEmpty()
@@ -11309,6 +11536,8 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
boolean table = node instanceof TableNode;
insetLeft += (node == leftMarginInCell ? 0 : node.margin().left()) + (table ? node.padding().left() : 0);
insetRight += node.margin().right() + (table ? node.padding().right() : 0);
+ boolean outerOpening = rowOpeningItsPage;
+ rowOpeningItsPage = onANewPage;
try {
if (node instanceof RowNode row) {
writeRow(document, row, false);
@@ -11318,6 +11547,7 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod
} finally {
insetLeft = outerLeft;
insetRight = outerRight;
+ rowOpeningItsPage = outerOpening;
}
owePendingSpacingAfter(node.margin().bottom() + node.padding().bottom());
}
@@ -12048,6 +12278,9 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
if (marginsEven) {
cellsGivingTheirPadding.add(cell.getCTTc());
}
+ if (layout.placedRow(node, rowIdx)) {
+ rowsPlacedWhole.add(row.getCtRow());
+ }
// What the cell holds sits on the cell's fill — a stripe, not the card around
// the table — wherever it has no shading of its own.
DocumentColor outerSurface = surfaceBehind;
@@ -13065,6 +13298,9 @@ private void writeRow(XWPFDocument document, RowNode node, boolean inItsPanel) t
return;
}
XWPFTable table = newTable(document, 1, node.children().size());
+ if (!rowOpeningItsPage) {
+ rowsLiftedForTheirLines.add(table);
+ }
// A row is a layout device, not a table anybody asked to see. POI's createTable
// ships Word's default single-line grid, so without this every two-column block —
// a header pair, a label beside a value — exported with visible rules the PDF
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java
index 4a1e6ff43..6ac1f7606 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java
@@ -813,8 +813,10 @@ void aRowsHeightIsWrittenLessTheMarginsItsCellsHold() throws Exception {
int asTheCell = heightOf(document.getTables().get(1));
// 12pt above and below, less the default 1pt rule on each side that Word gives
- // room of its own.
- assertThat(inside - asTheCell).as("the second cell's 22pt of margins").isEqualTo(22 * 20);
+ // room of its own, and less what the second cell's first line rose into its
+ // padding: the margin left is that much less, and the row's height that much more.
+ assertThat(inside - asTheCell).as("the second cell's 22pt of margins")
+ .isEqualTo(22 * 20 - DocxExports.DEFAULT_LINE_RAISE);
}
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java
index aa9bb3e02..04ad461db 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java
@@ -132,7 +132,8 @@ void thePaddingIsTheCellsMarginsAndTheTableStartsAtTheCardsEdge() throws Excepti
XWPFTable table = document.getTables().get(0);
var margins = onlyCell(document).getCTTc().getTcPr().getTcMar();
- assertThat(DocxTwips.of(margins.getTop().getW())).isEqualTo(10 * 20L);
+ // Less what its first line rises into it, as Word sets that line low.
+ assertThat(DocxTwips.of(margins.getTop().getW())).isEqualTo(10 * 20L - DocxExports.DEFAULT_LINE_RAISE);
assertThat(DocxTwips.of(margins.getRight().getW())).isEqualTo(14 * 20L);
assertThat(DocxTwips.of(margins.getBottom().getW())).isEqualTo(12 * 20L);
assertThat(DocxTwips.of(margins.getLeft().getW())).isEqualTo(16 * 20L);
@@ -182,7 +183,8 @@ void aBorderStraddlesThePanelsEdgeAsOnThePage() throws Exception {
var margins = onlyCell(document).getCTTc().getTcPr().getTcMar();
assertThat(DocxTwips.of(margins.getLeft().getW())).as("the padding, all of it").isEqualTo(280L);
- assertThat(DocxTwips.of(margins.getTop().getW())).as("no border on top").isEqualTo(280L);
+ assertThat(DocxTwips.of(margins.getTop().getW())).as("no border on top, less what its first line rises into")
+ .isEqualTo(280L - DocxExports.DEFAULT_LINE_RAISE);
assertThat(DocxTwips.of(table.getCTTbl().getTblPr().getTblW().getW()))
.as("no wider on its left, where its border is centred on its edge")
.isEqualTo(Math.round((placed + DocxSemanticBackend.EDITOR_COLUMN_SLACK_POINTS) * 20));
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
index 965f5090d..60b582e6c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerSpacingTest.java
@@ -81,7 +81,9 @@ void aPanelsSpacingStandsBetweenTheChildrenInItsCell() throws Exception {
List paragraphs = written(cell.getParagraphs());
assertThat(paragraphs).extracting(XWPFParagraph::getText).containsExactly("First", "Second");
- assertThat(before(paragraphs.get(1))).isEqualTo(NINE_POINTS - RAISE);
+ // The first line rises into the panel's padding and owes the second as much, which
+ // the second rises into.
+ assertThat(before(paragraphs.get(1))).isEqualTo(NINE_POINTS);
}
}
@@ -249,7 +251,8 @@ void aTableOpeningTheDocumentKeepsTheSpaceAboveIt() throws Exception {
assertThat(body.get(0)).as("a paragraph before the table carries the space")
.isInstanceOf(XWPFParagraph.class);
- assertThat(before((XWPFParagraph) body.get(0))).isEqualTo(18 * 20L);
+ // Less what the row's first lines, set low in Word, rise out of it.
+ assertThat(before((XWPFParagraph) body.get(0))).isEqualTo(18 * 20L - RAISE);
assertThat(body.get(1)).isInstanceOf(XWPFTable.class);
}
}
@@ -323,7 +326,8 @@ void aTableAfterAPageBreakKeepsTheSpaceAboveIt() throws Exception {
assertThat(body.get(table - 1)).as("a paragraph after the break carries the space")
.isInstanceOf(XWPFParagraph.class);
- assertThat(before((XWPFParagraph) body.get(table - 1))).isGreaterThanOrEqualTo(18 * 20L);
+ // Less what the row's first lines, set low in Word, rise out of it.
+ assertThat(before((XWPFParagraph) body.get(table - 1))).isGreaterThanOrEqualTo(18 * 20L - RAISE);
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
index 39df7c04e..31d19296c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
@@ -35,7 +35,9 @@ final class DocxExports {
* test of the space between blocks of default text reads the space above such a line as that
* much less, and the space below it as that much more. A table cell's default face is the same
* text, and a row of such cells is moved up as much through its margins
- * ({@code DocxCellSeatTest}).
+ * ({@code DocxCellSeatTest}); a panel's or a composed cell's first line rises as much into its
+ * top padding, and a row of columns' out of the space above its table
+ * ({@code DocxFirstLineSeatTest}).
*/
static final long DEFAULT_LINE_RAISE = 6;
/** {@link #DEFAULT_LINE_RAISE} for the default face at 7pt, the size tests set small text in. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
new file mode 100644
index 000000000..6a56f24d5
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
@@ -0,0 +1,432 @@
+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.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+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.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+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.font.FontName;
+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 org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+
+import java.io.ByteArrayInputStream;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The first line of a panel or a table's cell stands where the page sets it, where nothing was
+ * written above it to raise it into.
+ *
+ * Word stands an exact line's baseline four fifths of the way down it, and the default text,
+ * Helvetica at 14pt, {@link DocxExports#DEFAULT_LINE_RAISE} twips lower than the page. A line
+ * opening a cell has no space above it of its own: the cell's padding is its margin, and a row
+ * of columns' space above is written above its table. What the space above cannot give, the
+ * padding gives, and above a row of columns the space above its table; what follows the line
+ * keeps its place.
+ */
+class DocxFirstLineSeatTest {
+
+ private static final long RAISE = DocxExports.DEFAULT_LINE_RAISE;
+ private static final long SMALL_RAISE = DocxExports.SMALL_LINE_RAISE;
+ private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
+ private static final DocumentStroke RULE = DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 2);
+ private static final DocumentTextStyle SMALL = DocumentTextStyle.DEFAULT.withSize(7);
+
+ @Test
+ void aPanelsFirstLineRisesIntoItsPadding() throws Exception {
+ Exported exported = export(page -> page.addParagraph("Before").addSection("Card", card -> card
+ .fillColor(SURFACE).padding(DocumentInsets.of(8)).addParagraph("Inside")));
+ XWPFTableCell panel = onlyTable(exported.document()).getRow(0).getCell(0);
+ XWPFParagraph inside = panel.getParagraphs().get(0);
+
+ assertThat(topMargin(panel)).as("its padding, less what its line rose into").isEqualTo(8 * 20 - RAISE);
+ assertThat(after(inside)).as("given back below the line, what follows where it was").isEqualTo(RAISE);
+ assertThat(position(inside)).isZero();
+ assertThat((long) panel.getTableRow().getHeight()).as("held as much taller as Word adds the margin left")
+ .isEqualTo(Math.round((exported.heights().get("Card") - 16) * 20) + RAISE);
+ }
+
+ @Test
+ void aPanelsTopBorderKeepsWhatItTakesOfThePadding() throws Exception {
+ // Padded as far as its top border reaches past its box: the border takes the padding.
+ Exported exported = export(page -> page.addParagraph("Before").addSection("Card", card -> card
+ .fillColor(SURFACE).borders(DocumentBorders.top(RULE)).padding(DocumentInsets.of(2))
+ .addParagraph("Inside")));
+ XWPFTableCell panel = onlyTable(exported.document()).getRow(0).getCell(0);
+
+ assertThat(topMargin(panel)).isZero();
+ assertThat(after(panel.getParagraphs().get(0))).as("nothing risen, nothing owed").isZero();
+ assertThat(exported.report().bySubject()).doesNotContainKey("SectionNode");
+ }
+
+ @Test
+ void aLineHalfAPointLowAndMoreKeepsItsPosition() throws Exception {
+ // Spectral's 30pt line stands 4.9pt low in Word: the position moves it, as before.
+ Exported exported = export(page -> page.addSection("Card", card -> card.fillColor(SURFACE)
+ .padding(DocumentInsets.of(8)).addParagraph(p -> p.text("Title").textStyle(spectral(30)))));
+ XWPFTableCell panel = onlyTable(exported.document()).getRow(0).getCell(0);
+
+ assertThat(topMargin(panel)).as("its padding, all of it").isEqualTo(8 * 20);
+ assertThat(position(panel.getParagraphs().get(0))).isPositive();
+ }
+
+ @Test
+ void aComposedCellsFirstLineRisesIntoItsCellsPadding() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(160))
+ .rowCells(composed(new ParagraphBuilder().text("Inside").build())))
+ .addParagraph("After"))) {
+ XWPFTableCell cell = onlyTable(document).getRow(0).getCell(0);
+ XWPFParagraph inside = cell.getParagraphs().get(0);
+
+ assertThat(topMargin(cell)).as("its padding, less what its line rose into").isEqualTo(4 * 20 - RAISE);
+ assertThat(after(inside)).isEqualTo(RAISE);
+ assertThat(position(inside)).isZero();
+ }
+ }
+
+ @Test
+ void aCellBesideOneOpeningWithAPanelKeepsItsPadding() throws Exception {
+ // A badge, a painted section, is a table in its cell, which keeps its margin; Word gives the
+ // row's cells its margin, the largest, and a line risen into a smaller one would not rise.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(160), DocumentTableColumn.fixed(160))
+ .rowCells(composed(new ParagraphBuilder().text("Inside").build()),
+ composed(new SectionBuilder().name("Badge").fillColor(SURFACE)
+ .padding(DocumentInsets.of(4)).addParagraph("Paid").build())))
+ .addParagraph("After"))) {
+ XWPFTableCell inside = onlyTable(document).getRow(0).getCell(0);
+
+ assertThat(onlyTable(document).getRow(0).getCell(1).getBodyElements().get(0))
+ .as("the premise: the badge is a table").isInstanceOf(XWPFTable.class);
+ assertThat(topMargin(inside)).as("its padding, all of it").isEqualTo(4 * 20);
+ assertThat(after(inside.getParagraphs().get(0))).isZero();
+ }
+ }
+
+ @Test
+ void aPanelSplitOverAPageKeepsItsPadding() throws Exception {
+ // Only a panel written whole is held to a height its raise can be added to.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection("Card", card -> card
+ .fillColor(SURFACE).padding(DocumentInsets.of(8)).addParagraph("First")
+ .addPageBreak(b -> b.name("Break")).addParagraph("Second")))) {
+ XWPFTableCell first = document.getTables().get(0).getRow(0).getCell(0);
+
+ assertThat(document.getTables()).as("the premise: a table a piece").hasSize(2);
+ assertThat(topMargin(first)).as("its padding, all of it").isEqualTo(8 * 20);
+ assertThat(after(first.getParagraphs().get(0))).isZero();
+ }
+ }
+
+ @Test
+ void theCellBesideOneThatRoseKeepsItsPlace() throws Exception {
+ // Lato at 10pt Word sets high: it does not rise, and takes the row's margin back above it.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(160), DocumentTableColumn.fixed(160))
+ .rowCells(composed(new ParagraphBuilder().text("Inside").build()),
+ composed(new ParagraphBuilder().text("Beside").textStyle(lato(10)).build())))
+ .addParagraph("After"))) {
+ XWPFTableCell beside = onlyTable(document).getRow(0).getCell(1);
+
+ assertThat(topMargin(beside)).as("the row's margin").isEqualTo(4 * 20 - RAISE);
+ assertThat(before(beside.getParagraphs().get(0))).as("what it gave, above its line").isEqualTo(RAISE);
+ assertThat(after(beside.getParagraphs().get(0))).isZero();
+ }
+ }
+
+ @Test
+ void aLineInAComposedCellRisesIntoTheSpaceAboveIt() throws Exception {
+ // A composed cell has no placement of its own, but its row is kept on one page.
+ DocumentTableCell cell = DocumentTableCell.node(new SectionBuilder().name("Two").spacing(9)
+ .addParagraph("First").addParagraph("Second").build())
+ .withStyle(DocumentTableStyle.builder().padding(DocumentInsets.zero()).build());
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(160)).rowCells(cell))
+ .addParagraph("After"))) {
+ List lines = onlyTable(document).getRow(0).getCell(0).getParagraphs();
+
+ assertThat(lines).extracting(XWPFParagraph::getText).containsExactly("First", "Second");
+ assertThat(before(lines.get(1))).isEqualTo(9 * 20 - RAISE);
+ assertThat(after(lines.get(1))).isEqualTo(RAISE);
+ }
+ }
+
+ @Test
+ void aRowsFirstLinesRiseOutOfTheSpaceAboveItsTable() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(10)
+ .addParagraph("Above")
+ .addRow(row -> row.addParagraph("Left").addParagraph("Right"))
+ .addParagraph("After"))) {
+ XWPFTable table = onlyTable(document);
+
+ assertThat(after(paragraphBefore(document, table))).as("the gap, less the lift").isEqualTo(10 * 20 - RAISE);
+ for (XWPFTableCell cell : table.getRow(0).getTableCells()) {
+ assertThat(before(cell.getParagraphs().get(0))).isZero();
+ assertThat(after(cell.getParagraphs().get(0))).as("given back below the line").isEqualTo(RAISE);
+ }
+ }
+ }
+
+ @Test
+ void aRowUnderAnotherRisesOutOfTheFootOfTheOneAbove() throws Exception {
+ // Two rows of columns in a row are two tables held apart by an empty paragraph; where the
+ // columns above end padded, the gap is theirs: their cells end with it.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(0)
+ .addParagraph("Above")
+ .addRow(row -> row
+ .addSection("Left", column -> column.padding(new DocumentInsets(0, 0, 10, 0)).addParagraph("Left"))
+ .addSection("Right", column -> column.padding(new DocumentInsets(0, 0, 10, 0)).addParagraph("Right")))
+ .addRow(row -> row.addParagraph("Lower").addParagraph("Other"))
+ .addParagraph("After"))) {
+ XWPFTable upper = document.getTables().get(0);
+ XWPFTable lower = document.getTables().get(1);
+
+ for (XWPFTableCell cell : upper.getRow(0).getTableCells()) {
+ assertThat(after(cell.getParagraphs().get(0))).as("the gap, less the lift under it")
+ .isEqualTo(10 * 20 - RAISE);
+ }
+ for (XWPFTableCell cell : lower.getRow(0).getTableCells()) {
+ assertThat(after(cell.getParagraphs().get(0))).as("given back below the line").isEqualTo(RAISE);
+ }
+ }
+ }
+
+ @Test
+ void aPictureAboveARowKeepsTheSpaceAboveIt() throws Exception {
+ // A paragraph holding a picture is no empty separator: its space above places the picture.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(0)
+ .addParagraph("Above")
+ .addImage(image -> image.source(com.demcha.compose.document.image.DocumentImageData.fromBytes(png()))
+ .size(20, 20).margin(new DocumentInsets(10, 0, 0, 0)))
+ .addRow(row -> row.addParagraph("Left").addParagraph("Right"))
+ .addParagraph("After"))) {
+ XWPFTable table = onlyTable(document);
+ XWPFParagraph picture = paragraphBefore(document, table);
+
+ assertThat(picture.getText()).as("the premise: the picture's paragraph, holding no text").isEmpty();
+ assertThat(before(picture)).isEqualTo(10 * 20);
+ assertThat(after(table.getRow(0).getCell(0).getParagraphs().get(0))).isZero();
+ }
+ }
+
+ private static byte[] png() {
+ try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
+ javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(8, 8,
+ java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out);
+ return out.toByteArray();
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException(failure);
+ }
+ }
+
+ @Test
+ void aCellWhoseLineCallsForLessStartsThatMuchLower() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(10)
+ .addParagraph("Above")
+ .addRow(row -> row.addParagraph("Left").addParagraph(p -> p.text("Right").textStyle(SMALL)))
+ .addParagraph("After"))) {
+ XWPFTableCell small = onlyTable(document).getRow(0).getCell(1);
+
+ assertThat(before(small.getParagraphs().get(0))).as("lifted further than it called for")
+ .isEqualTo(RAISE - SMALL_RAISE);
+ assertThat(after(small.getParagraphs().get(0))).as("what it rose").isEqualTo(SMALL_RAISE);
+ }
+ }
+
+ @Test
+ void aCellWordSetsHighStartsLowerByTheWholeLift() throws Exception {
+ // Lato at 10pt Word sets high: it does not rise, and keeps its place under the lift.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(10)
+ .addParagraph("Above")
+ .addRow(row -> row.addParagraph("Left").addParagraph(p -> p.text("Right").textStyle(lato(10))))
+ .addParagraph("After"))) {
+ XWPFTableCell high = onlyTable(document).getRow(0).getCell(1);
+
+ assertThat(before(high.getParagraphs().get(0))).isEqualTo(RAISE);
+ assertThat(after(high.getParagraphs().get(0))).isZero();
+ }
+ }
+
+ @Test
+ void aLineRisenWithItsRowGivesItBackAboveTheLineUnderIt() throws Exception {
+ // One distance between two lines, on one side of them: LibreOffice takes the larger of a
+ // space below one line and above the next, where Word adds them.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(10)
+ .addParagraph("Above")
+ .addRow(row -> row.weights(1, 1)
+ .addSection("Entry", column -> column.spacing(4).addParagraph("Title").addParagraph("Detail"))
+ .addParagraph("Right"))
+ .addParagraph("After"))) {
+ List entry = onlyTable(document).getRow(0).getCell(0).getParagraphs();
+
+ assertThat(entry).extracting(XWPFParagraph::getText).containsExactly("Title", "Detail");
+ assertThat(after(entry.get(0))).isZero();
+ // The detail's own raise came out of its 4pt above, and the title's comes back into it.
+ assertThat(before(entry.get(1))).isEqualTo(4 * 20);
+ }
+ }
+
+ @Test
+ void aRowOfLinesUnderATenthOfAPointLowIsNotLifted() throws Exception {
+ // Helvetica at 4pt stands 0.09pt low in Word: a twip less of space moves a line on Word's
+ // grid a step or nothing.
+ DocumentTextStyle tiny = DocumentTextStyle.DEFAULT.withSize(4);
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(10)
+ .addParagraph("Above")
+ .addRow(row -> row.addParagraph(p -> p.text("Left").textStyle(tiny))
+ .addParagraph(p -> p.text("Right").textStyle(tiny)))
+ .addParagraph("After"))) {
+ XWPFTable table = onlyTable(document);
+
+ assertThat(after(paragraphBefore(document, table))).as("the gap, whole").isEqualTo(10 * 20);
+ assertThat(after(table.getRow(0).getCell(0).getParagraphs().get(0))).isZero();
+ }
+ }
+
+ @Test
+ void aRowWhoseColumnOpensWithAPanelIsNotLifted() throws Exception {
+ // ClassicInvoice's notes: a column ruled down its left side is a panel in its cell, which
+ // would rise whole with the row.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.spacing(10)
+ .addParagraph("Above")
+ .addRow(row -> row.weights(1, 1)
+ .addSection("Notes", column -> column.accentLeft(DocumentColor.rgb(196, 152, 76), 3)
+ .padding(new DocumentInsets(0, 0, 0, 8)).addParagraph("Notes"))
+ .addParagraph("Right"))
+ .addParagraph("After"))) {
+ XWPFTable table = onlyTable(document);
+
+ assertThat(table.getRow(0).getCell(0).getBodyElements().get(0)).as("the premise: a panel")
+ .isInstanceOf(XWPFTable.class);
+ assertThat(after(paragraphBefore(document, table))).as("the gap, whole").isEqualTo(10 * 20);
+ assertThat(after(table.getRow(0).getCell(1).getParagraphs().get(0))).isZero();
+ }
+ }
+
+ @Test
+ void aRowTheLayoutMovesToANewPageIsNotLifted() throws Exception {
+ // Seven lines 10pt apart fill the 160pt of the first page, and the row starts the next:
+ // the space above it stays on the page above, and lifted out of it the row would stand lower.
+ Consumer content = page -> {
+ page.spacing(10);
+ for (int i = 0; i < 7; i++) {
+ page.addParagraph("Line " + i);
+ }
+ page.addRow(row -> row.name("Row").addParagraph("Left").addParagraph("Right"));
+ page.addParagraph("After");
+ };
+ try (DocumentSession session = GraphCompose.document().pageSize(300, 200).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(content::accept);
+ assertThat(session.layoutGraph().nodes().stream().filter(node -> "Row".equals(node.semanticName()))
+ .findFirst().orElseThrow().startPage()).as("the premise: the row starts the second page").isOne();
+ }
+ try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, content)) {
+ for (XWPFTableCell cell : onlyTable(document).getRow(0).getTableCells()) {
+ assertThat(after(cell.getParagraphs().get(0))).isZero();
+ }
+ }
+ }
+
+ @Test
+ void aRowHeldToAHeightIsNotLifted() throws Exception {
+ // In a panel, a row is held as tall as the page makes it, which the space its cells take
+ // would not make taller: what follows would stand higher by the lift.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection("Card", card -> card
+ .fillColor(SURFACE).spacing(10).addParagraph("Above")
+ .addRow(row -> row.addParagraph("Left").addParagraph("Right")).addParagraph("After"))
+ .addParagraph("After"))) {
+ XWPFTable row = onlyTable(document).getRow(0).getCell(0).getTables().get(0);
+
+ assertThat(row.getRow(0).getCtRow().getTrPr().sizeOfTrHeightArray()).as("the premise: held").isOne();
+ for (XWPFTableCell cell : row.getRow(0).getTableCells()) {
+ assertThat(after(cell.getParagraphs().get(0))).isZero();
+ }
+ }
+ }
+
+ private static DocumentTableCell composed(com.demcha.compose.document.node.DocumentNode node) {
+ return DocumentTableCell.node(node).withStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(4))
+ .stroke(DocumentStroke.of(DocumentColor.BLACK, 0)).build());
+ }
+
+ private static DocumentTextStyle spectral(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(size).build();
+ }
+
+ private static DocumentTextStyle lato(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.LATO).size(size).build();
+ }
+
+ private static XWPFTable onlyTable(XWPFDocument document) {
+ return document.getTables().get(0);
+ }
+
+ private static XWPFParagraph paragraphBefore(XWPFDocument document, IBodyElement element) {
+ List body = document.getBodyElements();
+ return (XWPFParagraph) body.get(body.indexOf(element) - 1);
+ }
+
+ private static long topMargin(XWPFTableCell cell) {
+ var properties = cell.getCTTc().getTcPr();
+ return properties == null || !properties.isSetTcMar() || !properties.getTcMar().isSetTop() ? 0
+ : DocxTwips.of(properties.getTcMar().getTop().getW());
+ }
+
+ private static CTSpacing spacing(XWPFParagraph paragraph) {
+ return paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getSpacing();
+ }
+
+ private static long before(XWPFParagraph paragraph) {
+ CTSpacing spacing = spacing(paragraph);
+ return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore());
+ }
+
+ private static long after(XWPFParagraph paragraph) {
+ CTSpacing spacing = spacing(paragraph);
+ return spacing == null || !spacing.isSetAfter() ? 0 : DocxTwips.of(spacing.getAfter());
+ }
+
+ /** The {@code w:position} of a paragraph's first run, in half points; 0 for none. */
+ private static long position(XWPFParagraph paragraph) {
+ var properties = paragraph.getCTP().getRArray(0).getRPr();
+ return properties == null || properties.sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getPositionArray(0).getVal()).longValue();
+ }
+
+ private record Exported(XWPFDocument document, DocxExportReport report, Map heights) {
+ }
+
+ private static Exported export(Consumer content) throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ Map heights = new HashMap<>();
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(content::accept);
+ session.layoutGraph().nodes().forEach(node -> {
+ if (node.semanticName() != null) {
+ heights.putIfAbsent(node.semanticName(), node.placementHeight());
+ }
+ });
+ docx = session.export(new DocxSemanticBackend(report::set));
+ }
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get(), heights);
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java
index 6c6da156f..bb1644f29 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java
@@ -170,7 +170,8 @@ void aRowsCellReachesPastTheRowByItsHangAndItsCardsBorderTogether() throws Excep
})
.addParagraph("Beside"))
.addParagraph(p -> p.name("After").text("After")));
- long down = reachBelow(exported, "Iconed");
+ // The card's first line rises out of its padding, and owes the space above the icon's.
+ long down = reachBelow(exported, "Iconed", DocxExports.SMALL_LINE_RAISE);
assertThat(down).as("the line reaches below the page's").isPositive();
assertThat(exported.report().bySubject().get("space above")).singleElement().satisfies(note -> {
@@ -279,7 +280,8 @@ void aPaddedCardsTopBorderComesOutOfItsPaddingWhereNoSpaceAboveTakesIt() throws
.fillColor(SURFACE).borders(DocumentBorders.top(RULE)).padding(DocumentInsets.of(4)).addParagraph("Inside")));
assertThat(exported.report().bySubject()).doesNotContainKey("SectionNode");
- assertThat(topMargin(panel(exported))).as("its padding, less the whole border").isEqualTo(2 * 20);
+ assertThat(topMargin(panel(exported))).as("its padding, less the whole border and what its first line rose into")
+ .isEqualTo(2 * 20 - DocxExports.DEFAULT_LINE_RAISE);
}
@Test
@@ -289,8 +291,9 @@ void anOutlinedCardHoldsTheHeightOfItsContent() throws Exception {
Exported exported = export(page -> page.addSection("Card", card -> card.fillColor(SURFACE).stroke(RULE)
.padding(DocumentInsets.of(4)).addParagraph("Inside")).addParagraph("After"));
+ // Its first line rises into its padding, and the panel is held as much taller.
assertThat(panel(exported).getTableRow().getHeight()).as("the page's height, less its padding")
- .isEqualTo(Math.round((exported.heights().get("Card") - 8) * 20));
+ .isEqualTo(Math.round((exported.heights().get("Card") - 8) * 20) + DocxExports.DEFAULT_LINE_RAISE);
assertThat(exported.report().bySubject()).doesNotContainKey("space above").doesNotContainKey("SectionNode");
}
@@ -532,7 +535,8 @@ void theSpaceAPullTakesIsNoRoomForACardsTopBorder() throws Exception {
.addSection("Card", card -> card.fillColor(SURFACE).borders(DocumentBorders.top(RULE))
.margin(new DocumentInsets(6, 0, 0, 0)).padding(DocumentInsets.of(4)).addParagraph("Inside")));
- assertThat(topMargin(panel(exported))).as("its padding, less the whole border").isEqualTo(2 * 20);
+ assertThat(topMargin(panel(exported))).as("its padding, less the whole border and what its first line rose into")
+ .isEqualTo(2 * 20 - DocxExports.DEFAULT_LINE_RAISE);
assertThat(exported.report().bySubject()).doesNotContainKey("space above").doesNotContainKey("SectionNode");
}
@@ -610,8 +614,9 @@ void theSpaceAboveACardTakesTheHangOfTheBlockAboveBeforeItsBorder() throws Excep
Exported exported = hungOverACard(gap);
assertThat(reachBelow(exported, "Iconed")).as("the same line").isEqualTo(down);
- assertThat(topMargin(panel(exported))).as("its padding less half the border, less the half point the space left")
- .isEqualTo((4 - 1) * 20 - 10);
+ assertThat(topMargin(panel(exported)))
+ .as("its padding less half the border, less the half point the space left and what its first line rose into")
+ .isEqualTo((4 - 1) * 20 - 10 - DocxExports.DEFAULT_LINE_RAISE);
assertThat(exported.report().bySubject()).doesNotContainKey("space above").doesNotContainKey("SectionNode");
}
@@ -707,6 +712,14 @@ private static String points(long twips) {
* the paragraph's top (the space above it written that much shorter).
*/
private static long reachBelow(Exported exported, String name) {
+ return reachBelow(exported, name, 0);
+ }
+
+ /**
+ * {@link #reachBelow(Exported, String)}, where the line above it rose by {@code rose} twips
+ * out of its card's padding and owes that much above this one.
+ */
+ private static long reachBelow(Exported exported, String name, long rose) {
// The icon's line — "GitHub" and its icon, in contact() — wherever the tables put it.
List paragraphs = new java.util.ArrayList<>(exported.document().getParagraphs());
exported.document().getTables().forEach(table -> collect(table, paragraphs));
@@ -714,7 +727,7 @@ private static long reachBelow(Exported exported, String name) {
CTSpacing spacing = iconed.getCTP().getPPr().getSpacing();
assertThat(spacing.getLineRule()).as("held to its icon")
.isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT);
- long up = 3 * 20 - (spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0);
+ long up = 3 * 20 + rose - (spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0);
return DocxTwips.of(spacing.getLine()) - up - Math.round(exported.heights().get(name) * 20);
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java
index 0a4d595b8..701270306 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceOnANewPageTest.java
@@ -96,8 +96,10 @@ void aRowOnThePageOfTheBlockBeforeItIsWrittenAsBefore() throws Exception {
XWPFParagraph above = paragraphBefore(document, onlyTable(document));
assertThat(above.getText()).as("no line between them").isNotEmpty();
- assertThat(spacingAfter(above)).as("and what that line owes below it for its raise")
- .isEqualTo(Math.round((GAP + EDGE) * 20) + DocxExports.DEFAULT_LINE_RAISE);
+ // What that line owes below it for its raise, the row's first lines, set as low,
+ // rise out of again.
+ assertThat(spacingAfter(above)).as("and what that line owes below it for its raise, taken back")
+ .isEqualTo(Math.round((GAP + EDGE) * 20));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStandInMoveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStandInMoveTest.java
index 3686e9ec0..f22e5d0b5 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStandInMoveTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStandInMoveTest.java
@@ -69,12 +69,17 @@ void theBodyResumesFromTheStripsFootNotFromInsideIt() throws Exception {
void aStandInTallerThanWhatMovesIntoItKeepsTheRestOfItsHeight() throws Exception {
Export export = export(false, 30, 10);
try (XWPFDocument document = export.document()) {
- XWPFParagraph subtitle = paragraph(cellHolding(document, NAME), "Subtitle");
+ XWPFTableCell strip = cellHolding(document, NAME);
+ XWPFParagraph subtitle = paragraph(strip, "Subtitle");
double rest = export.node("Subtitle").placementY() - export.node("SubtitlePlace").placementY();
+ // The name rises into the strip's 10pt of padding, and the subtitle, set as low, into
+ // what the name owes above it: the subtitle owes as much below it.
+ double rose = (10 * 20 - DocxTwips.of(strip.getCTTc().getTcPr().getTcMar().getTop().getW())) / 20.0;
assertThat(rest).isGreaterThan(15);
+ assertThat(rose).as("the premise: the name rose").isPositive();
assertThat(DocxTwips.of(subtitle.getCTP().getPPr().getSpacing().getAfter()) / 20.0)
- .isCloseTo(rest, within(0.1));
+ .isCloseTo(rest + rose, within(0.1));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java
index 5e0f36c34..58badef58 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java
@@ -454,7 +454,8 @@ void aRowHoldsItsOwnPaddingTheSameWay() throws Exception {
.addParagraph(p -> p.text("Right")))
.addParagraph(p -> p.text("After")));
- assertThat(after(paragraphs.get(0))).isEqualTo(Math.round(14 * TWIPS_PER_POINT));
+ // Less what the row's first lines, set low in Word, rise out of the space above it.
+ assertThat(after(paragraphs.get(0))).isEqualTo(Math.round(14 * TWIPS_PER_POINT) - RAISE);
assertThat(before(paragraphs.get(1))).isEqualTo(Math.round(14 * TWIPS_PER_POINT) - RAISE);
}
From f4cd0e28aa30a6f7c7bd826635a92521bee4e1a3 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 12 Oct 2026 08:15:58 +0100
Subject: [PATCH 2/2] fix(docx): even a row's margins beside a picture or a
spacer, and name the held table row
- A cell opening with a picture, or a spacer keeping no other layer's
place, writes a paragraph first: its row's margins even again, as
before the first-line raise, and the gap above a wrapped first line
is taken from the cell's padding beside it.
- Javadoc and docs: a table row is held at its height less the margins
left, so LibreOffice 24.2 grows a table row held taller than its text
by the raise, as it does a panel; the line height paragraph's rules
for the panel's padding and for the space a line's position leaves
now name the new sources; the row of columns' lift skips a line in
any overlay.
- Tests: DocxFirstLineSeatTest covers a cell beside a picture.
---
CHANGELOG.md | 5 +-
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 49 +++++++++++--------
.../semantic/docx/DocxFirstLineSeatTest.java | 19 +++++++
4 files changed, 52 insertions(+), 23 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9112872d..f49987303 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,8 +29,9 @@ follow semantic versioning; release dates are ISO 8601.
row is kept on one page; one in a table nested in that cell is not yet.
Half a point and more is the position's, as before: taken from the padding, `CobaltRota`'s
masthead, whose position LibreOffice moves further than it says, came out 0.5 to 0.7pt lower there.
- LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel held taller
- than its text — a drawing set where the page puts it — comes out taller there by the raise.
+ LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table
+ row held taller than its text — a drawing set where the page puts it — comes out taller there by
+ the raise.
`ClassicInvoice`'s header, "BILL TO", "FROM" and "TOTAL DUE" moved up 0.24 to 0.36pt in Word and
0.2 to 0.4pt in LibreOffice, `ConsultingInvoice`'s line items 0.24 to 0.36pt and 0.35pt,
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 14888b84b..2de9c229a 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -185,7 +185,7 @@ it cannot work out for itself:
| What | Where it lands |
|---|---|
-| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell opening with a table, whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list item's lines are not seated yet; a table text cell's are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
+| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list item's lines are not seated yet; a table text cell's are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
| Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them |
| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |
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 1e8b912a1..1623a2081 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
@@ -9207,15 +9207,16 @@ private void applyLineGap(XWPFParagraph target, double gap, int lines, java.util
/**
* Whether every cell of a table row can have its top margin evened down to the row's
- * smallest ({@link #evenTheRowsMargins}): none is merged down over rows, and none opens with
- * a table, which has no paragraph above it to hold the padding.
+ * smallest ({@link #evenTheRowsMargins}): none is merged down over rows, and every one opens
+ * with a paragraph ({@link #opensWithAParagraph}): one that may open with a table has no
+ * paragraph above it to hold the padding.
*
* Word gives every cell of a row the row's largest top margin. Where one stays as it is,
* a cell whose paragraph took the gap from its padding would be set at that margin
* whatever its own says: the padding would come back, and the line grown for it stand that
* much low. Such a row is written as before.
*/
- private static boolean everyMarginEvens(List row) {
+ private boolean everyMarginEvens(List row) {
for (TableGrid.Placement placement : row) {
if (placement.rowSpan() > 1 || !opensWithAParagraph(placement.cell().content())) {
return false;
@@ -9225,17 +9226,20 @@ private static boolean everyMarginEvens(List row) {
}
/**
- * Whether writing a cell's content writes a paragraph first: a text cell's, a paragraph, a
- * list, or a section or container painting nothing whose first child does. A cell opening with
- * anything else may open with a table — a table, a row of columns, a painted container or a
- * shape, each written as one — and keeps its margin, which then evens the row's
+ * Whether a cell's content is known to write a paragraph first: a text cell's, a paragraph, a
+ * list, a picture, a spacer that keeps no other layer's place, or a section or container
+ * painting nothing whose first child does — a picture drawn where the page puts it, or not at
+ * all, leaves its cell a paragraph to hold it. A cell opening with anything else may open with a
+ * table — a table, a row of columns, a painted container or a shape, each written as one, or
+ * the content moved into a spacer's place — and keeps its margin, which then evens the row's
* ({@link #canMoveItsPadding}): a paragraph beside it given its padding would rise into a margin
* Word does not set. Read off the content as authored, before any of the row is written.
*/
- private static boolean opensWithAParagraph(DocumentNode node) {
+ private boolean opensWithAParagraph(DocumentNode node) {
for (DocumentNode current = node; ; current = current.children().get(0)) {
- if (current == null || current instanceof ParagraphNode
- || current instanceof com.demcha.compose.document.node.ListNode) {
+ if (current == null || current instanceof ParagraphNode || current instanceof ImageNode
+ || current instanceof com.demcha.compose.document.node.ListNode
+ || current instanceof SpacerNode && !standIns.contains(current)) {
return true;
}
if (!(current instanceof SectionNode || current instanceof ContainerNode)
@@ -9256,9 +9260,10 @@ private static boolean opensWithAParagraph(DocumentNode node) {
* Word set them 12.35pt apart, the first 1.85pt low and the second 0.36pt.
*
* Only a table's cell gives its padding, in a row whose margins all even
- * ({@link #everyMarginEvens}). A panel's cell keeps its: its margin is the padding less half
- * its border, and Word sets the content its whole border and then that margin inside the row
- * ({@link #outsideTheRow}).
+ * ({@link #everyMarginEvens}). A panel's cell keeps its for the gap: its margin is the padding
+ * less half its border, and Word sets the content its whole border and then that margin inside
+ * the row ({@link #outsideTheRow}). A first line under half a point low takes it all the same
+ * ({@link #paddingAbove}).
*
* @return the twips taken, 0 when the paragraph does not open a padded cell
*/
@@ -9807,8 +9812,10 @@ private double seatShift(ParagraphNode node) {
* then seats it by its own measure of the face.
*
* A line of its own Word sets low is raised first by moving it up into the space written
- * above it, where that space can take it ({@link #raiseIntoTheSpaceAbove}). What that space
- * cannot give, and a line set
+ * above it, where that space can take it ({@link #raiseIntoTheSpaceAbove}); under half a point,
+ * what that space cannot give the top padding of the cell it opens takes ({@link #paddingAbove}),
+ * or the space above a row of columns' table ({@link #noteAFirstLineLow}). What none of them
+ * gives, and a line set
* high, is left to the text's position, which counts in half points: a line whose difference
* is under {@link #LEAST_BASELINE_SHIFT_POINTS} — a quarter point for a line of Lato body text
* — keeps no position, as every line of body text moved by one would win a quarter point at
@@ -9968,10 +9975,11 @@ private boolean inARowKeptWhole() {
* top, the middle or the bottom alike (measured). A panel written whole gives what of its padding its
* top border does not take ({@link #panelsMarginKeeps}), and its row is held as much taller
* ({@link #writePanelPiece}), as Word adds the margin left to the height a row is held at.
- * LibreOffice 24.2 counts the margins inside that height ({@link #seatTheLabelByItsLine}): a
- * panel held taller than its text — a drawing set where the page puts it — comes out taller
- * there by the raise, under half a point. A shape's layers are centred in it, and none
- * rises.
+ * A table's row is held at its height less the margins left too ({@link #holdRowHeight}), as
+ * much taller. LibreOffice 24.2 counts the margins inside that height
+ * ({@link #seatTheLabelByItsLine}): a panel or a table row held taller than its text — a
+ * drawing set where the page puts it — comes out taller there by the raise, under half a
+ * point. A shape's layers are centred in it, and none rises.
*/
private long paddingAbove(XWPFParagraph para) {
if (panelsPaddingGives && opensThePanel(para)) {
@@ -9997,7 +10005,8 @@ private boolean opensTheCell(XWPFParagraph para) {
* A cell of a row of columns has no padding of its own above its first paragraph: the
* columns' space above is the row's, written above its table. Only a first row the export
* wrote for a row of columns ({@link #rowsLiftedForTheirLines}), and only where its line is not
- * laid over the flow, whose space owed below it would be lost.
+ * in an overlay — a band, a layer stack, a shape container, a canvas, the text laid over the
+ * flow — whose writers measure what follows from the page.
*
* @param low how far Word still sets the line below the page's, in points
*/
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
index 6a56f24d5..1afb1d907 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
@@ -122,6 +122,25 @@ void aCellBesideOneOpeningWithAPanelKeepsItsPadding() throws Exception {
}
}
+ @Test
+ void aCellBesideAPictureRisesIntoItsPadding() throws Exception {
+ // A picture leaves its cell a paragraph to hold it: the row's margins even.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(160), DocumentTableColumn.fixed(160))
+ .rowCells(composed(new ParagraphBuilder().text("Inside").build()),
+ composed(new com.demcha.compose.document.dsl.ImageBuilder()
+ .source(com.demcha.compose.document.image.DocumentImageData.fromBytes(png()))
+ .size(12, 12).build())))
+ .addParagraph("After"))) {
+ XWPFTableCell inside = onlyTable(document).getRow(0).getCell(0);
+
+ assertThat(onlyTable(document).getRow(0).getCell(1).getBodyElements().get(0))
+ .as("the premise: the picture's cell opens with a paragraph").isInstanceOf(XWPFParagraph.class);
+ assertThat(topMargin(inside)).as("its padding, less what its line rose into").isEqualTo(4 * 20 - RAISE);
+ assertThat(after(inside.getParagraphs().get(0))).isEqualTo(RAISE);
+ }
+ }
+
@Test
void aPanelSplitOverAPageKeepsItsPadding() throws Exception {
// Only a panel written whole is held to a height its raise can be added to.