From fcd423d1188d200614f79aa9f60e3d2844047aad Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 1 Oct 2026 00:00:44 +0100
Subject: [PATCH 1/2] fix(docx): take a pull no space above can give off the
foot of the line above, then the top of the pulled line
---
CHANGELOG.md | 12 ++
docs/recipes/docx-export.md | 10 +-
.../semantic/docx/DocxSemanticBackend.java | 105 ++++++++++++++++++
.../semantic/docx/DocxSpaceAboveTest.java | 82 ++++++++++++++
4 files changed, 207 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccdda5b3f..8ac87076e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,18 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A line pulled up into the line above it stands where the page puts it in Word, with no
+ space above to take the pull from.** A paragraph's negative top edge comes out of the space
+ owed above it, and where there is none — the two lines of a lockup in a table cell — it was
+ dropped: `CobaltRota`'s subtitle, 6.5pt up under its 21pt wordmark, stood that much low, and
+ its masthead row and the rota under it with it. The pull now comes off the foot of the line
+ of text written just before, on the same page — a one-line paragraph with no space below it
+ and no shaded run — as far as its letters leave room, three quarters of a point clear of
+ them: the line written shorter, its text lowered by the four fifths of that Word's baseline
+ would rise. The rest comes off the top of the pulled line itself, as far as its letters
+ allow. `CobaltRota`'s median drift falls from 8.9pt to 2.4 in Word and in LibreOffice; its
+ lines more than 2pt off fall from 65 to 45 in Word and from 62 to 47 in LibreOffice.
+
- **A chip's label taller than the chip no longer makes it taller in Word.** A shape composed in
a table cell centres its one line of text in it, and the page lets the line pass the outline
where it is taller: `CobaltRota`'s stacked shift chips, 9.2pt outlines round 8.2pt text on a
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index a6eff6a0b..32e552de3 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -201,8 +201,14 @@ a table (not a row) opening a cell still loses that edge.
An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top
`margin` or `padding` — comes out of the space owed above it, as the page takes it from
-there: Word has no negative space above a paragraph. What that space cannot give is not
-written. Text laid over the flow in text boxes owes no space, so its edges move nothing.
+there: Word has no negative space above a paragraph. What that space cannot give a paragraph
+takes from the foot of the line of text written just before it on the same page — a one-line
+paragraph with no space below it and no shaded run, whose shading would fill the line — as
+far as that line's letters leave room below them, three quarters of a point clear: the line
+written shorter, its text lowered by the four fifths of that Word's baseline would rise. The
+rest it takes from the top of its own one-line line, as far as its letters leave room above
+them, its text seated where the page sets it; what neither can give is not written. Text laid over the flow in text boxes owes no space, so its edges move
+nothing.
A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines —
goes into the line: Word has one line height for a paragraph and no gap between its lines,
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 76769d220..98109b3be 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
@@ -330,6 +330,11 @@ public final class DocxSemanticBackend implements SemanticBackend {
// The last paragraph written into the body, so a container can hand it the space it
// holds below itself once its children are done.
private XWPFParagraph lastBodyParagraph;
+ // The paragraph writeParagraph wrote last, and the Word paragraph it wrote it in: a paragraph
+ // pulled up into it takes the pull off its foot (takeFromTheLineAbove), while nothing else has
+ // been written since — lastBodyParagraph is that same Word paragraph then.
+ private ParagraphNode lastWrittenNode;
+ private XWPFParagraph lastWrittenParagraph;
// The empty paragraph closing the last table written into the cell being filled, while
// nothing has been written after it; see newTable.
private XWPFParagraph tableCloser;
@@ -718,6 +723,8 @@ private void beginSection(SemanticSection section, int index) {
anItemWasWritten = false;
forgetTheHang();
lastBodyParagraph = null;
+ lastWrittenNode = null;
+ lastWrittenParagraph = null;
contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth();
canvasHeight = context.canvas() == null ? Double.NaN : context.canvas().height();
}
@@ -3829,7 +3836,103 @@ private void standsIntoTheSpaceAbove(DocumentNode node) {
double edge = node.margin().top() + node.padding().top();
if (edge < 0) {
carriedSpacingBefore += edge;
+ double shortBy = -(carriedSpacingBefore + pendingSpacingAfter - borderBelow);
+ if (shortBy > 0.01 && node instanceof ParagraphNode paragraph) {
+ shortBy -= takeFromTheLineAbove(shortBy, paragraph);
+ if (shortBy > 0.01) {
+ riseInsideItsOwnLine(paragraph, shortBy);
+ }
+ }
+ }
+ }
+
+ /**
+ * Takes up to {@code points} off the foot of the one line written just before, where its
+ * letters leave that room, keeping its text where it stood; how much it took is given back to
+ * the space above the next block.
+ *
+ * A paragraph pulled up into the one above it, with nothing above it to give the pull —
+ * the two lines of a lockup in a table cell, {@code CobaltRota}'s subtitle 6.5pt up under
+ * its 21pt wordmark — stood that much low in Word, and the row and the page under it with
+ * it. The line above is written shorter by what its letters do not reach, and its text,
+ * which Word would raise with the baseline of a shorter exact line, lowered as much.
+ *
+ * Only a line of text alone in its paragraph, with no space written below it and no run
+ * shaded behind its text, on the page the pulled paragraph starts on: a shaded run fills the
+ * line in Word, and the last line of a page shortened would move where Word breaks it.
+ *
+ * @return how far the line above was shortened, in points
+ */
+ private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
+ ParagraphNode above = lastWrittenNode;
+ XWPFParagraph para = lastWrittenParagraph;
+ if (above == null || para == null || para != lastBodyParagraph || layout.lineCount(above) != 1
+ || stackedLineHeights.containsKey(above)) {
+ return 0;
+ }
+ com.demcha.compose.document.layout.PlacedNode abovePlaced = layout.placement(above);
+ com.demcha.compose.document.layout.PlacedNode pulledPlaced = layout.placement(pulled);
+ if (abovePlaced != null && pulledPlaced != null && abovePlaced.endPage() != pulledPlaced.startPage()) {
+ return 0;
+ }
+ for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) {
+ if (run.isSetRPr() && run.getRPr().sizeOfShdArray() > 0) {
+ return 0;
+ }
+ }
+ CTPPr properties = para.getCTP().getPPr();
+ CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null;
+ Long line = spacing != null && spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT
+ ? writtenTwips(spacing.getLine()) : null;
+ java.util.Optional laid = layout.firstLine(above);
+ double[] ink = inkOf(above);
+ if (line == null || laid.isEmpty() || ink == null || spacing.isSetAfter()) {
+ return 0;
+ }
+ double room = laid.get().baselineOffsetFromBottom() - ink[1] - DocxStackedLines.INK_MARGIN;
+ long taken = Math.round(Math.min(points, room) * POINT_TO_TWIP);
+ if (taken <= 0 || taken >= line) {
+ return 0;
+ }
+ spacing.setLine(BigInteger.valueOf(line - taken));
+ // Word stands an exact line's baseline four fifths of the way down it: shorter by the
+ // taken twips, its text would rise by four fifths of them.
+ long lowered = Math.round(taken / POINT_TO_TWIP * DocxTextBands.BASELINE_SHARE * HALF_POINTS_PER_POINT);
+ for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) {
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr runProperties =
+ run.isSetRPr() ? run.getRPr() : run.addNewRPr();
+ org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSignedHpsMeasure position =
+ runProperties.sizeOfPositionArray() > 0 ? runProperties.getPositionArray(0) : runProperties.addNewPosition();
+ long raised = position.getVal() instanceof Number number ? number.longValue() : 0;
+ position.setVal(BigInteger.valueOf(raised - lowered));
+ }
+ double takenPoints = taken / POINT_TO_TWIP;
+ carriedSpacingBefore += takenPoints;
+ return takenPoints;
+ }
+
+ /**
+ * Rises a one-line paragraph inside its own line by up to {@code points}, where the room above
+ * its letters allows, as {@link #riseIntoItsLine} does for a container's first line.
+ */
+ private void riseInsideItsOwnLine(ParagraphNode paragraph, double points) {
+ if (layout.lineCount(paragraph) != 1 || stackedLineHeights.containsKey(paragraph)) {
+ return;
}
+ java.util.OptionalDouble own = layout.lineHeight(paragraph);
+ java.util.Optional line = layout.firstLine(paragraph);
+ double[] ink = inkOf(paragraph);
+ if (own.isEmpty() || line.isEmpty() || ink == null) {
+ return;
+ }
+ double aboveTheLetters = line.get().lineHeight() - line.get().baselineOffsetFromBottom() - ink[0]
+ - DocxStackedLines.INK_MARGIN;
+ double taken = Math.min(points, aboveTheLetters);
+ if (!(taken > 0.01) || taken >= own.getAsDouble()) {
+ return;
+ }
+ stackedLineHeights.put(paragraph, new DocxStackedLines.Line(own.getAsDouble() - taken, -taken, 0));
+ carriedSpacingBefore += taken;
}
/**
@@ -4570,6 +4673,8 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
DocxStackedLines.Line stacked = stackedLineHeights.get(node);
writeParagraphRuns(para, node, rightToLeft, stacked == null ? 0 : stacked.topAbove(), stacked == null);
closeAnchor(para, anchor);
+ lastWrittenNode = node;
+ lastWrittenParagraph = para;
}
/**
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
index 523a70618..14aaaf1c3 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
@@ -91,6 +91,88 @@ void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception {
}
}
+ @Test
+ void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception {
+ // CobaltRota's lockup: a subtitle pulled up under its wordmark, in a cell with nothing
+ // above to give the pull. The wordmark's line, its letters standing on the baseline, is
+ // written 2pt shorter, its text lowered by the four fifths of that Word's baseline would
+ // rise, and the subtitle has no space above it.
+ DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build();
+ XWPFParagraph[] plain = lockup(wordmark, 0);
+ XWPFParagraph[] pulled = lockup(wordmark, -2);
+
+ double shorter = (DocxTwips.of(lineOf(plain[0])) - DocxTwips.of(lineOf(pulled[0]))) / 20.0;
+ assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(2, within(0.06));
+ assertThat(positionOf(pulled[0]) - positionOf(plain[0])).as("its text lowered as its baseline would rise")
+ .isEqualTo(-Math.round(2 * 0.8 * 2));
+ assertThat(DocxTwips.of(lineOf(pulled[1]))).as("the pulled line whole, the pull taken above it")
+ .isEqualTo(DocxTwips.of(lineOf(plain[1])));
+ }
+
+ @Test
+ void aLineAboveWithItsTextShadedIsLeftWhole() throws Exception {
+ // A shaded run fills its exact line in Word: shortened, the chip would lose its foot.
+ DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build();
+ DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addRow("Head", row -> row
+ .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
+ .addSection("Lockup", cell -> cell
+ .addParagraph(p -> p.inlineHighlight("HALL", wordmark,
+ DocumentColor.rgb(255, 230, 150), 0, DocumentInsets.zero()))
+ .addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle)
+ .margin(new DocumentInsets(-2, 0, 0, 0))))
+ .addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) {
+ XWPFParagraph shaded = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
+
+ assertThat(positionOf(shaded)).as("not lowered: its line was not cut").isGreaterThan(-3);
+ assertThat(shaded.getRuns().get(0).getCTR().getRPr().sizeOfShdArray()).as("a shaded run").isPositive();
+ }
+ }
+
+ @Test
+ void aPullDeeperThanTheLettersAboveLeaveRoomForRisesInsideItsOwnLine() throws Exception {
+ // Under 12pt text the line's foot has about 3pt below the letters; the rest of an 8pt
+ // pull comes out of the top of the pulled line itself.
+ DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.LATO).size(12).build();
+ XWPFParagraph[] plain = lockup(small, 0);
+ XWPFParagraph[] pulled = lockup(small, -8);
+
+ assertThat(DocxTwips.of(lineOf(pulled[0]))).as("the line above gave some").isLessThan(DocxTwips.of(lineOf(plain[0])));
+ assertThat(DocxTwips.of(lineOf(pulled[1]))).as("and the pulled line the rest").isLessThan(DocxTwips.of(lineOf(plain[1])));
+ }
+
+ /** A wordmark over a 10pt subtitle pulled up by {@code pull}, in a row's cell. */
+ private static XWPFParagraph[] lockup(DocumentTextStyle wordmark, double pull) throws Exception {
+ DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addRow("Head", row -> row
+ .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
+ .addSection("Lockup", cell -> cell
+ .addParagraph(p -> p.text("HALL").textStyle(wordmark))
+ .addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle)
+ .margin(new DocumentInsets(pull, 0, 0, 0))))
+ .addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) {
+ List paragraphs = document.getTables().get(0).getRow(0).getCell(0).getParagraphs();
+ return new XWPFParagraph[]{paragraphs.get(0), paragraphs.get(1)};
+ }
+ }
+
+ private static Object lineOf(XWPFParagraph paragraph) {
+ return paragraph.getCTP().getPPr().getSpacing().getLine();
+ }
+
+ private static long beforeOf(XWPFParagraph paragraph) {
+ var spacing = paragraph.getCTP().getPPr().getSpacing();
+ return spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0;
+ }
+
+ private static int positionOf(XWPFParagraph paragraph) {
+ var run = paragraph.getRuns().get(0).getCTR();
+ return run.isSetRPr() && run.getRPr().sizeOfPositionArray() > 0
+ ? ((Number) run.getRPr().getPositionArray(0).getVal()).intValue() : 0;
+ }
+
@Test
void aRowOpeningAPaddedColumnKeepsThePaddingAboveIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
From 3fdfb2638b4f9a3d4a016347208e6295ffc52ea8 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 1 Oct 2026 00:22:28 +0100
Subject: [PATCH 2/2] fix(docx): read the room under the line above off Word's
geometry, and leave an underlined line whole
---
CHANGELOG.md | 20 +++----
docs/recipes/docx-export.md | 16 ++---
.../semantic/docx/DocxSemanticBackend.java | 36 +++++++++---
.../semantic/docx/DocxSpaceAboveTest.java | 58 +++++++++++++------
4 files changed, 86 insertions(+), 44 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ac87076e..558f33d7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,16 +8,16 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
-- **A line pulled up into the line above it stands where the page puts it in Word, with no
- space above to take the pull from.** A paragraph's negative top edge comes out of the space
- owed above it, and where there is none — the two lines of a lockup in a table cell — it was
- dropped: `CobaltRota`'s subtitle, 6.5pt up under its 21pt wordmark, stood that much low, and
- its masthead row and the rota under it with it. The pull now comes off the foot of the line
- of text written just before, on the same page — a one-line paragraph with no space below it
- and no shaded run — as far as its letters leave room, three quarters of a point clear of
- them: the line written shorter, its text lowered by the four fifths of that Word's baseline
- would rise. The rest comes off the top of the pulled line itself, as far as its letters
- allow. `CobaltRota`'s median drift falls from 8.9pt to 2.4 in Word and in LibreOffice; its
+- **A line pulled up into the line above it, with no space above to take the pull from, is no
+ longer dropped in Word.** A paragraph's negative top edge comes out of the space owed above
+ it, and where there is none — the two lines of a lockup in a table cell — it was dropped:
+ `CobaltRota`'s subtitle, 6.5pt up under its 21pt wordmark, stood that much low, and its
+ masthead row and the rota under it with it. The pull now comes off the foot of the line of
+ text written just before, on the same page — one exact line alone in its paragraph, with no
+ space below it and no run shaded or underlined — as far as its letters leave room where Word
+ draws them, three quarters of a point clear: the line written shorter, its text lowered by
+ the four fifths of that Word's baseline would rise. The rest comes off the top of the pulled
+ line itself, as far as its letters allow; what neither can give is not written. `CobaltRota`'s median drift falls from 8.9pt to 2.4 in Word and in LibreOffice; its
lines more than 2pt off fall from 65 to 45 in Word and from 62 to 47 in LibreOffice.
- **A chip's label taller than the chip no longer makes it taller in Word.** A shape composed in
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 32e552de3..ac6714317 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -202,13 +202,15 @@ a table (not a row) opening a cell still loses that edge.
An edge pulling a block up — a paragraph's, a page reference's or a rule's negative top
`margin` or `padding` — comes out of the space owed above it, as the page takes it from
there: Word has no negative space above a paragraph. What that space cannot give a paragraph
-takes from the foot of the line of text written just before it on the same page — a one-line
-paragraph with no space below it and no shaded run, whose shading would fill the line — as
-far as that line's letters leave room below them, three quarters of a point clear: the line
-written shorter, its text lowered by the four fifths of that Word's baseline would rise. The
-rest it takes from the top of its own one-line line, as far as its letters leave room above
-them, its text seated where the page sets it; what neither can give is not written. Text laid over the flow in text boxes owes no space, so its edges move
-nothing.
+takes from the foot of the line of text written just before it on the same page — one exact
+line alone in its paragraph, not one of a stack, with no space below it and no run shaded or
+underlined, as shading fills the line and an underline is drawn below the letters — as far as
+that line's letters leave room below them where Word draws them, three quarters of a point
+clear: the line written shorter, its text lowered by the four fifths of that Word's baseline
+would rise. The rest it takes from the top of its own line, where it is one line of text, as
+far as its letters leave room above them, its text seated where the page sets it; what neither
+can give is not written. Text laid over the flow in text boxes owes no space, so its edges
+move nothing.
A paragraph's or a list's `lineSpacing` — the gap the page puts between two wrapped lines —
goes into the line: Word has one line height for a paragraph and no gap between its lines,
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 98109b3be..b5e21e89b 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
@@ -3828,8 +3828,10 @@ private static boolean drawn(CTBorder border) {
* past its content: the pull was lost, and the rule and the page under it stood 10pt low,
* the 16.4pt less the 6.3pt its metadata grid had lost above it. The pull is added
* to the edges carried down to the block, as a container's negative edge already is, so
- * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it;
- * what that cannot give stays unwritten, as before. Text laid over the flow owes no
+ * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it.
+ * Where that cannot give it all, a paragraph takes the rest off the foot of the line written
+ * just before it ({@link #takeFromTheLineAbove}), then off the top of its own line
+ * ({@link #riseInsideItsOwnLine}); what neither can give stays unwritten. Text laid over the flow owes no
* space ({@link #writeOverTheFlow}), so its edges move nothing.
*/
private void standsIntoTheSpaceAbove(DocumentNode node) {
@@ -3857,9 +3859,11 @@ private void standsIntoTheSpaceAbove(DocumentNode node) {
* it. The line above is written shorter by what its letters do not reach, and its text,
* which Word would raise with the baseline of a shorter exact line, lowered as much.
*
- * Only a line of text alone in its paragraph, with no space written below it and no run
- * shaded behind its text, on the page the pulled paragraph starts on: a shaded run fills the
- * line in Word, and the last line of a page shortened would move where Word breaks it.
+ * Only an exact line of text alone in its paragraph, not one of a stack, with no space
+ * written below it and no run shaded or underlined: a shaded run fills the line in Word, and
+ * an underline is drawn below the letters. Where both are laid out, the line must end on the
+ * page the pulled paragraph starts on: the last line of a page shortened would move where
+ * Word breaks it.
*
* @return how far the line above was shortened, in points
*/
@@ -3876,7 +3880,9 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
return 0;
}
for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) {
- if (run.isSetRPr() && run.getRPr().sizeOfShdArray() > 0) {
+ // A shaded run fills the line; an underline is drawn below the letters' ink.
+ if (run.isSetRPr() && (run.getRPr().sizeOfShdArray() > 0 || run.getRPr().sizeOfUArray() > 0
+ && run.getRPr().getUArray(0).getVal() != org.openxmlformats.schemas.wordprocessingml.x2006.main.STUnderline.NONE)) {
return 0;
}
}
@@ -3885,11 +3891,23 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
Long line = spacing != null && spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT
? writtenTwips(spacing.getLine()) : null;
java.util.Optional laid = layout.firstLine(above);
- double[] ink = inkOf(above);
- if (line == null || laid.isEmpty() || ink == null || spacing.isSetAfter()) {
+ // The letters' reach off their own baseline, before any seat: the position written on
+ // the runs below carries the seat, as far as it was written.
+ double[] ink = laid.isEmpty() ? null : DocxInk.of(laid.get(), measuredFonts());
+ if (line == null || ink == null || spacing.isSetAfter()) {
return 0;
}
- double room = laid.get().baselineOffsetFromBottom() - ink[1] - DocxStackedLines.INK_MARGIN;
+ // The room under the letters where Word draws them: its baseline stands a fifth of the
+ // line above the foot, raised by the runs' written position; less the margin, and the
+ // quarter point the lowering below can round away.
+ List runs = runsIn(para);
+ long seated = 0;
+ if (!runs.isEmpty() && runs.get(0).isSetRPr() && runs.get(0).getRPr().sizeOfPositionArray() > 0
+ && runs.get(0).getRPr().getPositionArray(0).getVal() instanceof Number number) {
+ seated = number.longValue();
+ }
+ double wordFoot = (1 - DocxTextBands.BASELINE_SHARE) * line / POINT_TO_TWIP + seated / HALF_POINTS_PER_POINT;
+ double room = wordFoot - ink[1] - DocxStackedLines.INK_MARGIN - 0.5 / HALF_POINTS_PER_POINT;
long taken = Math.round(Math.min(points, room) * POINT_TO_TWIP);
if (taken <= 0 || taken >= line) {
return 0;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
index 14aaaf1c3..68bf04e4c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java
@@ -3,6 +3,7 @@
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
+import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;
import org.apache.poi.xwpf.usermodel.IBodyElement;
@@ -95,16 +96,17 @@ void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception {
void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception {
// CobaltRota's lockup: a subtitle pulled up under its wordmark, in a cell with nothing
// above to give the pull. The wordmark's line, its letters standing on the baseline, is
- // written 2pt shorter, its text lowered by the four fifths of that Word's baseline would
- // rise, and the subtitle has no space above it.
+ // written 3.5pt shorter, its text lowered by the four fifths of that Word's baseline would
+ // rise — 6 half points, where three quarters would round to 5 — and the subtitle has no
+ // space above it.
DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build();
XWPFParagraph[] plain = lockup(wordmark, 0);
- XWPFParagraph[] pulled = lockup(wordmark, -2);
+ XWPFParagraph[] pulled = lockup(wordmark, -3.5);
double shorter = (DocxTwips.of(lineOf(plain[0])) - DocxTwips.of(lineOf(pulled[0]))) / 20.0;
- assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(2, within(0.06));
+ assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(3.5, within(0.06));
assertThat(positionOf(pulled[0]) - positionOf(plain[0])).as("its text lowered as its baseline would rise")
- .isEqualTo(-Math.round(2 * 0.8 * 2));
+ .isEqualTo(-6);
assertThat(DocxTwips.of(lineOf(pulled[1]))).as("the pulled line whole, the pull taken above it")
.isEqualTo(DocxTwips.of(lineOf(plain[1])));
}
@@ -112,21 +114,46 @@ void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception {
@Test
void aLineAboveWithItsTextShadedIsLeftWhole() throws Exception {
// A shaded run fills its exact line in Word: shortened, the chip would lose its foot.
- DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build();
+ XWPFParagraph plain = decoratedLockup(true, false, 0);
+ XWPFParagraph pulled = decoratedLockup(true, false, -2);
+
+ assertThat(pulled.getRuns().get(0).getCTR().getRPr().sizeOfShdArray()).as("a shaded run").isPositive();
+ assertThat(DocxTwips.of(lineOf(pulled))).as("its line not cut").isEqualTo(DocxTwips.of(lineOf(plain)));
+ assertThat(positionOf(pulled)).as("nor its text lowered").isEqualTo(positionOf(plain));
+ }
+
+ @Test
+ void aLineAboveWithItsTextUnderlinedIsLeftWhole() throws Exception {
+ // Word draws an underline below the letters, in the room the pull would take.
+ XWPFParagraph plain = decoratedLockup(false, true, 0);
+ XWPFParagraph pulled = decoratedLockup(false, true, -2);
+
+ assertThat(pulled.getRuns().get(0).getCTR().getRPr().sizeOfUArray()).as("an underlined run").isPositive();
+ assertThat(DocxTwips.of(lineOf(pulled))).as("its line not cut").isEqualTo(DocxTwips.of(lineOf(plain)));
+ assertThat(positionOf(pulled)).as("nor its text lowered").isEqualTo(positionOf(plain));
+ }
+
+ /** A 21pt wordmark, shaded or underlined, over a 10pt subtitle pulled up by {@code pull}. */
+ private static XWPFParagraph decoratedLockup(boolean shaded, boolean underlined, double pull) throws Exception {
+ DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21)
+ .decoration(underlined ? DocumentTextDecoration.UNDERLINE : DocumentTextDecoration.DEFAULT).build();
DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addRow("Head", row -> row
.columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1))
.addSection("Lockup", cell -> cell
- .addParagraph(p -> p.inlineHighlight("HALL", wordmark,
- DocumentColor.rgb(255, 230, 150), 0, DocumentInsets.zero()))
+ .addParagraph(p -> {
+ if (shaded) {
+ p.inlineHighlight("HALL", wordmark,
+ DocumentColor.rgb(255, 230, 150), 0, DocumentInsets.zero());
+ } else {
+ p.text("HALL").textStyle(wordmark);
+ }
+ })
.addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle)
- .margin(new DocumentInsets(-2, 0, 0, 0))))
+ .margin(new DocumentInsets(pull, 0, 0, 0))))
.addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) {
- XWPFParagraph shaded = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
-
- assertThat(positionOf(shaded)).as("not lowered: its line was not cut").isGreaterThan(-3);
- assertThat(shaded.getRuns().get(0).getCTR().getRPr().sizeOfShdArray()).as("a shaded run").isPositive();
+ return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
}
}
@@ -162,11 +189,6 @@ private static Object lineOf(XWPFParagraph paragraph) {
return paragraph.getCTP().getPPr().getSpacing().getLine();
}
- private static long beforeOf(XWPFParagraph paragraph) {
- var spacing = paragraph.getCTP().getPPr().getSpacing();
- return spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0;
- }
-
private static int positionOf(XWPFParagraph paragraph) {
var run = paragraph.getRuns().get(0).getCTR();
return run.isSetRPr() && run.getRPr().sizeOfPositionArray() > 0