diff --git a/CHANGELOG.md b/CHANGELOG.md index c645c3a5b..d37dc91a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ 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. + 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 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 — 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,12 +139,11 @@ 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 - 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, + 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. 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 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 8a0170f81..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 | +| 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 13b612195..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 | +| 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 | @@ -555,11 +555,19 @@ 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: 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`. 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/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/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{@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 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) { ListThe 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; } @@ -4689,12 +4782,62 @@ 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; + } + + /** + * 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; + // 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 69affea3e..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 @@ -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,84 +19,162 @@ * 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. + * + *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. 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 { /** - * 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() { } /** - * The line each paragraph laid over the layer before it is written at. + * 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 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 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 — 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, + * 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 MapWord 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 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 + // 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/DocxInkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java new file mode 100644 index 000000000..ab358fe77 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInkTest.java @@ -0,0 +1,97 @@ +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); + } + + @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()); + } + + 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 7cfd7fc45..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 @@ -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; @@ -32,21 +42,109 @@ 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 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()); + } + } + + @Test + void theLastLineOfAStackEndsAtTheContainersFootAndHangsPastNothing() 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, "Two"))).isCloseTo(PITCH, within(0.05)); - assertThat(line(paragraph(document, "Three"))).isCloseTo(PITCH, within(0.05)); + .addSection("Cover", cover -> cover.spacing(30) + .add(title(2 * PITCH + 34)) + .addParagraph("After")))) { + 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 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); + Stacked last = stacked(title(2 * PITCH + 26), "One", "Two", "Three").get(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 aLineLaidTooTightForItsFaceKeepsItsOwnHeight() throws Exception { - // 18pt apart is under two thirds of a 30pt face: Word would cut its letters off. + 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 + // 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) @@ -55,15 +153,21 @@ void aLineLaidTooTightForItsFaceKeepsItsOwnHeight() 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)); } } @Test void aLineHoldingAPictureKeepsItsOwnHeight() throws Exception { - // A picture is not its line's face: squeezed, 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, 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 +178,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 +215,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); + // 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); assertThat(taller - shorter).isCloseTo(4, within(0.1)); } @@ -142,15 +247,95 @@ 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("Low").rectangle(300, boxHeight) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .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; + } + + /** + * 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