From 3df6086ce6283f3e2f27c4eb18b52badc32e63d5 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 3 Oct 2026 00:07:12 +0100 Subject: [PATCH 1/2] fix(docx): take a drawn shape's picture frame from the space around its line --- CHANGELOG.md | 8 ++ docs/recipes/docx-export.md | 2 +- .../libreoffice-windows-lines.tsv | 40 +++++----- .../docx-fidelity/libreoffice-windows.tsv | 2 +- .../docx-fidelity/word-windows-lines.tsv | 40 +++++----- .../resources/docx-fidelity/word-windows.tsv | 2 +- .../semantic/docx/DocxSemanticBackend.java | 58 +++++++++++++- .../docx/DocxPictureLineEdgeTest.java | 75 +++++++++++++++++++ 8 files changed, 181 insertions(+), 46 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8669e8b04..5966eac4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **What follows a line holding only a drawn shape stands where the page sets it in DOCX.** + A shape is written as a picture a transparent frame larger than its ink on every side, and + Word makes a line of pictures and no text, grown to them, as tall as the pictures, frame + included. + `MonogramSidebar`'s contact icons stood in lines half a point taller than the page's, and + each contact under them half a point lower than the one above. The frame is now taken from + the space above the line and above what follows. `MonogramSidebar`'s lines more than 2pt off + fall from 17 to none in Word, its median from 0.79 to 0.29pt. - **A panel bled to the paper's edge stands where the page puts it in Word.** A panel's table indent places its text, and an indent of 0 was not written. A panel whose negative margin takes its fill to the paper's edge and whose padding takes its text back to the diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index a4044238e..1d2d60fc7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -176,7 +176,7 @@ it cannot work out for itself: | What | Where it lands | |---|---| -| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it (not yet a list item's, or text set over the flow). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell opening with a table, whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one 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 — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. 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 | +| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it (not yet a list item's, or text set over the flow). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell opening with a table, whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one 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 — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), 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 | diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index 9f3484505..e59943004 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -814,26 +814,26 @@ cv-modern_professional 1:ec9d3622395e -7.77 workeligibility:eligibletoworkin cv-modern_professional 1:dd20ae01c3cf -7.77 opensource:maintainerofgraphcomp cv-modern_professional 1:33c35e9d236d -7.77 speaking:jvmsummit2024,devoxxuk2 cv-monogram_sidebar 0:093e7d5fdbaa 2.70 contact -cv-monogram_sidebar 0:89674e6f130e 3.35 +442055551000 -cv-monogram_sidebar 0:e0f850305ded 3.87 jordan.rivera@example.com -cv-monogram_sidebar 0:fbeea547d99e 4.39 london,uk -cv-monogram_sidebar 0:84308bea4540 4.91 linkedin -cv-monogram_sidebar 0:c0b0109d9439 5.43 github -cv-monogram_sidebar 0:68bf0109c6f8 5.40 education -cv-monogram_sidebar 0:0ad1957d6bab 5.50 msccomputerscience -cv-monogram_sidebar 0:47ce0218bbea 5.53 universityofmanchester -cv-monogram_sidebar 0:3fa772117260 5.54 2019-2021 -cv-monogram_sidebar 0:b6749d0b616b 5.51 bscsoftwareengineering -cv-monogram_sidebar 0:89ad9656f393 5.54 imperialcollegelondon -cv-monogram_sidebar 0:30f9262640a0 5.55 2015-2019 -cv-monogram_sidebar 0:32c1cf49a2fe 5.52 skills -cv-monogram_sidebar 0:d5680c2e2769 5.67 java21 -cv-monogram_sidebar 0:19a67c2a5369 5.69 kotlin -cv-monogram_sidebar 0:95bfacd3a466 5.71 groovy -cv-monogram_sidebar 0:11a4a60b518b 5.73 python -cv-monogram_sidebar 0:4ec7c53222c8 5.75 sql -cv-monogram_sidebar 0:57529f20c3ad 5.77 pdfbox -cv-monogram_sidebar 0:1814aacb6d5a 5.79 apachepoi(docx/xlsx) +cv-monogram_sidebar 0:89674e6f130e 2.85 +442055551000 +cv-monogram_sidebar 0:e0f850305ded 2.87 jordan.rivera@example.com +cv-monogram_sidebar 0:fbeea547d99e 2.89 london,uk +cv-monogram_sidebar 0:84308bea4540 2.91 linkedin +cv-monogram_sidebar 0:c0b0109d9439 2.93 github +cv-monogram_sidebar 0:68bf0109c6f8 2.90 education +cv-monogram_sidebar 0:0ad1957d6bab 3.00 msccomputerscience +cv-monogram_sidebar 0:47ce0218bbea 3.03 universityofmanchester +cv-monogram_sidebar 0:3fa772117260 3.04 2019-2021 +cv-monogram_sidebar 0:b6749d0b616b 3.01 bscsoftwareengineering +cv-monogram_sidebar 0:89ad9656f393 3.04 imperialcollegelondon +cv-monogram_sidebar 0:30f9262640a0 3.05 2015-2019 +cv-monogram_sidebar 0:32c1cf49a2fe 3.02 skills +cv-monogram_sidebar 0:d5680c2e2769 3.17 java21 +cv-monogram_sidebar 0:19a67c2a5369 3.19 kotlin +cv-monogram_sidebar 0:95bfacd3a466 3.21 groovy +cv-monogram_sidebar 0:11a4a60b518b 3.23 python +cv-monogram_sidebar 0:4ec7c53222c8 3.25 sql +cv-monogram_sidebar 0:57529f20c3ad 3.27 pdfbox +cv-monogram_sidebar 0:1814aacb6d5a 3.29 apachepoi(docx/xlsx) cv-monogram_sidebar 0:136c67657614 2.57 jordan cv-monogram_sidebar 0:8ba35ebe3d5c 2.58 rivera cv-monogram_sidebar 0:2d58f0cadc2d 2.76 platformengineer diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index b1cdf790e..8df7d81d1 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -13,7 +13,7 @@ cv-midnight_navy 1 1 86 80 2.72 3.24 76 cv-minimal_underlined 2 2 59 57 0.69 13.40 9 cv-mint_editorial 2 2 59 59 0.27 0.60 0 cv-modern_professional 2 2 61 59 0.12 0.59 5 -cv-monogram_sidebar 1 1 53 50 3.57 5.69 50 +cv-monogram_sidebar 1 1 53 50 2.90 3.57 50 cv-navy_sidebar 1 1 71 71 2.81 3.03 71 cv-nordic_clean 1 1 63 58 0.20 0.57 0 cv-orange_ops 1 2 108 5 77.92 77.92 5 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 29fa5f5b0..84a46b6d5 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -816,26 +816,26 @@ cv-modern_professional 1:ec9d3622395e -7.77 workeligibility:eligibletoworkin cv-modern_professional 1:dd20ae01c3cf -7.74 opensource:maintainerofgraphcomp cv-modern_professional 1:33c35e9d236d -7.69 speaking:jvmsummit2024,devoxxuk2 cv-monogram_sidebar 0:093e7d5fdbaa 0.00 contact -cv-monogram_sidebar 0:89674e6f130e 0.63 +442055551000 -cv-monogram_sidebar 0:e0f850305ded 1.11 jordan.rivera@example.com -cv-monogram_sidebar 0:fbeea547d99e 1.59 london,uk -cv-monogram_sidebar 0:84308bea4540 2.21 linkedin -cv-monogram_sidebar 0:c0b0109d9439 2.69 github -cv-monogram_sidebar 0:68bf0109c6f8 2.73 education -cv-monogram_sidebar 0:0ad1957d6bab 2.81 msccomputerscience -cv-monogram_sidebar 0:47ce0218bbea 2.81 universityofmanchester -cv-monogram_sidebar 0:3fa772117260 2.92 2019-2021 -cv-monogram_sidebar 0:b6749d0b616b 2.81 bscsoftwareengineering -cv-monogram_sidebar 0:89ad9656f393 2.84 imperialcollegelondon -cv-monogram_sidebar 0:30f9262640a0 2.95 2015-2019 -cv-monogram_sidebar 0:32c1cf49a2fe 2.80 skills -cv-monogram_sidebar 0:d5680c2e2769 2.92 java21 -cv-monogram_sidebar 0:19a67c2a5369 2.92 kotlin -cv-monogram_sidebar 0:95bfacd3a466 3.04 groovy -cv-monogram_sidebar 0:11a4a60b518b 3.03 python -cv-monogram_sidebar 0:4ec7c53222c8 3.03 sql -cv-monogram_sidebar 0:57529f20c3ad 3.03 pdfbox -cv-monogram_sidebar 0:1814aacb6d5a 3.06 apachepoi(docx/xlsx) +cv-monogram_sidebar 0:89674e6f130e 0.15 +442055551000 +cv-monogram_sidebar 0:e0f850305ded 0.15 jordan.rivera@example.com +cv-monogram_sidebar 0:fbeea547d99e 0.15 london,uk +cv-monogram_sidebar 0:84308bea4540 0.15 linkedin +cv-monogram_sidebar 0:c0b0109d9439 0.17 github +cv-monogram_sidebar 0:68bf0109c6f8 0.21 education +cv-monogram_sidebar 0:0ad1957d6bab 0.29 msccomputerscience +cv-monogram_sidebar 0:47ce0218bbea 0.29 universityofmanchester +cv-monogram_sidebar 0:3fa772117260 0.40 2019-2021 +cv-monogram_sidebar 0:b6749d0b616b 0.29 bscsoftwareengineering +cv-monogram_sidebar 0:89ad9656f393 0.32 imperialcollegelondon +cv-monogram_sidebar 0:30f9262640a0 0.43 2015-2019 +cv-monogram_sidebar 0:32c1cf49a2fe 0.40 skills +cv-monogram_sidebar 0:d5680c2e2769 0.40 java21 +cv-monogram_sidebar 0:19a67c2a5369 0.52 kotlin +cv-monogram_sidebar 0:95bfacd3a466 0.52 groovy +cv-monogram_sidebar 0:11a4a60b518b 0.51 python +cv-monogram_sidebar 0:4ec7c53222c8 0.51 sql +cv-monogram_sidebar 0:57529f20c3ad 0.51 pdfbox +cv-monogram_sidebar 0:1814aacb6d5a 0.54 apachepoi(docx/xlsx) cv-monogram_sidebar 0:136c67657614 0.36 jordan cv-monogram_sidebar 0:8ba35ebe3d5c 0.43 rivera cv-monogram_sidebar 0:2d58f0cadc2d -0.07 platformengineer diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 5e64d10a3..15a8a8e0f 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -13,7 +13,7 @@ cv-midnight_navy 1 1 86 80 0.15 0.58 0 cv-minimal_underlined 2 2 59 59 0.62 0.89 0 cv-mint_editorial 2 2 59 59 0.13 0.31 0 cv-modern_professional 2 2 61 59 0.20 0.77 5 -cv-monogram_sidebar 1 1 53 50 0.79 2.95 17 +cv-monogram_sidebar 1 1 53 50 0.29 0.79 0 cv-navy_sidebar 1 1 71 69 0.13 0.22 0 cv-nordic_clean 1 1 63 58 0.22 0.61 0 cv-orange_ops 1 1 108 99 0.42 0.71 0 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 d78f41955..2a90955f0 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 @@ -5859,6 +5859,9 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean : pictures.pageLine() == 0 ? styleLineHeight(node.textStyle()) : 0; if (pageLine > 0 && pictures.reach() >= pageLine - PICTURE_FILLS_ITS_LINE) { letThePicturesSetTheLine(para); + if (ownLine) { + takeThePicturesEdgesFromAround(para, pictures); + } } seatInTheLine(para, node, runsBefore, lineTopAbove + heldAbove, cutToTheInk); } @@ -5901,6 +5904,47 @@ private static void letThePicturesSetTheLine(XWPFParagraph para) { } } + /** + * Keeps a line of pictures and no text, grown to its pictures, as tall as the page's. + * + *

Word makes such a line, written at least the pictures' reach, as tall as the pictures + * themselves — a drawn shape's transparent frame ({@link DocxShapePictures#EDGE}) above and + * below its ink included. {@code MonogramSidebar}'s contact icons, a 22pt glyph each over + * its line of text, stood in 22.5pt lines, and every contact under them half a point lower + * than the one above. What the pictures' edges reach past the page's line is taken from the + * space written above the line and from the space above what follows ({@link #hangingBelow}), + * as a line held to its pictures takes its ink's ({@link #holdPicturesInTheLine}); the ink + * then stands where the page draws it, and what follows where the page sets it.

+ * + *

Only a drawn shape's frame, and only where the pictures' edges pass the line written: + * where they stay within it Word keeps that height, and taking the room would lift what + * follows. A line of its own only: half of a line pair shares its line with the other half.

+ */ + private void takeThePicturesEdgesFromAround(XWPFParagraph para, PictureReach pictures) { + CTPPr properties = para.getCTP().getPPr(); + if (!holdsOnlyPictures(para) || properties == null || !properties.isSetSpacing() + || !properties.getSpacing().isSetLineRule() + || properties.getSpacing().getLineRule() != STLineSpacingRule.AT_LEAST + || !Double.isFinite(pictures.boxAbove()) || !Double.isFinite(pictures.boxBelow()) + || !(pictures.boxAbove() > pictures.above() + 1e-9 || pictures.boxBelow() > pictures.below() + 1e-9)) { + return; + } + Long written = writtenTwips(properties.getSpacing().getLine()); + double box = pictures.pageLine() + Math.max(0, pictures.boxAbove()) + Math.max(0, pictures.boxBelow()); + if (written == null || !(box * POINT_TO_TWIP > written + 0.5)) { + return; + } + CTSpacing spacing = properties.getSpacing(); + long before = spacing.isSetBefore() ? twipsOf(spacing.getBefore()) : 0; + long up = Math.min(before, Math.round(Math.max(0, pictures.boxAbove()) * POINT_TO_TWIP)); + if (up > 0) { + spacing.setBefore(BigInteger.valueOf(before - up)); + } + if (pictures.boxBelow() > 0) { + hangingBelow = Math.max(hangingBelow, pictures.boxBelow()); + } + } + /** * Whether a paragraph's runs, a link's included, hold pictures and nothing else: no * letter, break, tab, symbol or field. An internal link's runs are not among the @@ -6371,7 +6415,12 @@ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, Docum * @param below how far the lowest picture's ink reaches below it, in points; negative when * it stays that far inside */ - record PictureReach(double reach, boolean overText, double pageLine, double above, double below) { + record PictureReach(double reach, boolean overText, double pageLine, double above, double below, + double boxAbove, double boxBelow) { + + PictureReach(double reach, boolean overText, double pageLine, double above, double below) { + this(reach, overText, pageLine, above, below, above, below); + } /** No picture written. */ static final PictureReach NONE = new PictureReach(0, false, Double.NaN, @@ -6407,7 +6456,9 @@ static PictureReach of(double bottomFromBaseline, double height, boolean passes = top > line.textAscent() || -(bottomFromBaseline + inset) > descent; double above = descent + wordTop - line.lineHeight(); double below = -(bottomFromBaseline + inset) - descent; - return new PictureReach(descent + wordTop, passes, line.lineHeight(), above, below); + // The picture's own edges, frame and all: what a line Word sizes to it spans. + return new PictureReach(descent + wordTop, passes, line.lineHeight(), above, below, + above + inset, below + inset); } PictureReach max(PictureReach other) { @@ -6418,7 +6469,8 @@ PictureReach max(PictureReach other) { : Double.isNaN(other.pageLine) ? pageLine : pageLine > 0 && other.pageLine > 0 ? Math.max(pageLine, other.pageLine) : 0; return new PictureReach(Math.max(reach, other.reach), overText || other.overText, line, - Math.max(above, other.above), Math.max(below, other.below)); + Math.max(above, other.above), Math.max(below, other.below), + Math.max(boxAbove, other.boxAbove), Math.max(boxBelow, other.boxBelow)); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java new file mode 100644 index 000000000..05dc8fb89 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java @@ -0,0 +1,75 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +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.CTSpacing; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A line of a drawn shape and no text keeps the page's height in Word, its picture's frame + * taken from the space around it. + * + *

A shape is written as a picture larger than its ink by a transparent frame on every side + * ({@link DocxShapePictures#EDGE}). Word makes a line of pictures and no text, written at least + * their reach, as tall as the pictures themselves, frame included: {@code MonogramSidebar}'s + * contact icons stood in lines half a point taller than the page's, and each contact half a + * point lower than the one above.

+ * + * @author Artem Demchyshyn + */ +class DocxPictureLineEdgeTest { + + private static final long EDGE_TWIPS = Math.round(DocxShapePictures.EDGE * 20); + + @Test + void theFrameAboveComesOffTheSpaceAboveAndTheFrameBelowOffTheSpaceUnder() throws Exception { + DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.LATO).size(6).build(); + try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page + .spacing(0) + .addParagraph("Above") + .addParagraph(p -> p.textStyle(small).align(TextAlign.CENTER) + .margin(DocumentInsets.top(4)) + .dot(22, DocumentColor.rgb(176, 141, 87))) + .addParagraph(p -> p.text("Under").textStyle(small).align(TextAlign.CENTER) + .margin(DocumentInsets.top(8))))) { + XWPFParagraph icon = document.getParagraphs().get(1); + XWPFParagraph under = document.getParagraphs().get(2); + assertThat(icon.getText()).isEmpty(); + assertThat(spacing(icon).getLineRule()).as("grown to the picture").isEqualTo(STLineSpacingRule.AT_LEAST); + + assertThat(before(icon)).as("its 4pt, less the frame above the ink").isEqualTo(4 * 20 - EDGE_TWIPS); + assertThat(before(under)).as("its 8pt, less the frame below the ink").isEqualTo(8 * 20 - EDGE_TWIPS); + } + } + + @Test + void aShapeAmongTextTakesNothing() throws Exception { + // Its line is the text's, exact, and Word does not grow it to the picture. + try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page + .spacing(0) + .addParagraph("Above") + .addParagraph(p -> p.margin(DocumentInsets.top(4)) + .dot(4, DocumentColor.rgb(176, 141, 87)).inlineText(" Contact", null)))) { + XWPFParagraph line = document.getParagraphs().get(1); + + assertThat(before(line)).isEqualTo(4 * 20L); + } + } + + private static CTSpacing spacing(XWPFParagraph paragraph) { + return paragraph.getCTP().getPPr().getSpacing(); + } + + private static long before(XWPFParagraph paragraph) { + CTSpacing spacing = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getSpacing(); + return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore()); + } +} From da23bd8fb879f792c437b464c9de856bbe9a6753 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 3 Oct 2026 07:30:33 +0100 Subject: [PATCH 2/2] fix(docx): take only what a picture line grows by, and take the Linux LibreOffice baseline from CI --- .../docx-fidelity/libreoffice-linux-lines.tsv | 40 +++++++++---------- .../docx-fidelity/libreoffice-linux.tsv | 2 +- .../semantic/docx/DocxSemanticBackend.java | 19 ++++++--- .../docx/DocxPictureLineEdgeTest.java | 30 +++++++++++--- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index df2e7a762..d0fa2ddb3 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -815,26 +815,26 @@ cv-modern_professional 1:ec9d3622395e -7.77 workeligibility:eligibletoworkin cv-modern_professional 1:dd20ae01c3cf -7.77 opensource:maintainerofgraphcomp cv-modern_professional 1:33c35e9d236d -7.77 speaking:jvmsummit2024,devoxxuk2 cv-monogram_sidebar 0:093e7d5fdbaa 2.70 contact -cv-monogram_sidebar 0:89674e6f130e 3.35 +442055551000 -cv-monogram_sidebar 0:e0f850305ded 3.87 jordan.rivera@example.com -cv-monogram_sidebar 0:fbeea547d99e 4.39 london,uk -cv-monogram_sidebar 0:84308bea4540 4.91 linkedin -cv-monogram_sidebar 0:c0b0109d9439 5.43 github -cv-monogram_sidebar 0:68bf0109c6f8 5.40 education -cv-monogram_sidebar 0:0ad1957d6bab 5.50 msccomputerscience -cv-monogram_sidebar 0:47ce0218bbea 5.53 universityofmanchester -cv-monogram_sidebar 0:3fa772117260 5.54 2019-2021 -cv-monogram_sidebar 0:b6749d0b616b 5.51 bscsoftwareengineering -cv-monogram_sidebar 0:89ad9656f393 5.54 imperialcollegelondon -cv-monogram_sidebar 0:30f9262640a0 5.55 2015-2019 -cv-monogram_sidebar 0:32c1cf49a2fe 5.52 skills -cv-monogram_sidebar 0:d5680c2e2769 5.67 java21 -cv-monogram_sidebar 0:19a67c2a5369 5.69 kotlin -cv-monogram_sidebar 0:95bfacd3a466 5.71 groovy -cv-monogram_sidebar 0:11a4a60b518b 5.73 python -cv-monogram_sidebar 0:4ec7c53222c8 5.75 sql -cv-monogram_sidebar 0:57529f20c3ad 5.77 pdfbox -cv-monogram_sidebar 0:1814aacb6d5a 5.79 apachepoi(docx/xlsx) +cv-monogram_sidebar 0:89674e6f130e 2.85 +442055551000 +cv-monogram_sidebar 0:e0f850305ded 2.87 jordan.rivera@example.com +cv-monogram_sidebar 0:fbeea547d99e 2.89 london,uk +cv-monogram_sidebar 0:84308bea4540 2.91 linkedin +cv-monogram_sidebar 0:c0b0109d9439 2.93 github +cv-monogram_sidebar 0:68bf0109c6f8 2.90 education +cv-monogram_sidebar 0:0ad1957d6bab 3.00 msccomputerscience +cv-monogram_sidebar 0:47ce0218bbea 3.03 universityofmanchester +cv-monogram_sidebar 0:3fa772117260 3.04 2019-2021 +cv-monogram_sidebar 0:b6749d0b616b 3.01 bscsoftwareengineering +cv-monogram_sidebar 0:89ad9656f393 3.04 imperialcollegelondon +cv-monogram_sidebar 0:30f9262640a0 3.05 2015-2019 +cv-monogram_sidebar 0:32c1cf49a2fe 3.02 skills +cv-monogram_sidebar 0:d5680c2e2769 3.17 java21 +cv-monogram_sidebar 0:19a67c2a5369 3.19 kotlin +cv-monogram_sidebar 0:95bfacd3a466 3.21 groovy +cv-monogram_sidebar 0:11a4a60b518b 3.23 python +cv-monogram_sidebar 0:4ec7c53222c8 3.25 sql +cv-monogram_sidebar 0:57529f20c3ad 3.27 pdfbox +cv-monogram_sidebar 0:1814aacb6d5a 3.29 apachepoi(docx/xlsx) cv-monogram_sidebar 0:136c67657614 2.57 jordan cv-monogram_sidebar 0:8ba35ebe3d5c 2.58 rivera cv-monogram_sidebar 0:2d58f0cadc2d 2.76 platformengineer diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 8f6760d65..50b3722e6 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -13,7 +13,7 @@ cv-midnight_navy 1 1 86 80 2.72 3.24 76 cv-minimal_underlined 2 2 59 57 0.69 13.40 9 cv-mint_editorial 2 2 59 59 0.27 0.60 0 cv-modern_professional 2 2 61 59 0.12 0.59 5 -cv-monogram_sidebar 1 1 53 50 3.57 5.69 50 +cv-monogram_sidebar 1 1 53 50 2.90 3.57 50 cv-navy_sidebar 1 1 71 71 2.81 3.03 71 cv-nordic_clean 1 1 63 58 0.22 0.52 0 cv-orange_ops 1 1 108 99 0.32 0.53 0 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 2a90955f0..35dd2d0a8 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 @@ -5930,18 +5930,23 @@ private void takeThePicturesEdgesFromAround(XWPFParagraph para, PictureReach pic return; } Long written = writtenTwips(properties.getSpacing().getLine()); - double box = pictures.pageLine() + Math.max(0, pictures.boxAbove()) + Math.max(0, pictures.boxBelow()); - if (written == null || !(box * POINT_TO_TWIP > written + 0.5)) { + // The pictures' box, edge to edge, and how much taller than the line written Word makes + // the line for it: that much is taken, from above as far as the box passes the line's + // top, the rest from below. + double box = pictures.pageLine() + pictures.boxAbove() + pictures.boxBelow(); + double grown = written == null ? 0 : box - written / POINT_TO_TWIP; + if (!(grown * POINT_TO_TWIP > 0.5)) { return; } CTSpacing spacing = properties.getSpacing(); long before = spacing.isSetBefore() ? twipsOf(spacing.getBefore()) : 0; - long up = Math.min(before, Math.round(Math.max(0, pictures.boxAbove()) * POINT_TO_TWIP)); + long up = Math.min(before, Math.round(Math.min(grown, Math.max(0, pictures.boxAbove())) * POINT_TO_TWIP)); if (up > 0) { spacing.setBefore(BigInteger.valueOf(before - up)); } - if (pictures.boxBelow() > 0) { - hangingBelow = Math.max(hangingBelow, pictures.boxBelow()); + double down = grown - up / POINT_TO_TWIP; + if (down > 0) { + hangingBelow = Math.max(hangingBelow, down); } } @@ -6414,6 +6419,10 @@ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, Docum * puts it, in points; negative when it stays that far inside * @param below how far the lowest picture's ink reaches below it, in points; negative when * it stays that far inside + * @param boxAbove how far the highest picture's own edge reaches above the page's line, a + * drawn shape's transparent frame included; {@code above} for a picture with + * no frame + * @param boxBelow how far the lowest picture's own edge reaches below it, likewise */ record PictureReach(double reach, boolean overText, double pageLine, double above, double below, double boxAbove, double boxBelow) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java index 05dc8fb89..d690dbd3e 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPictureLineEdgeTest.java @@ -51,19 +51,37 @@ void theFrameAboveComesOffTheSpaceAboveAndTheFrameBelowOffTheSpaceUnder() throws } @Test - void aShapeAmongTextTakesNothing() throws Exception { - // Its line is the text's, exact, and Word does not grow it to the picture. + void aPictureWithNoFrameTakesNothing() throws Exception { + // An author's picture alone in its line: its edges are its ink, which stands where the + // page draws it, and nothing is taken from around it. + DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.LATO).size(6).build(); + com.demcha.compose.document.image.DocumentImageData picture = + com.demcha.compose.document.image.DocumentImageData.fromBytes(png()); try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page .spacing(0) .addParagraph("Above") - .addParagraph(p -> p.margin(DocumentInsets.top(4)) - .dot(4, DocumentColor.rgb(176, 141, 87)).inlineText(" Contact", null)))) { - XWPFParagraph line = document.getParagraphs().get(1); + .addParagraph(p -> p.textStyle(small).align(TextAlign.CENTER) + .margin(DocumentInsets.top(4)) + .inlineImage(picture, 22, 22)) + .addParagraph(p -> p.text("Under").textStyle(small).align(TextAlign.CENTER) + .margin(DocumentInsets.top(8))))) { + XWPFParagraph icon = document.getParagraphs().get(1); + XWPFParagraph under = document.getParagraphs().get(2); + assertThat(icon.getText()).isEmpty(); - assertThat(before(line)).isEqualTo(4 * 20L); + assertThat(before(icon)).isEqualTo(4 * 20L); + assertThat(before(under)).isEqualTo(8 * 20L); } } + /** A small opaque picture. */ + private static byte[] png() throws Exception { + java.awt.image.BufferedImage image = new java.awt.image.BufferedImage(8, 8, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + javax.imageio.ImageIO.write(image, "png", bytes); + return bytes.toByteArray(); + } + private static CTSpacing spacing(XWPFParagraph paragraph) { return paragraph.getCTP().getPPr().getSpacing(); }