From 79704f8d99f955155c22c2e9c67c0432073a4dca Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 30 Sep 2026 11:45:35 +0100
Subject: [PATCH 1/3] fix(docx): stand text on the page's baseline in Word's
exact lines
Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face; the page sets it the face's ascent down. Runs are moved to the page's baseline where the two are half a point or more apart, and lines a container stacks tighter than their face are written a pitch tall, the last to the container's foot.
---
CHANGELOG.md | 24 ++-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 14 +-
.../semantic/docx/DocxLayoutMetrics.java | 19 +++
.../semantic/docx/DocxSemanticBackend.java | 158 +++++++++++++++---
.../semantic/docx/DocxStackedLines.java | 67 +++++---
.../backend/semantic/docx/DocxTextBands.java | 8 +-
.../semantic/docx/DocxBaselineSeatTest.java | 156 +++++++++++++++++
.../semantic/docx/DocxStackedLayersTest.java | 61 +++++--
.../semantic/docx/DocxVerticalSeatTest.java | 25 ++-
10 files changed, 451 insertions(+), 83 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c645c3a5b..2bc900f7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **Text stands on the page's baseline in Word's exact lines, and stacked title lines take the
+ container's height.** Word stands the baseline of an exact line four fifths of the way down it
+ whatever the face — measured for Spectral, Lato and Arial, in lines 12 to 100pt tall, and
+ LibreOffice does the same — where the page sets it the face's ascent below the line's top. For a
+ face with a deep descent the two part: `NorthlineProposal`'s 46pt Spectral title stood 7pt low.
+ Its three lines, laid 48pt apart in a 140pt container, were also written 70, 48 and 48pt tall,
+ and what the 26pt they ran past the container left after the gap under it pushed the rest of the
+ cover 8pt below the section icons drawn where the page puts them. A paragraph's runs are now
+ moved to the page's baseline (`w:position`) wherever the two stand half a point or more apart,
+ from the line Word was given — a line pair's from the higher text's top, lines that took their
+ gaps from the space above from that higher top, several lines at the middle one; a list item's
+ and a composed table cell's are not moved yet. Lines a container stacks tighter than their face
+ are each written as tall as the step to the next one's top, and in a shape container the last
+ to the container's foot. In Word `NorthlineProposal`'s median drift falls from 8.0pt to 0.8, its
+ cover within 1.5pt of the page throughout, and `EditorialProposal`'s from 5.9 to 0.4; across the
+ 62 templates, lines more than 2pt off fall from 1464 to 1341.
- **A value set from its start keeps its start in Word.** Two texts one layer holds on one line —
a label and its value, a title and its dates — are written as one Word line split by a tab
stop, and the stop was always a right tab at the right text's end. A value aligned left in a
@@ -117,10 +133,10 @@ follow semantic versioning; release dates are ISO 8601.
- **Stacked title lines and icons beside their labels keep the page's height in Word.** A
container written layer by layer took more room in Word than on the page for two kinds of
layer. Lines of text laid over one another — `NorthlineProposal`'s title, three 46pt lines
- 48pt apart — took each line's own height, 70pt; now the first line keeps its height, each
- single line of text laid over the one above it across takes the distance from that line's
- foot to its own (`DocxStackedLines`), and a shape container's last line running past the
- container's foot takes its overhang from the gap below. A line is never squeezed below 0.65
+ 48pt apart — took each line's own height, 70pt; now each single line of text laid over by
+ the next across takes the step down to that one's top (`DocxStackedLines`), and a shape
+ container's last line running past the container's foot takes its overhang from the gap
+ below, or, ending such a stack, the rest of the container. A line is never squeezed below 0.65
of its face, nor one holding a picture: Word was measured setting a 16pt word whole in a
10.7pt line. An icon picture beside a layer of text in a shape container neither painted,
clipped to its outline nor transformed, or in a layer stack — `NorthlineProposal`'s glance
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 8a0170f81..a965a141d 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -62,7 +62,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a composed table cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face are written a pitch tall, and in a shape container on one page the last to the container's foot |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 13b612195..826965e67 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -173,7 +173,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. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height |
+| 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. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a composed table cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a shape container stacks tighter than their face — a title's lines a pitch apart — are each written as tall as the step to the next one's top and the last to the container's foot, so the stack is as tall as the container |
| 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. 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 |
@@ -555,11 +555,13 @@ tint it was flattened to. Recorded, like the other two.
puts it, in front inside a painted panel, rather than written as a line
above the text. Single lines of text a container written layer by layer
lays over one another — a title set a pitch apart, tighter than its
- face's line — keep the page's pitch: the first line keeps its height,
- each line laid over the one above it across takes the distance from
- that line's foot to its own, never below 0.65 of its face nor when it
- holds a picture, and a shape container's last line running past the
- container's foot takes its overhang from the gap below. An outline no
+ face's line — keep the page's pitch: each line laid over by the next
+ across is as tall as the step down to that one's top, never below 0.65
+ of its face nor when it holds a picture, and in a shape container on one
+ page the last is as tall as the rest of the container where its own line
+ runs past it; its text is seated in the shorter line by `w:position`.
+ Any other last line running past a shape container's foot takes its
+ overhang from the gap below. An outline no
shape shows is reported as dropped.
- **`hangingIndent(true)` → the ordinary list form.** A list that opts
into marker/content geometry exports exactly as one that did not: the
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index fff62efb0..e68325dc3 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -6,6 +6,7 @@
import com.demcha.compose.document.layout.PlacedNode;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
+import com.demcha.compose.document.layout.payloads.ParagraphLineGeometry;
import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.InlineRun;
@@ -493,6 +494,24 @@ java.util.Optional firstLine(DocumentNode node) {
return java.util.Optional.empty();
}
+ /**
+ * Where a node's first line of text starts, measured up from the foot of its page: the top
+ * of the first fragment holding its lines, inside the paragraph's padding, where the page
+ * starts setting them.
+ *
+ * @param node any node that lays out as paragraph lines
+ * @return the top in points, or empty when the node laid out nothing
+ */
+ OptionalDouble firstLineTop(DocumentNode node) {
+ for (PlacedFragment fragment : textFragmentsOf(node)) {
+ if (fragment.payload() instanceof ParagraphFragmentPayload paragraph && !paragraph.lines().isEmpty()) {
+ return OptionalDouble.of(ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(),
+ paragraph.padding().top()));
+ }
+ }
+ return OptionalDouble.empty();
+ }
+
/**
* Every line a node laid out, page after page, in order.
*
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 07630bc8e..f336a5d02 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
@@ -176,6 +176,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
/** {@code w:sz} and {@code w:szCs} count half-points. */
private static final double HALF_POINTS_PER_POINT = 2.0;
private static final double POINT_TO_TWIP = 20.0;
+ /** The least difference between Word's baseline and the page's that is moved: one half point. */
+ private static final double LEAST_BASELINE_SHIFT_POINTS = 0.5;
private static final Logger LOG = LoggerFactory.getLogger(DocxSemanticBackend.class);
// The page's content width, so an image is held to the same bound layout holds it to.
// Set per export; Double.MAX_VALUE means "no canvas, so nothing to clamp against".
@@ -230,6 +232,10 @@ public final class DocxSemanticBackend implements SemanticBackend {
// The line a layer of text is written at when it overlaps the layer above it, in points:
// see holdStackedLines.
private final java.util.Map stackedLineHeights = new java.util.IdentityHashMap<>();
+ // How far above the page's first line a paragraph's Word line starts, in points, where its
+ // lines took the space between them from the space above it: see applyLineGap.
+ private final java.util.Map lineTopsTakenIn =
+ new java.util.IdentityHashMap<>();
// Icons drawn beside the text they label rather than written: see drawnBesideItsText.
private final java.util.Set picturesDrawnBeside =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
@@ -595,6 +601,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
moves.clear();
writingInAStandIn.clear();
stackedLineHeights.clear();
+ lineTopsTakenIn.clear();
picturesDrawnBeside.clear();
listNumbering.clear();
report = new DocxExportReport.Builder();
@@ -2777,7 +2784,9 @@ private void writeChartFallback(XWPFDocument document, ChartNode node) throws Ex
* warned about once per export rather than pretended away.
*/
private void writeContainerChildren(XWPFDocument document, DocumentNode node) throws Exception {
- holdStackedLines(node.children());
+ // Nothing takes a wrapper's last line's overhang from the gap under it, as
+ // hangBelowItsBox does a shape container's, so that line keeps its own height.
+ holdStackedLines(node.children(), Double.NaN);
ContainerPaint paint = paintOf(node);
if (paint.isEmpty()) {
writeContainerBody(document, node);
@@ -4069,12 +4078,15 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
// Each half is still the paragraph it was: its outline level, its bookmark around its
// own text, and whether it keeps with what follows.
applyHeadingRole(para, headingLevelOf(pair.left()) != null ? pair.left() : pair.right());
+ // The line starts at the higher text's top; each half is seated from there.
+ com.demcha.compose.document.layout.PlacedNode box = layout.placement(overlay);
+ double lineTop = box.placementY() + box.placementHeight() - pair.above();
int leftAnchor = openAnchor(para, pair.left().anchor());
- writeParagraphRuns(para, pair.left(), false);
+ writeParagraphRuns(para, pair.left(), false, lineTopAbove(lineTop, pair.left()));
closeAnchor(para, leftAnchor);
para.createRun().addTab();
int rightAnchor = openAnchor(para, pair.right().anchor());
- writeParagraphRuns(para, pair.right(), false);
+ writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()));
closeAnchor(para, rightAnchor);
if ((pair.left().keepWithNext() && layout.onOnePage(pair.left()))
|| (pair.right().keepWithNext() && layout.onOnePage(pair.right()))) {
@@ -4098,6 +4110,18 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
}
}
+ /**
+ * How far a line starting at {@code lineTop} starts above a paragraph's first line on the
+ * page, in points; 0 when the paragraph laid out no line.
+ *
+ * @param lineTop the line's top, measured up from the foot of the page
+ * @param paragraph the paragraph set in the line
+ */
+ private double lineTopAbove(double lineTop, ParagraphNode paragraph) {
+ java.util.OptionalDouble first = layout.firstLineTop(paragraph);
+ return first.isPresent() ? lineTop - first.getAsDouble() : 0;
+ }
+
/**
* Writes a shape container's layers, drawing its outline where the page draws it. A
* container whose one written layer is laid beside drawing is written as a band (see
@@ -4152,7 +4176,7 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node,
writeOverlayBand(document, node, band);
return;
}
- holdStackedLines(node.children());
+ holdStackedLines(node.children(), contentFoot(node));
// Its edges are space above and below what it holds, as a section's are: a
// SerifHeadline section heading — a row in a container set its gap below the block
// above — stood that gap high in Word, and everything under it with it.
@@ -4182,14 +4206,29 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node,
}
/**
- * Holds each line of text a container lays over the one above it to the distance between
- * the two lines' feet, so written one after the other they stand where the page stacks them
- * (see {@link DocxStackedLines}).
+ * Holds each line of text a container lays the next one over to the distance down to that
+ * one's top, and the last to the container's foot, so written one after the other they
+ * start where the page stacks them and end where the container does (see
+ * {@link DocxStackedLines}).
*
* @param layers a container's children, in the order they are written
+ * @param foot the foot of the container's content, measured up from the foot of the page,
+ * or {@code NaN} to leave the last line its own height
+ */
+ private void holdStackedLines(List layers, double foot) {
+ stackedLineHeights.putAll(DocxStackedLines.of(layers, foot, layout));
+ }
+
+ /**
+ * The foot of a shape container's content, measured up from the foot of its page, or
+ * {@code NaN} when it is not laid out on one page. Its bottom padding is owed below it
+ * (writeShapeContainer), so the content ends above it — but not in a band, which drops what
+ * its layers owe and sets its own space below.
*/
- private void holdStackedLines(List layers) {
- stackedLineHeights.putAll(DocxStackedLines.of(layers, layout));
+ private double contentFoot(ShapeContainerNode node) {
+ com.demcha.compose.document.layout.PlacedNode box = layout.placement(node);
+ return box == null || box.startPage() != box.endPage()
+ ? Double.NaN : box.placementY() + (bandDepth > 0 ? 0 : node.padding().bottom());
}
/**
@@ -4197,13 +4236,15 @@ private void holdStackedLines(List layers) {
* text below a band does (see {@link #writeLinePair}): the page sets what follows under the
* container, and the line's overhang is taken from the gap above it.
*
- * {@code NorthlineProposal}'s title is 166pt tall on the page, three lines 48pt apart
- * with the last one's foot 14pt below its box. Written whole, that line pushed the cover's
- * byline and everything under it 14pt lower.
+ * A line whose own foot runs past its container's pushed what follows that much lower.
+ * The last line of a stack is written to the container's foot instead, and hangs past
+ * nothing (see {@link #holdStackedLines}).
*/
private void hangBelowItsBox(ShapeContainerNode node) {
List layers = node.children();
- if (layers.isEmpty() || !(layers.get(layers.size() - 1) instanceof ParagraphNode last)) {
+ // A stack's last line written to the container's foot hangs past nothing.
+ if (layers.isEmpty() || !(layers.get(layers.size() - 1) instanceof ParagraphNode last)
+ || stackedLineHeights.containsKey(last)) {
return;
}
com.demcha.compose.document.layout.PlacedNode box = layout.placement(node);
@@ -4212,11 +4253,8 @@ private void hangBelowItsBox(ShapeContainerNode node) {
|| line.startPage() != box.startPage() || line.endPage() != box.startPage()) {
return;
}
- // The page's y runs up from a box's foot. The container's bottom padding is owed below
- // it (writeShapeContainer), so the overhang is measured from its content's foot — but
- // not in a band, which drops what its layers owe and sets its own space below.
- double contentFoot = box.placementY() + (bandDepth > 0 ? 0 : node.padding().bottom());
- double overhang = contentFoot - line.placementY();
+ // The page's y runs up from a box's foot.
+ double overhang = contentFoot(node) - line.placementY();
if (overhang > 0.01) {
hangingBelow = Math.max(hangingBelow, overhang);
}
@@ -4428,7 +4466,9 @@ private static boolean hasAnExactLine(XWPFParagraph paragraph) {
* {@code n} in Word; the one too many comes off the space above the paragraph. The
* editor puts most of an exact line's spare height above its text (measured in
* LibreOffice: 8pt of 10 above), so taking it from above keeps the first line nearly
- * where the page sets it. Where the space above is less than a gap — a paragraph opening
+ * where the page sets it; the line then starts that much above the page's, and the text
+ * is seated from there (see {@link #shiftToThePagesBaseline}). Where the space above is
+ * less than a gap — a paragraph opening
* a cell, or right under the block before it — what it cannot give is not put into the
* lines at all: the {@code n - 1} gaps are shared out over {@code n} lines, so the
* paragraph is as tall as on the page, its lines a little closer than there.
@@ -4458,6 +4498,7 @@ private void applyLineGap(XWPFParagraph target, double gap, int lines) {
long taken = Math.min(before, twips);
if (taken > 0) {
spacing.setBefore(BigInteger.valueOf(before - taken));
+ lineTopsTakenIn.put(target.getCTP(), taken / POINT_TO_TWIP);
}
// n lines of (line + extra), less what came off above, are n lines and n - 1 gaps.
long extra = Math.round(((lines - 1) * (double) twips + taken) / lines);
@@ -4580,7 +4621,19 @@ private static void turnSidesToTheFlow(CTInd indent) {
* than its text. Its fill is read from the authored run beside the reduced one.
*/
private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean rightToLeft) {
- int runsBefore = node.verticalAlign() == null || node.verticalAlign() == TextVerticalAlign.DEFAULT
+ writeParagraphRuns(para, node, rightToLeft, 0);
+ }
+
+ /**
+ * Writes a paragraph's runs into a Word paragraph whose line starts above the page's first
+ * line of it: a line pair's line starts at the higher of its two texts.
+ *
+ * @param lineTopAbove how far above the page's first line of the paragraph the Word line
+ * starts, in points
+ */
+ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean rightToLeft,
+ double lineTopAbove) {
+ int runsBefore = para.getCTP().sizeOfRArray() == 0 && para.getCTP().sizeOfHyperlinkArray() == 0
? 0 : runsIn(para).size();
warnDroppedInlineRuns(node);
String path = layout.pathOf(node);
@@ -4619,7 +4672,7 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
}
makeRoomForPictures(para, pictures);
styleTheMark(para, markStyle);
- seatInTheLine(para, node, runsBefore);
+ seatInTheLine(para, node, runsBefore, lineTopAbove);
}
/**
@@ -4637,10 +4690,16 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
* line's seated baseline. The room made for a picture in the line is its unseated reach. A
* page zone's paragraph has no laid-out lines here, and is written on its baseline.
*
- * @param runsBefore how many runs the Word paragraph held before this one's were written
+ * The baseline the page seats off is not where Word puts it either (see
+ * {@link #shiftToThePagesBaseline}), and the two moves are one position.
+ *
+ * @param runsBefore how many runs the Word paragraph held before this one's were written
+ * @param lineTopAbove how far above the page's first line of the paragraph the Word line
+ * starts, in points
*/
- private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore) {
- long halfPoints = Math.round(seatShift(node) * HALF_POINTS_PER_POINT);
+ private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore, double lineTopAbove) {
+ long halfPoints = Math.round((seatShift(node) + shiftToThePagesBaseline(para, node, lineTopAbove))
+ * HALF_POINTS_PER_POINT);
if (halfPoints == 0) {
return;
}
@@ -4700,6 +4759,57 @@ private double seatShift(ParagraphNode node) {
return 0;
}
+ /**
+ * How far Word's baseline in a paragraph's exact line stands below the page's, in points,
+ * positive when Word's is lower: the raise that stands the text where the page sets it.
+ *
+ * The page sets a line's text its ascent below the line's top. Word stands the baseline of
+ * an exact line four fifths of the way down it whatever the face (see
+ * {@link DocxTextBands#BASELINE_SHARE}). The two agree for a face whose ascent is about four
+ * fifths of its line, as Lato's is, and not for one with a deep descent: Spectral's 46pt
+ * title line, 70pt tall, stood 7pt low in Word. A line written shorter than the page's own
+ * — a title's lines stacked a pitch apart — moves Word's baseline up with it, and a line
+ * that starts above the page's, as a line pair's does, takes that distance with it.
+ *
+ * Read off the first line holding text and the height the paragraph was written at, the
+ * gap between lines included, at the paragraph's middle line: where the space above could not
+ * give up a whole gap, Word's lines step a little closer than the page's, and the error is
+ * shared by the first and last. 0 for a paragraph not written at an exact height: Word
+ * then seats it by its own measure of the face. A difference under
+ * {@link #LEAST_BASELINE_SHIFT_POINTS} — a quarter point for a line of Lato body text — is
+ * left as Word sets it: the position counts in half points, and every line of body text
+ * moved by one would win a quarter point at most.
+ *
+ * @param lineTopAbove how far above the page's first line the Word line starts, in points
+ */
+ private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, double lineTopAbove) {
+ CTPPr properties = para.getCTP().getPPr();
+ if (properties == null || !properties.isSetSpacing()) {
+ return 0;
+ }
+ CTSpacing spacing = properties.getSpacing();
+ Long written = spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT
+ ? writtenTwips(spacing.getLine()) : null;
+ if (written == null) {
+ return 0;
+ }
+ for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) {
+ if (line.spans().stream().anyMatch(
+ span -> span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan)) {
+ // Lines whose gaps Word shares out step closer than the page's; the middle one is
+ // matched, so the first and last are off by as little as the steps allow.
+ double wordLine = written / POINT_TO_TWIP;
+ double middle = Math.max(0, layout.lineCount(node) - 1) / 2.0;
+ double pageStep = line.lineHeight() + layout.lineGap(node);
+ double pages = lineTopAbove + lineTopsTakenIn.getOrDefault(para.getCTP(), 0.0)
+ + middle * pageStep + line.lineHeight() - line.baselineOffsetFromBottom();
+ double shift = middle * wordLine + wordLine * DocxTextBands.BASELINE_SHARE - pages;
+ return Math.abs(shift) < LEAST_BASELINE_SHIFT_POINTS ? 0 : shift;
+ }
+ }
+ return 0;
+ }
+
/** A line break in text, as the page breaks lines at it (see {@code ParagraphWrapping}). */
private static final java.util.regex.Pattern LINE_BREAK = java.util.regex.Pattern.compile("\r\n|\r|\n");
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
index 69affea3e..de597670d 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
@@ -19,10 +19,12 @@
* apart, tighter than its face's own line — the page overlaps the line boxes — was written a
* line at a time at each line's own height: {@code NorthlineProposal}'s three 46pt title
* lines, 48pt apart on the page, took 70pt each in Word, and everything under them on the
- * cover stood 44pt low while the shapes drawn where the page puts them stayed. Word sets the
- * foot of an exactly-spaced line at the bottom of its line, so the first line keeps its own
- * height and each line after it takes the distance from the foot of the line above to its
- * own: every line's foot, and the stack's bottom, land where the page puts them.
+ * cover stood 44pt low while the shapes drawn where the page puts them stayed. So each line
+ * laid over by the next is written as tall as the distance down to the next one's top, and in a
+ * shape container on one page the last as tall as the rest of the container where its own line
+ * runs past it: every line starts where the page starts it, and such a stack is as tall as its
+ * container. Where each line's text stands in its shorter line is
+ * the text's seat, not the line's height (see {@code DocxSemanticBackend#shiftToThePagesBaseline}).
*/
final class DocxStackedLines {
@@ -38,29 +40,36 @@ private DocxStackedLines() {
}
/**
- * The line each paragraph laid over the layer before it is written at.
+ * The line each paragraph of a stack is written at.
*
- * A paragraph counts when it and the layer before it are paragraphs of one laid-out
- * line on one page, its box starts above that line's foot and overlaps it across — a value
- * set beside its label a few points lower is a line of its own — and the distance between
- * the two feet is shorter than its own line and no shorter than {@link #LEAST_SHARE_OF_FACE}
- * of its largest face. A line holding anything but text, whose picture Word could cut off,
- * keeps its own height.
+ * A paragraph is laid over by the next layer when both are paragraphs of one laid-out
+ * line on one page, the next one's box starts above this one's foot and overlaps it across
+ * — a value set beside its label a few points lower is a line of its own — and the
+ * distance between their tops is shorter than this one's own line and no shorter than
+ * {@link #LEAST_SHARE_OF_FACE} of its largest face. It is written as tall as that distance.
+ * The last layer, laid over the one before it, is written as tall as the room from its top
+ * to the container's foot when its own line runs past it, under the same least share. A line
+ * holding anything but text, whose picture Word could cut off, keeps its own height.
*
* @param layers a container's children, in the order they are written
+ * @param foot the foot of the container's content, measured up from the foot of the page,
+ * or {@code NaN} when the last layer is to keep its own line
* @param layout where the layout placed them
* @return each such paragraph's line, in points
*/
- static Map of(List layers, DocxLayoutMetrics layout) {
+ static Map of(List layers, double foot, DocxLayoutMetrics layout) {
Map lines = new IdentityHashMap<>();
+ // After the loop: whether the last layer is laid over the one before it.
+ boolean laidOver = false;
for (int i = 1; i < layers.size(); i++) {
- if (!(layers.get(i - 1) instanceof ParagraphNode above)
- || !(layers.get(i) instanceof ParagraphNode line)
- || layout.lineCount(above) != 1 || layout.lineCount(line) != 1) {
+ laidOver = false;
+ if (!(layers.get(i - 1) instanceof ParagraphNode line)
+ || !(layers.get(i) instanceof ParagraphNode below)
+ || layout.lineCount(line) != 1 || layout.lineCount(below) != 1) {
continue;
}
- PlacedNode upper = layout.placement(above);
- PlacedNode lower = layout.placement(line);
+ PlacedNode upper = layout.placement(line);
+ PlacedNode lower = layout.placement(below);
OptionalDouble own = layout.lineHeight(line);
double face = largestFace(line);
if (upper == null || lower == null || own.isEmpty() || Double.isNaN(face)
@@ -72,14 +81,32 @@ static Map of(List layers, DocxLayoutMetric
boolean overlaps = lower.placementY() + lower.placementHeight() > upper.placementY() + 0.01;
boolean under = lower.placementX() < upper.placementX() + upper.placementWidth()
&& upper.placementX() < lower.placementX() + lower.placementWidth();
- double footToFoot = upper.placementY() - lower.placementY();
- if (overlaps && under && footToFoot >= face * LEAST_SHARE_OF_FACE && footToFoot < own.getAsDouble()) {
- lines.put(line, footToFoot);
+ double topToTop = top(upper) - top(lower);
+ if (overlaps && under && fits(topToTop, own.getAsDouble(), face)) {
+ lines.put(line, topToTop);
+ laidOver = true;
+ }
+ }
+ if (laidOver && !Double.isNaN(foot) && layers.get(layers.size() - 1) instanceof ParagraphNode last) {
+ PlacedNode box = layout.placement(last);
+ OptionalDouble own = layout.lineHeight(last);
+ double face = largestFace(last);
+ if (box != null && own.isPresent() && !Double.isNaN(face) && fits(top(box) - foot, own.getAsDouble(), face)) {
+ lines.put(last, top(box) - foot);
}
}
return lines;
}
+ /** Whether a line may be written shorter than its own, at {@code height}. */
+ private static boolean fits(double height, double own, double face) {
+ return height >= face * LEAST_SHARE_OF_FACE && height < own;
+ }
+
+ private static double top(PlacedNode box) {
+ return box.placementY() + box.placementHeight();
+ }
+
/**
* The largest size a paragraph's text is set in: its runs' where it has runs, else its own;
* {@code NaN} when a run is anything but text — a picture, an icon — whose height is not
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java
index 04edf64c5..ab4181f04 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java
@@ -30,9 +30,11 @@ final class DocxTextBands {
static final double LINE_FACTOR = 1.2;
/**
- * Where the baseline stands in an exact line, as a share of the line from its top: both
- * editors centre the type's ascent and descent in the line, and the fonts a band is set in
- * put the baseline about four fifths of the way down.
+ * Where both editors stand the baseline in an exact line, as a share of the line from its
+ * top. Measured, it is four fifths of the line whatever the face and size: Spectral, Lato
+ * and Arial at 10 to 46pt, in lines 12 to 100pt tall, within 0.1pt of it in Word and on it in
+ * LibreOffice. A band's line is placed by it here, and a paragraph's text is moved from it to
+ * the page's baseline ({@code DocxSemanticBackend#shiftToThePagesBaseline}).
*/
static final double BASELINE_SHARE = 0.8;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
new file mode 100644
index 000000000..ea6fabc61
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
@@ -0,0 +1,156 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Text stands on the page's baseline in Word's exact lines.
+ *
+ * Word stands the baseline of an exact line four fifths of the way down it, whatever the
+ * face; the page sets it the face's ascent below the line's top. {@code NorthlineProposal}'s
+ * 46pt Spectral title, whose ascent is under seven tenths of its line, stood 7pt low in Word.
+ */
+class DocxBaselineSeatTest {
+
+ /** Spectral's ascent, in ems: 1059 of 1000 units. */
+ private static final double SPECTRAL_ASCENT = 1.059;
+ /** Spectral's own line, in ems: its ascent and 463 units of descent. */
+ private static final double SPECTRAL_LINE = 1.522;
+
+ private static DocumentTextStyle spectral(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(size).build();
+ }
+
+ @Test
+ void aDeepFaceIsRaisedFromWordsBaselineToThePages() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("Title").textStyle(spectral(30))))) {
+ XWPFParagraph title = paragraph(document, "Title");
+ double wordBelowThePage = 0.8 * line(title) - SPECTRAL_ASCENT * 30;
+
+ assertThat(wordBelowThePage).as("the premise: Word's baseline is the lower").isGreaterThan(4);
+ assertThat(position(title)).isCloseTo((int) Math.round(wordBelowThePage * 2), within(1));
+ }
+ }
+
+ @Test
+ void aFaceWhoseBaselineWordAlreadyNearlyMatchesIsLeftAlone() throws Exception {
+ // Lato's ascent is 0.82 of its line: a quarter point from Word's four fifths at 10pt.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("Body").textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(10).build())))) {
+ assertThat(position(paragraph(document, "Body"))).isZero();
+ }
+ }
+
+ @Test
+ void aLineStackedShorterThanItsFaceIsLoweredIntoIt() throws Exception {
+ // Lines 48pt apart in a 46pt face: Word's baseline, four fifths down 48pt, stands above
+ // the page's, the face's ascent down.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .add(new ShapeContainerBuilder().name("Title").rectangle(360, 140)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("One").text("One").textStyle(spectral(46)).build(),
+ 0, 0, LayerAlign.TOP_LEFT)
+ .position(new ParagraphBuilder().name("Two").text("Two").textStyle(spectral(46)).build(),
+ 0, 48, LayerAlign.TOP_LEFT)
+ .build()))) {
+ XWPFParagraph one = paragraph(document, "One");
+
+ assertThat(line(one)).isCloseTo(48, within(0.05));
+ assertThat(position(one)).isCloseTo((int) Math.round((0.8 * 48 - SPECTRAL_ASCENT * 46) * 2), within(1));
+ }
+ }
+
+ @Test
+ void theHalfOfALinePairSetLowerIsSeatedFromTheLinesTop() throws Exception {
+ // A 12pt label centred beside a 30pt value: the line is the value's, and the label's own
+ // line starts half the difference below its top.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .add(new ShapeContainerBuilder().name("Row").rectangle(360, 60)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("Label").text("Label").textStyle(spectral(12)).build(),
+ 0, 0, LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Value").text("Value").textStyle(spectral(30)).build(),
+ 0, 0, LayerAlign.CENTER_RIGHT)
+ .build()))) {
+ XWPFParagraph pair = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().contains("Value")).findFirst().orElseThrow();
+ double line = line(pair);
+ double labelLine = line * 12 / 30;
+ double labelBelow = (line - labelLine) / 2;
+ CTR label = pair.getRuns().stream().filter(run -> run.text().contains("Label")).findFirst()
+ .orElseThrow().getCTR();
+
+ assertThat(pair.getText()).as("one line").contains("Label");
+ assertThat(positionOf(label))
+ .isCloseTo((int) Math.round((0.8 * line - labelBelow - SPECTRAL_ASCENT * 12) * 2), within(1));
+ }
+ }
+
+ @Test
+ void linesThatTookTheirGapFromTheSpaceAboveAreSeatedFromTheHigherTop() throws Exception {
+ // Two lines 10pt apart under 20pt of space: Word's lines take their gaps from above and
+ // start 10pt above the page's first line.
+ try (XWPFDocument document = DocxExports.withLayout(240, 600, 20, page -> page
+ .addParagraph(p -> p.text("First line of it and second line of it").textStyle(spectral(20))
+ .lineSpacing(10).margin(new DocumentInsets(20, 0, 0, 0))))) {
+ XWPFParagraph text = paragraph(document, "First line of it and second line of it");
+ double line = line(text);
+
+ assertThat(line).as("the premise: two lines, each its gap taller").isGreaterThan(40);
+ assertThat(position(text)).isCloseTo((int) Math.round((0.8 * line - 10 - SPECTRAL_ASCENT * 20) * 2),
+ within(1));
+ }
+ }
+
+ @Test
+ void linesWhoseGapIsSharedOutAreSeatedAtTheMiddleOne() throws Exception {
+ // Two lines 10pt apart with no space above to give: Word's lines are each 5pt taller
+ // than the face, stepping 5pt closer than the page's. Seated at the first line, the
+ // second would stand 5pt high; at the middle, each is 2.5pt off.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.text("First\nSecond").textStyle(spectral(20)).lineSpacing(10)))) {
+ XWPFParagraph text = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().contains("First")).findFirst().orElseThrow();
+ double face = SPECTRAL_LINE * 20;
+ double line = line(text);
+ double middle = 0.5;
+
+ assertThat(line).as("the premise: the one gap shared by two lines").isCloseTo(face + 5, within(0.05));
+ assertThat(position(text)).isCloseTo((int) Math.round(
+ (middle * line + 0.8 * line - middle * (face + 10) - SPECTRAL_ASCENT * 20) * 2), within(1));
+ }
+ }
+
+ private static XWPFParagraph paragraph(XWPFDocument document, String text) {
+ return document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().equals(text)).findFirst().orElseThrow();
+ }
+
+ private static double line(XWPFParagraph paragraph) {
+ return DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine()) / 20.0;
+ }
+
+ private static int position(XWPFParagraph paragraph) {
+ return positionOf(paragraph.getRuns().get(0).getCTR());
+ }
+
+ private static int positionOf(CTR run) {
+ var properties = run.getRPr();
+ return properties == null || properties.sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getPositionArray(0).getVal()).intValue();
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
index 7cfd7fc45..a53be36c6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
@@ -32,15 +32,30 @@ class DocxStackedLayersTest {
private static final double PITCH = 32;
private static final DocumentTextStyle LARGE = DocumentTextStyle.builder().fontName(FontName.LATO).size(30).build();
+ /** Lato's own line at 30pt: its ascent and descent, 2400 of 2000 units. */
+ private static final double LARGE_LINE = 36;
@Test
- void eachLineLaidOverTheOneAboveTakesTheDistanceBetweenTheirFeet() throws Exception {
+ void eachLineLaidOverByTheNextTakesTheDistanceDownToItsTop() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.add(title(2 * PITCH + 40)))) {
- assertThat(line(paragraph(document, "One"))).as("the first line keeps its own height")
- .isGreaterThan(PITCH);
+ assertThat(line(paragraph(document, "One"))).isCloseTo(PITCH, within(0.05));
assertThat(line(paragraph(document, "Two"))).isCloseTo(PITCH, within(0.05));
- assertThat(line(paragraph(document, "Three"))).isCloseTo(PITCH, within(0.05));
+ assertThat(line(paragraph(document, "Three"))).as("room for its own line under the box: its own height")
+ .isCloseTo(LARGE_LINE, within(0.05));
+ }
+ }
+
+ @Test
+ void theLastLineOfAStackEndsAtTheContainersFootAndHangsPastNothing() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addSection("Cover", cover -> cover.spacing(30)
+ .add(title(2 * PITCH + 30))
+ .addParagraph("After")))) {
+ assertThat(line(paragraph(document, "Three"))).as("the rest of the box, not its own 36pt")
+ .isCloseTo(30, within(0.05));
+ assertThat(before(paragraph(document, "After"))).as("the whole gap under the box")
+ .isCloseTo(30, within(0.05));
}
}
@@ -61,9 +76,10 @@ void aLineLaidTooTightForItsFaceKeepsItsOwnHeight() throws Exception {
@Test
void aLineHoldingAPictureKeepsItsOwnHeight() throws Exception {
- // A picture is not its line's face: squeezed, Word would cut its top off.
+ // A picture is not its line's face: squeezed to the 28pt left in the box, Word would
+ // cut its top off.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
- .add(new ShapeContainerBuilder().name("Mixed").rectangle(300, 80)
+ .add(new ShapeContainerBuilder().name("Mixed").rectangle(300, PITCH + 28)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
.position(new ParagraphBuilder().name("One").text("One").textStyle(LARGE).build(),
0, 0, LayerAlign.TOP_LEFT)
@@ -74,8 +90,8 @@ void aLineHoldingAPictureKeepsItsOwnHeight() throws Exception {
.build()))) {
XWPFParagraph two = document.getParagraphs().stream()
.filter(paragraph -> paragraph.getText().startsWith("Two")).findFirst().orElseThrow();
- assertThat(line(two)).as("its own line, not the step down from the one above")
- .isEqualTo(line(paragraph(document, "One")));
+ assertThat(line(two)).as("its own line, not the rest of the box")
+ .isCloseTo(LARGE_LINE, within(0.05));
}
}
@@ -111,10 +127,11 @@ void aLineBesideTheOneAboveKeepsItsOwnHeight() throws Exception {
@Test
void theLastLinesOverhangBelowItsBoxComesOutOfTheGapUnderIt() throws Exception {
- // A box 4pt shorter leaves the last line hanging 4pt further below it, and the
- // paragraph after it that much less space above.
- double shorter = spaceAboveTheParagraphAfter(2 * PITCH + 30);
- double taller = spaceAboveTheParagraphAfter(2 * PITCH + 34);
+ // Two lines too tight to stack: the second keeps its own line, its foot 54pt down. A
+ // box 4pt shorter leaves it hanging 4pt further below, and the paragraph after it that
+ // much less space above.
+ double shorter = spaceAboveTheParagraphAfter(46);
+ double taller = spaceAboveTheParagraphAfter(50);
assertThat(taller - shorter).isCloseTo(4, within(0.1));
}
@@ -142,15 +159,25 @@ void anIconOverItsLabelIsWrittenAsBefore() throws Exception {
private static double spaceAboveTheParagraphAfter(double boxHeight) throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addSection("Cover", cover -> cover.spacing(30)
- .add(title(boxHeight))
+ .add(new ShapeContainerBuilder().name("Tight").rectangle(300, boxHeight)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("One").text("One").textStyle(LARGE).build(),
+ 0, 0, LayerAlign.TOP_LEFT)
+ .position(new ParagraphBuilder().name("Two").text("Two").textStyle(LARGE).build(),
+ 0, 18, LayerAlign.TOP_LEFT)
+ .build())
.addParagraph("After")))) {
- CTPPr properties = paragraph(document, "After").getCTP().getPPr();
- return properties != null && properties.isSetSpacing() && properties.getSpacing().isSetBefore()
- ? DocxTwips.of(properties.getSpacing().getBefore()) / 20.0
- : 0;
+ return before(paragraph(document, "After"));
}
}
+ private static double before(XWPFParagraph paragraph) {
+ CTPPr properties = paragraph.getCTP().getPPr();
+ return properties != null && properties.isSetSpacing() && properties.getSpacing().isSetBefore()
+ ? DocxTwips.of(properties.getSpacing().getBefore()) / 20.0
+ : 0;
+ }
+
private static DocumentNode title(double boxHeight) {
return new ShapeContainerBuilder().name("Title").rectangle(300, boxHeight)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSeatTest.java
index 684a1b147..b56d042e7 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSeatTest.java
@@ -34,14 +34,15 @@ class DocxVerticalSeatTest {
@Test
void textSeatedOffItsBaselineIsMovedInItsLine() throws Exception {
+ // Measured from where it stands on its baseline, which Word's line moves too.
+ int baseline = position(TextVerticalAlign.DEFAULT);
int top = position(TextVerticalAlign.TOP);
int bottom = position(TextVerticalAlign.BOTTOM);
- assertThat(top).as("raised to the line's top").isPositive();
- assertThat(bottom).as("lowered to the line's foot").isNegative();
+ assertThat(top).as("raised to the line's top").isGreaterThan(baseline);
+ assertThat(bottom).as("lowered to the line's foot").isLessThan(baseline);
assertThat(position(TextVerticalAlign.CENTER)).as("halfway between the two, the page's own midpoint")
.isCloseTo((top + bottom) / 2, org.assertj.core.data.Offset.offset(1));
- assertThat(position(TextVerticalAlign.DEFAULT)).as("on its baseline, as written before").isZero();
}
@Test
@@ -75,13 +76,22 @@ void anOverhangDeeperThanTheBandsMarginLeavesNoGapAtAll() throws Exception {
@Test
void onlyTheSeatedHalfOfALineMovesInIt() throws Exception {
- // A label and its value set as one line: the value is seated at its line's top, the
- // label on its baseline, and the label stays there.
+ // A label and its value set as one line: the value seated at its line's top moves up
+ // from where it stands on its baseline, and the label beside it stays where it was.
+ int[] onTheBaseline = labelAndValue(TextVerticalAlign.DEFAULT);
+ int[] atTheTop = labelAndValue(TextVerticalAlign.TOP);
+
+ assertThat(atTheTop[0]).as("the label where it was").isEqualTo(onTheBaseline[0]);
+ assertThat(atTheTop[1]).as("the value raised").isGreaterThan(onTheBaseline[1]);
+ }
+
+ /** The run positions of a label and a value seated as given, set as one line. */
+ private static int[] labelAndValue(TextVerticalAlign seat) throws Exception {
DocumentNode row = new ShapeContainerBuilder().name("Row")
.rectangle(300, 40).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
.position(new ParagraphBuilder().name("Label").text("Label").build(), 0, 0, LayerAlign.CENTER_LEFT)
.position(new ParagraphBuilder().name("Value").text("Value").textStyle(DISPLAY)
- .verticalAlign(TextVerticalAlign.TOP).build(), 0, 0, LayerAlign.CENTER_RIGHT)
+ .verticalAlign(seat).build(), 0, 0, LayerAlign.CENTER_RIGHT)
.build();
try (XWPFDocument document = DocxExports.withLayout(400, 600, 40, page -> page.add(row))) {
XWPFParagraph line = document.getParagraphs().stream()
@@ -90,8 +100,7 @@ void onlyTheSeatedHalfOfALineMovesInIt() throws Exception {
assertThat(line.getText()).as("one line").contains("Label");
var label = line.getRuns().stream().filter(run -> run.text().contains("Label")).findFirst().orElseThrow();
var value = line.getRuns().stream().filter(run -> run.text().contains("Value")).findFirst().orElseThrow();
- assertThat(positionOf(label.getCTR())).as("the label on its baseline").isZero();
- assertThat(positionOf(value.getCTR())).as("the value raised").isPositive();
+ return new int[]{positionOf(label.getCTR()), positionOf(value.getCTR())};
}
}
From fd3cc03e077e48fab11f435402f653b852d4ce3b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 30 Sep 2026 12:21:16 +0100
Subject: [PATCH 2/3] fix(docx): end stacked lines between their letters so
Word shows them whole
Word draws an exact line's text on screen only inside the line, though its PDF export draws it whole. Lines a pitch tall cut the title's descenders and capitals; each line of a stack now ends halfway between its letters and the next line's, read from the glyph outlines.
---
CHANGELOG.md | 19 +-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 18 +-
.../backend/semantic/docx/DocxInk.java | 76 +++++++
.../semantic/docx/DocxSemanticBackend.java | 61 ++++--
.../semantic/docx/DocxStackedLines.java | 201 +++++++++++-------
.../semantic/docx/DocxBaselineSeatTest.java | 19 --
.../backend/semantic/docx/DocxInkTest.java | 75 +++++++
.../semantic/docx/DocxStackedLayersTest.java | 143 +++++++++++--
9 files changed, 466 insertions(+), 148 deletions(-)
create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bc900f7e..dd1c9b37c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,10 +20,12 @@ follow semantic versioning; release dates are ISO 8601.
from the line Word was given — a line pair's from the higher text's top, lines that took their
gaps from the space above from that higher top, several lines at the middle one; a list item's
and a composed table cell's are not moved yet. Lines a container stacks tighter than their face
- are each written as tall as the step to the next one's top, and in a shape container the last
- to the container's foot. In Word `NorthlineProposal`'s median drift falls from 8.0pt to 0.8, its
- cover within 1.5pt of the page throughout, and `EditorialProposal`'s from 5.9 to 0.4; across the
- 62 templates, lines more than 2pt off fall from 1464 to 1341.
+ are each written to end halfway between their own letters and the next line's, read from the
+ glyphs' outlines, and in a shape container the last to the container's foot or below its
+ letters: Word draws an exact line's text on screen only inside the line, and lines a pitch tall
+ cut the title's descenders and capitals there. In Word `NorthlineProposal`'s median drift falls
+ from 8.0pt to 0.8, its cover within 1.5pt of the page throughout, and `EditorialProposal`'s from
+ 5.9 to 0.4; across the 62 templates, lines more than 2pt off fall from 1464 to 1341.
- **A value set from its start keeps its start in Word.** Two texts one layer holds on one line —
a label and its value, a title and its dates — are written as one Word line split by a tab
stop, and the stop was always a right tab at the right text's end. A value aligned left in a
@@ -134,11 +136,10 @@ follow semantic versioning; release dates are ISO 8601.
container written layer by layer took more room in Word than on the page for two kinds of
layer. Lines of text laid over one another — `NorthlineProposal`'s title, three 46pt lines
48pt apart — took each line's own height, 70pt; now each single line of text laid over by
- the next across takes the step down to that one's top (`DocxStackedLines`), and a shape
- container's last line running past the container's foot takes its overhang from the gap
- below, or, ending such a stack, the rest of the container. A line is never squeezed below 0.65
- of its face, nor one holding a picture: Word was measured setting a 16pt word whole in a
- 10.7pt line. An icon picture beside a layer of text in a shape container neither painted,
+ the next across ends halfway between its letters and the next line's (`DocxStackedLines`),
+ and a shape container's last line running past the container's foot takes its overhang from
+ the gap below. Lines whose letters meet, and a line holding a picture, keep their own height.
+ An icon picture beside a layer of text in a shape container neither painted,
clipped to its outline nor transformed, or in a layer stack — `NorthlineProposal`'s glance
card facts, the contact lines of `ConsultingInvoice` — was written as a line of its own
above the label, an icon's height a fact; clear of the text across and at most twice its
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index a965a141d..01ab3ba12 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -62,7 +62,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a composed table cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face are written a pitch tall, and in a shape container on one page the last to the container's foot |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a composed table cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and in a shape container on one page the last at the container's foot or below its letters |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 826965e67..5a5d63bde 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -173,7 +173,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. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a composed table cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a shape container stacks tighter than their face — a title's lines a pitch apart — are each written as tall as the step to the next one's top and the last to the container's foot, so the stack is as tall as the container |
+| 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. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a composed table cell's are not yet. 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 in a shape container the last at the container's 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. 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 |
@@ -555,13 +555,15 @@ tint it was flattened to. Recorded, like the other two.
puts it, in front inside a painted panel, rather than written as a line
above the text. Single lines of text a container written layer by layer
lays over one another — a title set a pitch apart, tighter than its
- face's line — keep the page's pitch: each line laid over by the next
- across is as tall as the step down to that one's top, never below 0.65
- of its face nor when it holds a picture, and in a shape container on one
- page the last is as tall as the rest of the container where its own line
- runs past it; its text is seated in the shorter line by `w:position`.
- Any other last line running past a shape container's foot takes its
- overhang from the gap below. An outline no
+ face's line — keep the page's pitch: Word draws an exact line's text on
+ screen only inside the line, so each line laid over by the next across
+ ends halfway between its letters and the next line's, read from the
+ glyphs' outlines, and its text is seated in it by `w:position`. In a
+ shape container on one page the last ends at the container's foot, or
+ below its letters where they hang past it, the overhang taken from the
+ gap below. Lines whose letters meet, and a line holding a picture, keep
+ their own height. Any other last line running past a shape container's
+ foot takes its overhang from the gap below. An outline no
shape shows is reported as dropped.
- **`hangingIndent(true)` → the ordinary list form.** A list that opts
into marker/content geometry exports exactly as one that did not: the
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java
new file mode 100644
index 000000000..4bce5386e
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java
@@ -0,0 +1,76 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
+import com.demcha.compose.document.layout.payloads.ParagraphSpan;
+import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
+import com.demcha.compose.engine.render.pdf.PdfFont;
+import com.demcha.compose.font.FontLibrary;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDVectorFont;
+
+import java.awt.geom.Rectangle2D;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+/**
+ * How far a line's letters reach above and below its baseline: the outlines of its glyphs, in
+ * the fonts the layout measured it with.
+ *
+ * Word draws the text of an exact line on screen only inside the line: a letter reaching past
+ * its top or foot is cut off there, though its PDF export draws it whole. A face's ascent and
+ * descent are the room its tallest and deepest glyphs could need; the letters of one line need
+ * less, and lines a title sets closer than its face's line fit only by what they hold.
+ */
+final class DocxInk {
+
+ private DocxInk() {
+ }
+
+ /**
+ * The reach of a line's letters, in points: {@code {above, below}} its baseline, each at
+ * least 0; {@code null} when a span is not text, a font is not known or a glyph has no
+ * outline to read.
+ *
+ * @param line the laid-out line
+ * @param fonts the fonts the layout measured it with
+ */
+ static double[] of(ParagraphLine line, FontLibrary fonts) {
+ double above = 0;
+ double below = 0;
+ for (ParagraphSpan span : line.spans()) {
+ if (!(span instanceof ParagraphTextSpan text)) {
+ return null;
+ }
+ PdfFont font = fonts.getFont(text.textStyle().fontName(), PdfFont.class).orElse(null);
+ if (font == null) {
+ return null;
+ }
+ PDFont face = font.fontType(text.textStyle().decoration());
+ if (!(face instanceof PDVectorFont outlines)) {
+ return null;
+ }
+ double scale = text.textStyle().size() / 1000.0;
+ String shown = font.sanitizeForRender(text.textStyle(), text.text());
+ for (int i = 0; i < shown.length(); ) {
+ int codePoint = shown.codePointAt(i);
+ i += Character.charCount(codePoint);
+ if (Character.isWhitespace(codePoint)) {
+ continue;
+ }
+ try {
+ byte[] bytes = face.encode(new String(Character.toChars(codePoint)));
+ int code = face.readCode(new ByteArrayInputStream(bytes));
+ Rectangle2D bounds = outlines.getNormalizedPath(code).getBounds2D();
+ if (bounds.isEmpty()) {
+ continue;
+ }
+ above = Math.max(above, bounds.getMaxY() * scale);
+ below = Math.max(below, -bounds.getMinY() * scale);
+ } catch (IOException | IllegalArgumentException unreadable) {
+ return null;
+ }
+ }
+ }
+ return new double[]{above, below};
+ }
+}
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 f336a5d02..71161136c 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
@@ -231,7 +231,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
// The line a layer of text is written at when it overlaps the layer above it, in points:
// see holdStackedLines.
- private final java.util.Map stackedLineHeights = new java.util.IdentityHashMap<>();
+ private final java.util.Map stackedLineHeights =
+ new java.util.IdentityHashMap<>();
// How far above the page's first line a paragraph's Word line starts, in points, where its
// lines took the space between them from the space above it: see applyLineGap.
private final java.util.Map lineTopsTakenIn =
@@ -349,7 +350,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
// session's own registrations win over the bundled ones, the way they do everywhere.
private java.util.Map wordFamilies = java.util.Map.of();
// The families the layout measured with, and the fonts it measured them in, loaded when a
- // line seated off its baseline first asks for a cap height (see seatShift).
+ // line seated off its baseline first asks for a cap height (see seatShift) or a stack for its
+ // letters' reach (see inkOf).
private List measuredFamilies = List.of();
private FontLibrary seatFonts;
// What this export could not carry as authored. Collected whether or not anyone asked
@@ -4206,17 +4208,32 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node,
}
/**
- * Holds each line of text a container lays the next one over to the distance down to that
- * one's top, and the last to the container's foot, so written one after the other they
- * start where the page stacks them and end where the container does (see
+ * Holds the lines of text a container lays over one another to lines that each end between
+ * its letters and the next one's, the last at the container's foot, so written one after
+ * the other they hold their letters whole and end where the container does (see
* {@link DocxStackedLines}).
*
* @param layers a container's children, in the order they are written
* @param foot the foot of the container's content, measured up from the foot of the page,
- * or {@code NaN} to leave the last line its own height
+ * or {@code NaN} to end the last line where its own line ends
*/
private void holdStackedLines(List layers, double foot) {
- stackedLineHeights.putAll(DocxStackedLines.of(layers, foot, layout));
+ stackedLineHeights.putAll(DocxStackedLines.of(layers, foot, layout, this::inkOf));
+ }
+
+ /** How far a paragraph's first line's letters reach above and below its baseline (see {@link DocxInk}). */
+ private double[] inkOf(ParagraphNode paragraph) {
+ java.util.Optional line = layout.firstLine(paragraph);
+ return line.isEmpty() ? null : DocxInk.of(line.get(), measuredFonts());
+ }
+
+ /** The fonts the layout measured with, loaded when first asked for. */
+ private FontLibrary measuredFonts() {
+ if (seatFonts == null) {
+ seatFonts = com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory
+ .measurementLibrary(measuredFamilies);
+ }
+ return seatFonts;
}
/**
@@ -4237,14 +4254,19 @@ private double contentFoot(ShapeContainerNode node) {
* container, and the line's overhang is taken from the gap above it.
*
* A line whose own foot runs past its container's pushed what follows that much lower.
- * The last line of a stack is written to the container's foot instead, and hangs past
- * nothing (see {@link #holdStackedLines}).
+ * The last line of a stack ends at the container's foot or below its own letters, and hangs
+ * as far as those run past the foot (see {@link #holdStackedLines}).
*/
private void hangBelowItsBox(ShapeContainerNode node) {
List layers = node.children();
- // A stack's last line written to the container's foot hangs past nothing.
- if (layers.isEmpty() || !(layers.get(layers.size() - 1) instanceof ParagraphNode last)
- || stackedLineHeights.containsKey(last)) {
+ if (layers.isEmpty() || !(layers.get(layers.size() - 1) instanceof ParagraphNode last)) {
+ return;
+ }
+ DocxStackedLines.Line stacked = stackedLineHeights.get(last);
+ if (stacked != null) {
+ if (stacked.hang() > 0.01) {
+ hangingBelow = Math.max(hangingBelow, stacked.hang());
+ }
return;
}
com.demcha.compose.document.layout.PlacedNode box = layout.placement(node);
@@ -4333,7 +4355,10 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
boolean rightToLeft = applyParagraphProperties(para, node);
applyHeadingRole(para, node);
int anchor = openAnchor(para, node.anchor());
- writeParagraphRuns(para, node, rightToLeft);
+ // A line of a stack starts where its letters and the ones above leave room, not where
+ // the page's line does (see DocxStackedLines).
+ DocxStackedLines.Line stacked = stackedLineHeights.get(node);
+ writeParagraphRuns(para, node, rightToLeft, stacked == null ? 0 : stacked.topAbove());
closeAnchor(para, anchor);
}
@@ -4391,8 +4416,8 @@ private boolean applyParagraphProperties(XWPFParagraph target, ParagraphNode sou
boolean rightToLeft = ParagraphDirection.resolve(source) == TextDirection.RTL;
target.setAlignment(toAlignment(source.align(), rightToLeft));
applyDirection(target, rightToLeft);
- Double stacked = stackedLineHeights.get(source);
- applyLineHeight(target, stacked != null ? java.util.OptionalDouble.of(stacked) : layout.lineHeight(source));
+ DocxStackedLines.Line stacked = stackedLineHeights.get(source);
+ applyLineHeight(target, stacked != null ? java.util.OptionalDouble.of(stacked.height()) : layout.lineHeight(source));
applyVerticalSpacing(target, source);
applyLineGap(target, layout.lineGap(source), layout.lineCount(source));
return rightToLeft;
@@ -4748,12 +4773,8 @@ private double seatShift(ParagraphNode node) {
for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) {
if (line.spans().stream().anyMatch(
span -> span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan)) {
- if (seatFonts == null) {
- seatFonts = com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory
- .measurementLibrary(measuredFamilies);
- }
return com.demcha.compose.document.backend.fixed.pdf.handlers.ParagraphSeating
- .shift(line, seatFonts, node.verticalAlign());
+ .shift(line, measuredFonts(), node.verticalAlign());
}
}
return 0;
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
index de597670d..472822861 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
@@ -1,16 +1,16 @@
package com.demcha.compose.document.backend.semantic.docx;
import com.demcha.compose.document.layout.PlacedNode;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.node.DocumentNode;
-import com.demcha.compose.document.node.InlineRun;
-import com.demcha.compose.document.node.InlineTextRun;
import com.demcha.compose.document.node.ParagraphNode;
-import com.demcha.compose.document.style.DocumentTextStyle;
+import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalDouble;
+import java.util.function.Function;
/**
* The lines of text a container lays over one another, and the line each is written at.
@@ -19,111 +19,158 @@
* apart, tighter than its face's own line — the page overlaps the line boxes — was written a
* line at a time at each line's own height: {@code NorthlineProposal}'s three 46pt title
* lines, 48pt apart on the page, took 70pt each in Word, and everything under them on the
- * cover stood 44pt low while the shapes drawn where the page puts them stayed. So each line
- * laid over by the next is written as tall as the distance down to the next one's top, and in a
- * shape container on one page the last as tall as the rest of the container where its own line
- * runs past it: every line starts where the page starts it, and such a stack is as tall as its
- * container. Where each line's text stands in its shorter line is
- * the text's seat, not the line's height (see {@code DocxSemanticBackend#shiftToThePagesBaseline}).
+ * cover stood 44pt low while the shapes drawn where the page puts them stayed.
+ *
+ * Written a pitch tall instead, the lines were cut: Word draws an exact line's text on screen
+ * only inside the line, and a 46pt line's letters, seated where the page sets them, reach past a
+ * 48pt step — "Proposal" lost its descenders and "Brand Refresh" the tops of its capitals. So
+ * each line of a stack ends halfway between its own letters and the next line's, where the page
+ * leaves room between them, and the stack ends at the container's foot or, where its last
+ * letters hang past it, below them. Every line holds its letters whole; the lines together are
+ * as tall as the page's, and the text is seated in each where the page sets it (see
+ * {@code DocxSemanticBackend#shiftToThePagesBaseline}).
*/
final class DocxStackedLines {
/**
- * The least share of its face a line is squeezed to. Measured, Word sets a 16pt word whole
- * in a 10.7pt exact line — {@code EditorialProposal}'s "STUDIO" under its "NORTHLINE", 0.67
- * of its face; a line squeezed further than that is left at its own height rather than
- * risked.
+ * The room kept below the stack's last letters, in points. Between two lines the edge is
+ * halfway between their letters, however close: {@code EditorialProposal}'s 41.8pt title,
+ * set 41.9pt apart, leaves a point between the "p" of one line and the capitals of the next,
+ * and the raise that seats each is rounded by at most a quarter point.
*/
- static final double LEAST_SHARE_OF_FACE = 0.65;
+ static final double INK_MARGIN = 0.75;
private DocxStackedLines() {
}
+ /**
+ * One line of a stack as written.
+ *
+ * @param height its exact height, in points
+ * @param topAbove how far above the page's line the Word line starts, in points
+ * @param hang how far the stack's last line runs past the container's foot, in points;
+ * 0 for any other line
+ */
+ record Line(double height, double topAbove, double hang) {
+ }
+
/**
* The line each paragraph of a stack is written at.
*
- * A paragraph is laid over by the next layer when both are paragraphs of one laid-out
- * line on one page, the next one's box starts above this one's foot and overlaps it across
- * — a value set beside its label a few points lower is a line of its own — and the
- * distance between their tops is shorter than this one's own line and no shorter than
- * {@link #LEAST_SHARE_OF_FACE} of its largest face. It is written as tall as that distance.
- * The last layer, laid over the one before it, is written as tall as the room from its top
- * to the container's foot when its own line runs past it, under the same least share. A line
- * holding anything but text, whose picture Word could cut off, keeps its own height.
+ * A stack is a run of layers, each a paragraph of one laid-out line on one page, whose
+ * box the next one's starts above the foot of and overlaps across — a value set beside its
+ * label a few points lower is a line of its own. A stack is written only where the letters
+ * of each line stay clear of the next one's, and each line's letters can be read: a line
+ * holding a picture, whose height is not its letters', keeps its own height, as does every
+ * line of its stack.
*
* @param layers a container's children, in the order they are written
* @param foot the foot of the container's content, measured up from the foot of the page,
- * or {@code NaN} when the last layer is to keep its own line
+ * or {@code NaN} when the last line is to end where its own line does
* @param layout where the layout placed them
- * @return each such paragraph's line, in points
+ * @param ink the reach of a paragraph's letters above and below its baseline, or
+ * {@code null} when it cannot be read (see {@link DocxInk})
+ * @return each stacked paragraph's line
*/
- static Map of(List layers, double foot, DocxLayoutMetrics layout) {
- Map lines = new IdentityHashMap<>();
- // After the loop: whether the last layer is laid over the one before it.
- boolean laidOver = false;
- for (int i = 1; i < layers.size(); i++) {
- laidOver = false;
- if (!(layers.get(i - 1) instanceof ParagraphNode line)
- || !(layers.get(i) instanceof ParagraphNode below)
- || layout.lineCount(line) != 1 || layout.lineCount(below) != 1) {
- continue;
- }
- PlacedNode upper = layout.placement(line);
- PlacedNode lower = layout.placement(below);
- OptionalDouble own = layout.lineHeight(line);
- double face = largestFace(line);
- if (upper == null || lower == null || own.isEmpty() || Double.isNaN(face)
- || upper.startPage() != upper.endPage() || lower.startPage() != upper.startPage()
- || lower.endPage() != upper.startPage()) {
- continue;
+ static Map of(List layers, double foot, DocxLayoutMetrics layout,
+ Function ink) {
+ Map lines = new IdentityHashMap<>();
+ List stack = new ArrayList<>();
+ for (int i = 0; i < layers.size(); i++) {
+ DocumentNode layer = layers.get(i);
+ boolean continues = !stack.isEmpty() && layer instanceof ParagraphNode next
+ && laidOver(stack.get(stack.size() - 1), next, layout);
+ if (!continues) {
+ write(stack, false, foot, layout, ink, lines);
+ stack.clear();
}
- // The page's y runs up from a box's foot.
- boolean overlaps = lower.placementY() + lower.placementHeight() > upper.placementY() + 0.01;
- boolean under = lower.placementX() < upper.placementX() + upper.placementWidth()
- && upper.placementX() < lower.placementX() + lower.placementWidth();
- double topToTop = top(upper) - top(lower);
- if (overlaps && under && fits(topToTop, own.getAsDouble(), face)) {
- lines.put(line, topToTop);
- laidOver = true;
- }
- }
- if (laidOver && !Double.isNaN(foot) && layers.get(layers.size() - 1) instanceof ParagraphNode last) {
- PlacedNode box = layout.placement(last);
- OptionalDouble own = layout.lineHeight(last);
- double face = largestFace(last);
- if (box != null && own.isPresent() && !Double.isNaN(face) && fits(top(box) - foot, own.getAsDouble(), face)) {
- lines.put(last, top(box) - foot);
+ if (layer instanceof ParagraphNode paragraph && oneLine(paragraph, layout)) {
+ stack.add(paragraph);
}
}
+ boolean endsTheContainer = !stack.isEmpty() && stack.get(stack.size() - 1) == layers.get(layers.size() - 1);
+ write(stack, endsTheContainer, foot, layout, ink, lines);
return lines;
}
- /** Whether a line may be written shorter than its own, at {@code height}. */
- private static boolean fits(double height, double own, double face) {
- return height >= face * LEAST_SHARE_OF_FACE && height < own;
+ /** Whether the page lays {@code below} over {@code above}: its box starts above that one's foot. */
+ private static boolean laidOver(ParagraphNode above, ParagraphNode below, DocxLayoutMetrics layout) {
+ if (!oneLine(below, layout)) {
+ return false;
+ }
+ PlacedNode upper = layout.placement(above);
+ PlacedNode lower = layout.placement(below);
+ if (upper == null || lower == null || lower.startPage() != upper.startPage()) {
+ return false;
+ }
+ // The page's y runs up from a box's foot.
+ boolean overlaps = lower.placementY() + lower.placementHeight() > upper.placementY() + 0.01;
+ boolean under = lower.placementX() < upper.placementX() + upper.placementWidth()
+ && upper.placementX() < lower.placementX() + lower.placementWidth();
+ return overlaps && under;
}
- private static double top(PlacedNode box) {
- return box.placementY() + box.placementHeight();
+ private static boolean oneLine(ParagraphNode paragraph, DocxLayoutMetrics layout) {
+ PlacedNode box = layout.placement(paragraph);
+ return layout.lineCount(paragraph) == 1 && box != null && box.startPage() == box.endPage();
}
/**
- * The largest size a paragraph's text is set in: its runs' where it has runs, else its own;
- * {@code NaN} when a run is anything but text — a picture, an icon — whose height is not
- * its face.
+ * Writes a stack's lines into {@code lines}, measured down from its first line's top.
+ *
+ * @param last whether the stack ends the container, and so may end at its foot
*/
- static double largestFace(ParagraphNode paragraph) {
- if (paragraph.inlineRuns() == null || paragraph.inlineRuns().isEmpty()) {
- return paragraph.textStyle().size();
+ private static void write(List stack, boolean last, double foot, DocxLayoutMetrics layout,
+ Function ink, Map lines) {
+ int n = stack.size();
+ if (n < 2) {
+ return;
}
- double largest = 0;
- for (InlineRun run : paragraph.inlineRuns()) {
- if (!(run instanceof InlineTextRun text)) {
- return Double.NaN;
+ double[] top = new double[n];
+ double[] inkTop = new double[n];
+ double[] inkFoot = new double[n];
+ double ownFoot = 0;
+ double first = Double.NaN;
+ for (int k = 0; k < n; k++) {
+ ParagraphNode paragraph = stack.get(k);
+ OptionalDouble lineTop = layout.firstLineTop(paragraph);
+ java.util.Optional line = layout.firstLine(paragraph);
+ double[] reach = ink.apply(paragraph);
+ if (lineTop.isEmpty() || line.isEmpty() || reach == null) {
+ return;
+ }
+ if (k == 0) {
+ first = lineTop.getAsDouble();
}
- DocumentTextStyle style = text.textStyle() != null ? text.textStyle() : paragraph.textStyle();
- largest = Math.max(largest, style.size());
+ top[k] = first - lineTop.getAsDouble();
+ double baseline = top[k] + line.get().lineHeight() - line.get().baselineOffsetFromBottom();
+ inkTop[k] = baseline - reach[0];
+ inkFoot[k] = baseline + reach[1];
+ ownFoot = top[k] + line.get().lineHeight();
+ }
+ if (inkTop[0] < 0) {
+ return;
+ }
+ double[] edge = new double[n + 1];
+ for (int k = 1; k < n; k++) {
+ // Letters the page sets into the next line's cannot both stand whole in two lines.
+ if (inkTop[k] < inkFoot[k - 1]) {
+ return;
+ }
+ edge[k] = (inkFoot[k - 1] + inkTop[k]) / 2;
+ }
+ double end = Math.max(ownFoot, inkFoot[n - 1] + INK_MARGIN);
+ double hang = 0;
+ if (last && !Double.isNaN(foot)) {
+ double containerFoot = first - foot;
+ if (ownFoot > containerFoot) {
+ end = Math.max(containerFoot, inkFoot[n - 1] + INK_MARGIN);
+ hang = Math.max(0, end - containerFoot);
+ }
+ }
+ edge[n] = end;
+ for (int k = 0; k < n; k++) {
+ lines.put(stack.get(k), new Line(edge[k + 1] - edge[k], top[k] - edge[k], k == n - 1 ? hang : 0));
}
- return largest;
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
index ea6fabc61..7805088f0 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
@@ -55,25 +55,6 @@ void aFaceWhoseBaselineWordAlreadyNearlyMatchesIsLeftAlone() throws Exception {
}
}
- @Test
- void aLineStackedShorterThanItsFaceIsLoweredIntoIt() throws Exception {
- // Lines 48pt apart in a 46pt face: Word's baseline, four fifths down 48pt, stands above
- // the page's, the face's ascent down.
- try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
- .add(new ShapeContainerBuilder().name("Title").rectangle(360, 140)
- .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
- .position(new ParagraphBuilder().name("One").text("One").textStyle(spectral(46)).build(),
- 0, 0, LayerAlign.TOP_LEFT)
- .position(new ParagraphBuilder().name("Two").text("Two").textStyle(spectral(46)).build(),
- 0, 48, LayerAlign.TOP_LEFT)
- .build()))) {
- XWPFParagraph one = paragraph(document, "One");
-
- assertThat(line(one)).isCloseTo(48, within(0.05));
- assertThat(position(one)).isCloseTo((int) Math.round((0.8 * 48 - SPECTRAL_ASCENT * 46) * 2), within(1));
- }
- }
-
@Test
void theHalfOfALinePairSetLowerIsSeatedFromTheLinesTop() throws Exception {
// A 12pt label centred beside a 30pt value: the line is the value's, and the label's own
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java
new file mode 100644
index 000000000..7d00ac964
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java
@@ -0,0 +1,75 @@
+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.backend.fixed.pdf.PdfFontLibraryFactory;
+import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.fontbox.ttf.TTFParser;
+import org.apache.fontbox.ttf.TrueTypeFont;
+import org.apache.pdfbox.io.RandomAccessReadBuffer;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * A line's letters reach as far as their own outlines, read from the face the layout measured.
+ */
+class DocxInkTest {
+
+ private static final double SIZE = 20;
+
+ @Test
+ void lettersReachAsFarAsTheirOwnOutlines() throws Exception {
+ TrueTypeFont face = spectralRegular();
+ double scale = SIZE / face.getUnitsPerEm();
+ double xHeight = face.getGlyph().getGlyph(face.nameToGID("x")).getBoundingBox().getUpperRightY() * scale;
+ double pDepth = -face.getGlyph().getGlyph(face.nameToGID("p")).getBoundingBox().getLowerLeftY() * scale;
+
+ double[] low = DocxInk.of(line("xxx"), fonts());
+ double[] deep = DocxInk.of(line("xpx"), fonts());
+
+ assertThat(low[0]).as("to the top of an x").isCloseTo(xHeight, within(0.05));
+ assertThat(low[1]).as("an x barely below the baseline").isLessThan(0.5);
+ assertThat(deep[1]).as("to the foot of a p").isCloseTo(pDepth, within(0.05));
+ }
+
+ @Test
+ void aLetterReachesLessFarThanTheFacesAscentAndDescent() throws Exception {
+ ParagraphLine line = line("Brand");
+ double[] reach = DocxInk.of(line, fonts());
+
+ assertThat(reach[0] + reach[1]).as("the letters of one line, not the room the face keeps")
+ .isLessThan(line.lineHeight() - 5);
+ }
+
+ private static com.demcha.compose.font.FontLibrary fonts() {
+ return PdfFontLibraryFactory.measurementLibrary(List.of());
+ }
+
+ private static ParagraphLine line(String text) throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 400)
+ .margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text(text).textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.SPECTRAL).size(SIZE).build())));
+ return session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload)
+ .map(fragment -> ((ParagraphFragmentPayload) fragment.payload()).lines().get(0))
+ .findFirst().orElseThrow();
+ }
+ }
+
+ private static TrueTypeFont spectralRegular() throws Exception {
+ try (InputStream in = DocxInkTest.class.getResourceAsStream("/fonts/google/spectral/Spectral-Regular.ttf")) {
+ assertThat(in).as("Spectral on the test class path").isNotNull();
+ return new TTFParser().parse(new RandomAccessReadBuffer(in));
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
index a53be36c6..06ff2840a 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
@@ -1,11 +1,18 @@
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.backend.fixed.pdf.PdfFontLibraryFactory;
import com.demcha.compose.document.dsl.ImageBuilder;
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.LayerAlign;
import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
@@ -15,7 +22,10 @@
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
@@ -36,13 +46,39 @@ class DocxStackedLayersTest {
private static final double LARGE_LINE = 36;
@Test
- void eachLineLaidOverByTheNextTakesTheDistanceDownToItsTop() throws Exception {
- try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
- .add(title(2 * PITCH + 40)))) {
- assertThat(line(paragraph(document, "One"))).isCloseTo(PITCH, within(0.05));
- assertThat(line(paragraph(document, "Two"))).isCloseTo(PITCH, within(0.05));
- assertThat(line(paragraph(document, "Three"))).as("room for its own line under the box: its own height")
- .isCloseTo(LARGE_LINE, within(0.05));
+ void eachLineOfAStackHoldsItsLettersWholeAndStandsOnThePagesBaseline() throws Exception {
+ // Word draws an exact line's text on screen only inside the line.
+ for (Stacked line : stacked(title(2 * PITCH + 40), "One", "Two", "Three")) {
+ assertThat(line.wordBaseline()).as("%s on the page's baseline", line.text())
+ .isCloseTo(line.pageBaseline(), within(0.3));
+ assertThat(line.wordBaseline() - line.inkAbove()).as("%s: its letters' tops inside its line", line.text())
+ .isGreaterThanOrEqualTo(line.top());
+ assertThat(line.wordBaseline() + line.inkBelow()).as("%s: its letters' feet inside its line", line.text())
+ .isLessThanOrEqualTo(line.top() + line.height());
+ }
+ }
+
+ @Test
+ void aTitleSetTighterThanItsFaceHoldsItsLettersWhole() throws Exception {
+ // NorthlineProposal's title: 46pt Spectral lines 48pt apart, whose letters a 48pt step
+ // cuts — "Proposal" loses its descenders, "Brand" the tops of its capitals.
+ DocumentTextStyle display = DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(46).build();
+ DocumentNode title = new ShapeContainerBuilder().name("Display").rectangle(520, 140)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("A").text("Proposal —").textStyle(display).build(),
+ 0, 0, LayerAlign.TOP_LEFT)
+ .position(new ParagraphBuilder().name("B").text("Brand Refresh &").textStyle(display).build(),
+ 0, 48, LayerAlign.TOP_LEFT)
+ .position(new ParagraphBuilder().name("C").text("Website Redesign").textStyle(display).build(),
+ 0, 96, LayerAlign.TOP_LEFT)
+ .build();
+ for (Stacked line : stacked(title, "Proposal —", "Brand Refresh &", "Website Redesign")) {
+ assertThat(line.wordBaseline()).as("%s on the page's baseline", line.text())
+ .isCloseTo(line.pageBaseline(), within(0.3));
+ assertThat(line.wordBaseline() - line.inkAbove()).as("%s: tops inside", line.text())
+ .isGreaterThanOrEqualTo(line.top());
+ assertThat(line.wordBaseline() + line.inkBelow()).as("%s: feet inside", line.text())
+ .isLessThanOrEqualTo(line.top() + line.height());
}
}
@@ -50,18 +86,38 @@ void eachLineLaidOverByTheNextTakesTheDistanceDownToItsTop() throws Exception {
void theLastLineOfAStackEndsAtTheContainersFootAndHangsPastNothing() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addSection("Cover", cover -> cover.spacing(30)
- .add(title(2 * PITCH + 30))
+ .add(title(2 * PITCH + 34))
.addParagraph("After")))) {
- assertThat(line(paragraph(document, "Three"))).as("the rest of the box, not its own 36pt")
- .isCloseTo(30, within(0.05));
+ double stack = line(paragraph(document, "One")) + line(paragraph(document, "Two"))
+ + line(paragraph(document, "Three"));
+ assertThat(stack).as("as tall as the box, its last line's own 36pt running past it")
+ .isCloseTo(2 * PITCH + 34, within(0.1));
assertThat(before(paragraph(document, "After"))).as("the whole gap under the box")
.isCloseTo(30, within(0.05));
}
}
@Test
- void aLineLaidTooTightForItsFaceKeepsItsOwnHeight() throws Exception {
- // 18pt apart is under two thirds of a 30pt face: Word would cut its letters off.
+ void theLettersOfAStacksLastLineHangingPastTheFootComeOutOfTheGapUnderIt() throws Exception {
+ // The box ends 4pt above the last line's baseline: its letters stand past the foot.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addSection("Cover", cover -> cover.spacing(30)
+ .add(title(2 * PITCH + 26))
+ .addParagraph("After")))) {
+ double stack = line(paragraph(document, "One")) + line(paragraph(document, "Two"))
+ + line(paragraph(document, "Three"));
+ double hang = stack - (2 * PITCH + 26);
+
+ assertThat(hang).as("the stack runs past the box").isGreaterThan(2);
+ assertThat(before(paragraph(document, "After"))).as("by as much less gap under it")
+ .isCloseTo(30 - hang, within(0.1));
+ }
+ }
+
+ @Test
+ void linesWhoseLettersOverlapKeepTheirOwnHeight() throws Exception {
+ // 18pt apart in a 30pt face, the letters of one line reach into the next's: no edge
+ // between them leaves both whole.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.add(new ShapeContainerBuilder().name("Tight").rectangle(300, 60)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
@@ -76,8 +132,8 @@ void aLineLaidTooTightForItsFaceKeepsItsOwnHeight() throws Exception {
@Test
void aLineHoldingAPictureKeepsItsOwnHeight() throws Exception {
- // A picture is not its line's face: squeezed to the 28pt left in the box, Word would
- // cut its top off.
+ // A picture's height is not its line's letters': squeezed to the 28pt left in the box,
+ // Word would cut its top off.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.add(new ShapeContainerBuilder().name("Mixed").rectangle(300, PITCH + 28)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
@@ -178,6 +234,65 @@ private static double before(XWPFParagraph paragraph) {
: 0;
}
+ /**
+ * One line of a stack as Word sets it, measured down from the stack's first line's top.
+ *
+ * @param top where its Word line starts
+ * @param height its Word line's height
+ * @param wordBaseline where Word stands its baseline: four fifths down, less its raise
+ * @param pageBaseline where the page sets it
+ * @param inkAbove how far its letters reach above the baseline
+ * @param inkBelow and below it
+ */
+ private record Stacked(String text, double top, double height, double wordBaseline, double pageBaseline,
+ double inkAbove, double inkBelow) {
+ }
+
+ /** Lays a container of stacked lines out, exports it, and reads each line's both ways. */
+ private static List stacked(DocumentNode container, String... texts) throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(595, 600)
+ .margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.add(container));
+ List fragments = session.layoutGraph().fragments();
+ byte[] docx = session.export(new DocxSemanticBackend());
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ List lines = new ArrayList<>();
+ double firstTop = Double.NaN;
+ double wordTop = 0;
+ for (String text : texts) {
+ PlacedFragment fragment = fragments.stream()
+ .filter(f -> f.payload() instanceof ParagraphFragmentPayload p
+ && p.lines().get(0).text().startsWith(text.split(" ")[0]))
+ .findFirst().orElseThrow();
+ ParagraphFragmentPayload payload = (ParagraphFragmentPayload) fragment.payload();
+ ParagraphLine line = payload.lines().get(0);
+ double pageTop = fragment.y() + fragment.height() - payload.padding().top();
+ if (Double.isNaN(firstTop)) {
+ firstTop = pageTop;
+ }
+ double pageBaseline = firstTop - pageTop + line.lineHeight() - line.baselineOffsetFromBottom();
+ double[] ink = DocxInk.of(line, PdfFontLibraryFactory.measurementLibrary(List.of()));
+ XWPFParagraph written = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().startsWith(text.split(" ")[0]))
+ .findFirst().orElseThrow();
+ double height = line(written);
+ double raise = positionOf(written) / 2.0;
+ assertThat(payload.lines()).as("%s on one line", text).hasSize(1);
+ lines.add(new Stacked(text, wordTop, height, wordTop + 0.8 * height - raise, pageBaseline,
+ ink[0], ink[1]));
+ wordTop += height;
+ }
+ return lines;
+ }
+ }
+ }
+
+ private static int positionOf(XWPFParagraph paragraph) {
+ var properties = paragraph.getRuns().get(0).getCTR().getRPr();
+ return properties == null || properties.sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getPositionArray(0).getVal()).intValue();
+ }
+
private static DocumentNode title(double boxHeight) {
return new ShapeContainerBuilder().name("Title").rectangle(300, boxHeight)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
From 519fde8431a1792ad7b9af219401ce1004f99e65 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 30 Sep 2026 12:41:06 +0100
Subject: [PATCH 3/3] fix(docx): seat a stack's letters where the page raises
them and keep the page still
A stacked line seated off its baseline has its letters' reach read where the page sets them. Lines whose letters meet are still split halfway, so the page does not move; a stack line is seated however small its shift; letters hanging past a container's foot are always taken from the gap below; a line with space above or below it stays out of a stack.
---
CHANGELOG.md | 20 +++---
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 18 +++--
.../semantic/docx/DocxSemanticBackend.java | 18 ++++-
.../semantic/docx/DocxStackedLines.java | 36 +++++-----
.../semantic/docx/DocxBaselineSeatTest.java | 28 ++++++++
.../backend/semantic/docx/DocxInkTest.java | 22 ++++++
.../semantic/docx/DocxStackedLayersTest.java | 67 +++++++++++++++----
8 files changed, 164 insertions(+), 47 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd1c9b37c..d37dc91a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,17 +13,21 @@ follow semantic versioning; release dates are ISO 8601.
whatever the face — measured for Spectral, Lato and Arial, in lines 12 to 100pt tall, and
LibreOffice does the same — where the page sets it the face's ascent below the line's top. For a
face with a deep descent the two part: `NorthlineProposal`'s 46pt Spectral title stood 7pt low.
- Its three lines, laid 48pt apart in a 140pt container, were also written 70, 48 and 48pt tall,
- and what the 26pt they ran past the container left after the gap under it pushed the rest of the
+ Its three lines, laid 48pt apart in a 140pt container, were also written 70, 48 and 48pt tall.
+ They ran 26pt past the container; the gap under it took 18pt of that, and the rest pushed the
cover 8pt below the section icons drawn where the page puts them. A paragraph's runs are now
moved to the page's baseline (`w:position`) wherever the two stand half a point or more apart,
from the line Word was given — a line pair's from the higher text's top, lines that took their
gaps from the space above from that higher top, several lines at the middle one; a list item's
- and a composed table cell's are not moved yet. Lines a container stacks tighter than their face
- are each written to end halfway between their own letters and the next line's, read from the
- glyphs' outlines, and in a shape container the last to the container's foot or below its
- letters: Word draws an exact line's text on screen only inside the line, and lines a pitch tall
- cut the title's descenders and capitals there. In Word `NorthlineProposal`'s median drift falls
+ and a table text cell's are not moved yet. Lines a container stacks tighter than their face are
+ each written to end halfway between their own letters and the next line's, read from the glyphs'
+ outlines: Word draws an exact line's text on screen only inside the line, and lines a pitch tall
+ cut the title's descenders and capitals there. Where the last layer of a shape container runs
+ past its foot, it ends at the foot, or below its letters where they hang past it. Where the page
+ sets two lines' letters into each other the edge still falls halfway, so the page does not move;
+ a stack whose letters cannot be read, or that holds a picture, keeps each line's own height, and
+ a line with space above or below it stays out of a stack. In Word `NorthlineProposal`'s median
+ drift falls
from 8.0pt to 0.8, its cover within 1.5pt of the page throughout, and `EditorialProposal`'s from
5.9 to 0.4; across the 62 templates, lines more than 2pt off fall from 1464 to 1341.
- **A value set from its start keeps its start in Word.** Two texts one layer holds on one line —
@@ -138,7 +142,7 @@ follow semantic versioning; release dates are ISO 8601.
48pt apart — took each line's own height, 70pt; now each single line of text laid over by
the next across ends halfway between its letters and the next line's (`DocxStackedLines`),
and a shape container's last line running past the container's foot takes its overhang from
- the gap below. Lines whose letters meet, and a line holding a picture, keep their own height.
+ the gap below. A stack that holds a picture keeps its lines' own heights.
An icon picture beside a layer of text in a shape container neither painted,
clipped to its outline nor transformed, or in a layer stack — `NorthlineProposal`'s glance
card facts, the contact lines of `ConsultingInvoice` — was written as a line of its own
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 01ab3ba12..68afd5ec5 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -62,7 +62,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a composed table cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and in a shape container on one page the last at the container's foot or below its letters |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 5a5d63bde..2a5d0a147 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -173,7 +173,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. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a composed table cell's are not yet. 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 in a shape container the last at the container's 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. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height; in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. 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. 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 |
@@ -558,12 +558,16 @@ tint it was flattened to. Recorded, like the other two.
face's line — keep the page's pitch: Word draws an exact line's text on
screen only inside the line, so each line laid over by the next across
ends halfway between its letters and the next line's, read from the
- glyphs' outlines, and its text is seated in it by `w:position`. In a
- shape container on one page the last ends at the container's foot, or
- below its letters where they hang past it, the overhang taken from the
- gap below. Lines whose letters meet, and a line holding a picture, keep
- their own height. Any other last line running past a shape container's
- foot takes its overhang from the gap below. An outline no
+ glyphs' outlines, and its text is seated in it by `w:position`. Where
+ the last layer of a shape container on one page runs past the
+ container's foot, it ends at the foot, or below its letters where they
+ hang past it, the overhang taken from the gap below. Where the page
+ sets two lines' letters into each other the edge still falls halfway,
+ so the page does not move; a stack whose letters cannot be read, or
+ that holds a picture, keeps its lines' own heights, and a line with
+ space above or below it stays out of a stack. Any other last line
+ running past a shape container's foot takes its overhang from the gap
+ below. An outline no
shape shows is reported as dropped.
- **`hangingIndent(true)` → the ordinary list form.** A list that opts
into marker/content geometry exports exactly as one that did not: the
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 71161136c..28d7a2782 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
@@ -4221,10 +4221,19 @@ private void holdStackedLines(List layers, double foot) {
stackedLineHeights.putAll(DocxStackedLines.of(layers, foot, layout, this::inkOf));
}
- /** How far a paragraph's first line's letters reach above and below its baseline (see {@link DocxInk}). */
+ /**
+ * How far a paragraph's first line's letters reach above and below its baseline (see
+ * {@link DocxInk}), where the page seats them: text raised to its line's top reaches as much
+ * further up and less far down.
+ */
private double[] inkOf(ParagraphNode paragraph) {
java.util.Optional line = layout.firstLine(paragraph);
- return line.isEmpty() ? null : DocxInk.of(line.get(), measuredFonts());
+ double[] reach = line.isEmpty() ? null : DocxInk.of(line.get(), measuredFonts());
+ if (reach == null) {
+ return null;
+ }
+ double seated = seatShift(paragraph);
+ return new double[]{reach[0] + seated, reach[1] - seated};
}
/** The fonts the layout measured with, loaded when first asked for. */
@@ -4825,7 +4834,10 @@ private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, d
double pages = lineTopAbove + lineTopsTakenIn.getOrDefault(para.getCTP(), 0.0)
+ middle * pageStep + line.lineHeight() - line.baselineOffsetFromBottom();
double shift = middle * wordLine + wordLine * DocxTextBands.BASELINE_SHARE - pages;
- return Math.abs(shift) < LEAST_BASELINE_SHIFT_POINTS ? 0 : shift;
+ // A line starting elsewhere than the page's — a stack's, a line pair's — was cut
+ // to fit its letters where the page sets them, and is seated however little.
+ boolean cutToFit = lineTopAbove != 0 || stackedLineHeights.containsKey(node);
+ return Math.abs(shift) < LEAST_BASELINE_SHIFT_POINTS && !cutToFit ? 0 : shift;
}
}
return 0;
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
index 472822861..21dee9e4e 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLines.java
@@ -25,9 +25,10 @@
* only inside the line, and a 46pt line's letters, seated where the page sets them, reach past a
* 48pt step — "Proposal" lost its descenders and "Brand Refresh" the tops of its capitals. So
* each line of a stack ends halfway between its own letters and the next line's, where the page
- * leaves room between them, and the stack ends at the container's foot or, where its last
- * letters hang past it, below them. Every line holds its letters whole; the lines together are
- * as tall as the page's, and the text is seated in each where the page sets it (see
+ * leaves room between them. A stack ending a shape container whose foot its own last line runs
+ * past ends at that foot or, where its last letters hang past it, below them; any other ends
+ * where its last line does. Every line holds its letters whole, and the text is seated in each
+ * where the page sets it (see
* {@code DocxSemanticBackend#shiftToThePagesBaseline}).
*/
final class DocxStackedLines {
@@ -59,10 +60,10 @@ record Line(double height, double topAbove, double hang) {
*
* A stack is a run of layers, each a paragraph of one laid-out line on one page, whose
* box the next one's starts above the foot of and overlaps across — a value set beside its
- * label a few points lower is a line of its own. A stack is written only where the letters
- * of each line stay clear of the next one's, and each line's letters can be read: a line
- * holding a picture, whose height is not its letters', keeps its own height, as does every
- * line of its stack.
+ * label a few points lower is a line of its own — and has no space written above or below
+ * it. A stack is written only where each line's letters can be read: a line holding a
+ * picture, whose height is not its letters', keeps its own height, as does every line of its
+ * stack.
*
* @param layers a container's children, in the order they are written
* @param foot the foot of the container's content, measured up from the foot of the page,
@@ -110,9 +111,15 @@ private static boolean laidOver(ParagraphNode above, ParagraphNode below, DocxLa
return overlaps && under;
}
+ /**
+ * Whether a paragraph is one line on one page with no space above or below it: a stack's
+ * lines are written back to back, and space written between two would move every line after.
+ */
private static boolean oneLine(ParagraphNode paragraph, DocxLayoutMetrics layout) {
PlacedNode box = layout.placement(paragraph);
- return layout.lineCount(paragraph) == 1 && box != null && box.startPage() == box.endPage();
+ return layout.lineCount(paragraph) == 1 && box != null && box.startPage() == box.endPage()
+ && paragraph.margin().top() == 0 && paragraph.margin().bottom() == 0
+ && paragraph.padding().top() == 0 && paragraph.padding().bottom() == 0;
}
/**
@@ -148,15 +155,11 @@ private static void write(List stack, boolean last, double foot,
inkFoot[k] = baseline + reach[1];
ownFoot = top[k] + line.get().lineHeight();
}
- if (inkTop[0] < 0) {
- return;
- }
double[] edge = new double[n + 1];
for (int k = 1; k < n; k++) {
- // Letters the page sets into the next line's cannot both stand whole in two lines.
- if (inkTop[k] < inkFoot[k - 1]) {
- return;
- }
+ // Halfway, even where the page sets one line's letters into the next one's: both lose
+ // what they share, and every line stays where the page stacks it — written at their
+ // own heights instead, the lines would push the page down by far more.
edge[k] = (inkFoot[k - 1] + inkTop[k]) / 2;
}
double end = Math.max(ownFoot, inkFoot[n - 1] + INK_MARGIN);
@@ -165,8 +168,9 @@ private static void write(List stack, boolean last, double foot,
double containerFoot = first - foot;
if (ownFoot > containerFoot) {
end = Math.max(containerFoot, inkFoot[n - 1] + INK_MARGIN);
- hang = Math.max(0, end - containerFoot);
}
+ // Letters hanging past the foot are the overhang, whether the line's own box does or not.
+ hang = Math.max(0, end - containerFoot);
}
edge[n] = end;
for (int k = 0; k < n; k++) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
index 7805088f0..4c813abd3 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
@@ -55,6 +55,34 @@ void aFaceWhoseBaselineWordAlreadyNearlyMatchesIsLeftAlone() throws Exception {
}
}
+ @Test
+ void aPictureOnTheBaselineMovesWithItsText() throws Exception {
+ // An 8pt picture standing on the baseline of a Spectral line: Word stands both on its own
+ // baseline, so the picture takes the text's move and nothing else.
+ byte[] png = png();
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addParagraph(p -> p.inlineText("Title ", spectral(30))
+ .inlineImage(com.demcha.compose.document.image.DocumentImageData.fromBytes(png), 8, 8,
+ com.demcha.compose.document.node.InlineImageAlignment.BASELINE, 0, null)))) {
+ XWPFParagraph title = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().startsWith("Title")).findFirst().orElseThrow();
+ int text = positionOf(title.getRuns().get(0).getCTR());
+
+ assertThat(text).as("the premise: the line moved").isPositive();
+ assertThat(positionOf(title.getRuns().get(1).getCTR())).isEqualTo(text);
+ }
+ }
+
+ 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 theHalfOfALinePairSetLowerIsSeatedFromTheLinesTop() throws Exception {
// A 12pt label centred beside a 30pt value: the line is the value's, and the label's own
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java
index 7d00ac964..ab358fe77 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java
@@ -50,6 +50,28 @@ void aLetterReachesLessFarThanTheFacesAscentAndDescent() throws Exception {
.isLessThan(line.lineHeight() - 5);
}
+ @Test
+ void aLineHoldingAPictureHasNoLettersToRead() throws Exception {
+ // A picture's height is not a glyph's: the line's reach is not known.
+ java.io.ByteArrayOutputStream png = new java.io.ByteArrayOutputStream();
+ javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(8, 8,
+ java.awt.image.BufferedImage.TYPE_INT_RGB), "png", png);
+ ParagraphLine line;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 400)
+ .margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.inlineText("Icon ", DocumentTextStyle.builder()
+ .fontName(FontName.SPECTRAL).size(SIZE).build())
+ .inlineImage(com.demcha.compose.document.image.DocumentImageData.fromBytes(png.toByteArray()),
+ 8, 8)));
+ line = session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload)
+ .map(fragment -> ((ParagraphFragmentPayload) fragment.payload()).lines().get(0))
+ .findFirst().orElseThrow();
+ }
+
+ assertThat(DocxInk.of(line, fonts())).isNull();
+ }
+
private static com.demcha.compose.font.FontLibrary fonts() {
return PdfFontLibraryFactory.measurementLibrary(List.of());
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
index 06ff2840a..3e0e30eeb 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackedLayersTest.java
@@ -107,17 +107,44 @@ void theLettersOfAStacksLastLineHangingPastTheFootComeOutOfTheGapUnderIt() throw
double stack = line(paragraph(document, "One")) + line(paragraph(document, "Two"))
+ line(paragraph(document, "Three"));
double hang = stack - (2 * PITCH + 26);
+ Stacked last = stacked(title(2 * PITCH + 26), "One", "Two", "Three").get(2);
- assertThat(hang).as("the stack runs past the box").isGreaterThan(2);
+ assertThat(hang).as("just below its letters, not where its own line ends")
+ .isCloseTo(last.pageBaseline() + last.inkBelow() + DocxStackedLines.INK_MARGIN - (2 * PITCH + 26),
+ within(0.1));
assertThat(before(paragraph(document, "After"))).as("by as much less gap under it")
.isCloseTo(30 - hang, within(0.1));
}
}
@Test
- void linesWhoseLettersOverlapKeepTheirOwnHeight() throws Exception {
+ void aStackedLineSeatedAtItsTopHoldsItsRaisedLettersWhole() throws Exception {
+ // "Two" is set against the top of its line, 8pt higher than on its baseline: its letters
+ // come that much closer to the line above, and the edge between them moves up with them.
+ DocumentNode title = new ShapeContainerBuilder().name("Seated").rectangle(300, 2 * PITCH + 40)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("One").text("One").textStyle(LARGE).build(),
+ 0, 0, LayerAlign.TOP_LEFT)
+ .position(new ParagraphBuilder().name("Two").text("Two").textStyle(LARGE)
+ .verticalAlign(com.demcha.compose.document.node.TextVerticalAlign.TOP).build(),
+ 0, PITCH, LayerAlign.TOP_LEFT)
+ .position(new ParagraphBuilder().name("Three").text("Three").textStyle(LARGE).build(),
+ 0, 2 * PITCH, LayerAlign.TOP_LEFT)
+ .build();
+ for (Stacked line : stacked(title, "One", "Two", "Three")) {
+ assertThat(line.wordBaseline()).as("%s where the page seats it", line.text())
+ .isCloseTo(line.pageBaseline(), within(0.3));
+ assertThat(line.wordBaseline() - line.inkAbove()).as("%s: tops inside", line.text())
+ .isGreaterThanOrEqualTo(line.top());
+ assertThat(line.wordBaseline() + line.inkBelow()).as("%s: feet inside", line.text())
+ .isLessThanOrEqualTo(line.top() + line.height());
+ }
+ }
+
+ @Test
+ void linesWhoseLettersOverlapAreStillSplitHalfwaySoThePageDoesNotMove() throws Exception {
// 18pt apart in a 30pt face, the letters of one line reach into the next's: no edge
- // between them leaves both whole.
+ // leaves both whole, and each line at its own 36pt would push the page 18pt down.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.add(new ShapeContainerBuilder().name("Tight").rectangle(300, 60)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
@@ -126,7 +153,12 @@ void linesWhoseLettersOverlapKeepTheirOwnHeight() throws Exception {
.position(new ParagraphBuilder().name("Two").text("Two").textStyle(LARGE).build(),
0, 18, LayerAlign.TOP_LEFT)
.build()))) {
- assertThat(line(paragraph(document, "Two"))).isEqualTo(line(paragraph(document, "One")));
+ double one = line(paragraph(document, "One"));
+ double two = line(paragraph(document, "Two"));
+
+ assertThat(one).as("cut halfway into the letters both lines share, short of its own 36pt")
+ .isLessThan(LARGE_LINE - 5);
+ assertThat(one + two).as("ending where the second line's own box does").isCloseTo(18 + LARGE_LINE, within(0.1));
}
}
@@ -183,9 +215,9 @@ void aLineBesideTheOneAboveKeepsItsOwnHeight() throws Exception {
@Test
void theLastLinesOverhangBelowItsBoxComesOutOfTheGapUnderIt() throws Exception {
- // Two lines too tight to stack: the second keeps its own line, its foot 54pt down. A
- // box 4pt shorter leaves it hanging 4pt further below, and the paragraph after it that
- // much less space above.
+ // One line, no stack, set 18pt down: its own line's foot is 54pt down. A box 4pt shorter
+ // leaves it hanging 4pt further below, and the paragraph after it that much less space
+ // above.
double shorter = spaceAboveTheParagraphAfter(46);
double taller = spaceAboveTheParagraphAfter(50);
@@ -215,10 +247,8 @@ void anIconOverItsLabelIsWrittenAsBefore() throws Exception {
private static double spaceAboveTheParagraphAfter(double boxHeight) throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addSection("Cover", cover -> cover.spacing(30)
- .add(new ShapeContainerBuilder().name("Tight").rectangle(300, boxHeight)
+ .add(new ShapeContainerBuilder().name("Low").rectangle(300, boxHeight)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
- .position(new ParagraphBuilder().name("One").text("One").textStyle(LARGE).build(),
- 0, 0, LayerAlign.TOP_LEFT)
.position(new ParagraphBuilder().name("Two").text("Two").textStyle(LARGE).build(),
0, 18, LayerAlign.TOP_LEFT)
.build())
@@ -270,8 +300,13 @@ private static List stacked(DocumentNode container, String... texts) th
if (Double.isNaN(firstTop)) {
firstTop = pageTop;
}
- double pageBaseline = firstTop - pageTop + line.lineHeight() - line.baselineOffsetFromBottom();
- double[] ink = DocxInk.of(line, PdfFontLibraryFactory.measurementLibrary(List.of()));
+ com.demcha.compose.font.FontLibrary fonts = PdfFontLibraryFactory.measurementLibrary(List.of());
+ // Measured down from the first line's top; text the page seats up stands higher.
+ double seat = payload.verticalAlign() == com.demcha.compose.document.node.TextVerticalAlign.DEFAULT
+ ? 0 : com.demcha.compose.document.backend.fixed.pdf.handlers.ParagraphSeating
+ .shift(line, fonts, payload.verticalAlign());
+ double pageBaseline = firstTop - pageTop + line.lineHeight() - line.baselineOffsetFromBottom() - seat;
+ double[] ink = DocxInk.of(line, fonts);
XWPFParagraph written = document.getParagraphs().stream()
.filter(paragraph -> paragraph.getText().startsWith(text.split(" ")[0]))
.findFirst().orElseThrow();
@@ -282,6 +317,14 @@ private static List stacked(DocumentNode container, String... texts) th
ink[0], ink[1]));
wordTop += height;
}
+ // Each edge between two lines stands halfway between the one's letters and the next's.
+ for (int k = 0; k + 1 < lines.size(); k++) {
+ Stacked above = lines.get(k);
+ Stacked below = lines.get(k + 1);
+ assertThat(below.top()).as("the edge under %s", above.text()).isCloseTo(
+ (above.pageBaseline() + above.inkBelow() + below.pageBaseline() - below.inkAbove()) / 2,
+ within(0.05));
+ }
return lines;
}
}