diff --git a/CHANGELOG.md b/CHANGELOG.md index e5dcc0715..a11ec620d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A line holding an icon keeps the page's height in Word.** A paragraph whose picture passes + its text was written "at least" the picture's height, and Word, growing the line to its own + measure, made it taller than the page: `TimelineMinimal`'s contact lines, a 10.5pt icon + beside smaller text, came out 0.9pt taller each, and the page under them 4.4pt low. A + paragraph of one line of text is now written at the page's exact height of its line, which + the page makes tall enough for its pictures; Word sets the text on the page's baseline and the + pictures with it, and what their ink reaches past the line is taken from the gaps around it, + with half a point more for the half points Word rounds a position to, so the lines keep the + page's pitch. A line whose space above is shorter than its pictures' reach above it — as the + first of a page or a cell can be — a paragraph of several lines, a line holding only a + picture, a list item, a table's text cell and a line set beside another are grown "at least" + as before. In LibreOffice a picture the page lowers now stands higher than its text and loses + what passes the line's top, where its text had drifted as far as 11pt low. Across the 62 + templates, lines more than 2pt off fall from 572 to 406 in Word and from 933 to 737 in + LibreOffice. In Word, `TimelineMinimal`'s median drift falls from 4.1pt to 1.4 and its lines + off from 67 to none, `MerchantInvoice`'s from 1.7pt to 0.3, and `CharcoalGold`, + `SidebarPortrait` and `MintEditorial` have no line 2pt off. + - **A block pulled up by a negative top edge, and a row padded down its column, stand where the page puts them in Word.** Word has no negative space above a paragraph, so a paragraph's, a page reference's or a rule's negative top `margin` or `padding` was dropped: diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index e8e486b8f..0e75c4479 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -65,7 +65,7 @@ Payload records live in `core` under | 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) | +| 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; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) | | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture, followed by a space) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 27abfe708..c68504f59 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 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. 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. 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 | @@ -414,8 +414,27 @@ page.addParagraph(p -> p paragraph, so every line of it is then at least that reach and otherwise as tall as the editor's own font makes it — for 14pt text, about 2.5pt taller than the page's in LibreOffice. A picture that stays inside the text in both editors keeps the exact - height; a 12pt icon on a line of 14pt text does not, since on the baseline it rises past + height; a 12pt icon on a line of 14pt text leaves it, since on the baseline it rises past the ascent. + Such a paragraph of one line of text, in a Word paragraph of its own, keeps an exact line + at the page's height of it instead — the page makes that line tall enough for its pictures — + and Word sets the text on the page's baseline and the pictures with it, where the page + puts them. What a picture's ink reaches past the line, into the gap above or below as the + page draws it, the line takes from that gap, so the lines keep the page's pitch: + `TimelineMinimal`'s contact lines, a 10.5pt icon beside smaller text, had grown 0.9pt each + in Word and 2.2pt in LibreOffice. The line keeps half a point past the ink on either side + as well, taken the same way where there is room: Word rounds the picture's position and + the text's to half points each, and an icon as tall as its line lost 0.2 to 0.4pt at an + edge without it. Where the space above is shorter than the ink's reach above the line — + as it can be for the first line of a page or a cell — the line is grown "at least", as + before. The ink below is taken from the space above what follows, as much as that space + holds. A paragraph + of several lines, a line holding only a picture — not seated + on the page's baseline, having no text — a list item, a table's text cell and a line set + beside another are grown as above. At the end of a cell, the ink below the last line is + not taken from the cell's bottom: the row is that much taller. LibreOffice stands such a picture on + the baseline, higher than the page does, and cuts what passes the line's top: a lowered + icon stands up to its drop too high there, its top cut, where its text is in place. - **What an icon is.** An SVG icon — an emoji among them — is drawn into a transparent picture from the same layers the page draws, by the raster the PPTX export falls back to, so it looks as it does on the page. The text it stands for is the picture's 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 fbaaf5c5a..7afddce87 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 @@ -4206,11 +4206,11 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine 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, lineTopAbove(lineTop, pair.left())); + writeParagraphRuns(para, pair.left(), false, lineTopAbove(lineTop, pair.left()), false); closeAnchor(para, leftAnchor); para.createRun().addTab(); int rightAnchor = openAnchor(para, pair.right().anchor()); - writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right())); + writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false); closeAnchor(para, rightAnchor); if ((pair.left().keepWithNext() && layout.onOnePage(pair.left())) || (pair.right().keepWithNext() && layout.onOnePage(pair.right()))) { @@ -4489,7 +4489,7 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { // 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()); + writeParagraphRuns(para, node, rightToLeft, stacked == null ? 0 : stacked.topAbove(), stacked == null); closeAnchor(para, anchor); } @@ -4777,7 +4777,7 @@ 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) { - writeParagraphRuns(para, node, rightToLeft, 0); + writeParagraphRuns(para, node, rightToLeft, 0, false); } /** @@ -4786,9 +4786,11 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean * * @param lineTopAbove how far above the page's first line of the paragraph the Word line * starts, in points + * @param ownLine whether the Word paragraph is this paragraph's alone, its lines the + * page's — not a line pair's, a stack's or a text box's */ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean rightToLeft, - double lineTopAbove) { + double lineTopAbove, boolean ownLine) { int runsBefore = para.getCTP().sizeOfRArray() == 0 && para.getCTP().sizeOfHyperlinkArray() == 0 ? 0 : runsIn(para).size(); warnDroppedInlineRuns(node); @@ -4826,9 +4828,25 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean applyRunDirection(docRun, rightToLeft); setTextBrokenAtLines(docRun, node.text()); } - makeRoomForPictures(para, pictures); + // A line pair's or a stack's line is cut to its own measure, not the paragraph's. A line + // with no text is not seated on the page's baseline (shiftToThePagesBaseline), and held + // exact its picture would stand where Word's baseline puts it, cut by the line's top. + double heldAbove = ownLine && layout.lineCount(node) == 1 && holdsText(node) + ? holdPicturesInTheLine(para, pictures, seatShift(node)) : Double.NaN; + // A line held to its pictures has their ink within half a point of its edges. + boolean cutToTheInk = !Double.isNaN(heldAbove); + if (Double.isNaN(heldAbove)) { + makeRoomForPictures(para, pictures); + heldAbove = 0; + } styleTheMark(para, markStyle); - seatInTheLine(para, node, runsBefore, lineTopAbove); + seatInTheLine(para, node, runsBefore, lineTopAbove + heldAbove, cutToTheInk); + } + + /** Whether a paragraph laid out a line holding text, which its seat is read from. */ + private boolean holdsText(ParagraphNode node) { + return layout.lines(node).stream().anyMatch(line -> line.spans().stream().anyMatch( + span -> span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan)); } /** @@ -4852,9 +4870,12 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean * @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 + * @param heldExact whether the line was held to its pictures' reach + * ({@link #holdPicturesInTheLine}), and so is seated however little */ - private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore, double lineTopAbove) { - long halfPoints = Math.round((seatShift(node) + shiftToThePagesBaseline(para, node, lineTopAbove)) + private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore, double lineTopAbove, + boolean heldExact) { + long halfPoints = Math.round((seatShift(node) + shiftToThePagesBaseline(para, node, lineTopAbove, heldExact)) * HALF_POINTS_PER_POINT); if (halfPoints == 0) { return; @@ -4933,8 +4954,11 @@ private double seatShift(ParagraphNode node) { * 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 + * @param heldExact whether the line was cut to its pictures' reach, and so is seated + * however little */ - private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, double lineTopAbove) { + private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, double lineTopAbove, + boolean heldExact) { CTPPr properties = para.getCTP().getPPr(); if (properties == null || !properties.isSetSpacing()) { return 0; @@ -4957,8 +4981,9 @@ private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, d + 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); + // to fit its letters where the page sets them, and is seated however little; so + // is one cut to its pictures' reach, whose ink stands at its edges. + boolean cutToFit = lineTopAbove != 0 || stackedLineHeights.containsKey(node) || heldExact; return Math.abs(shift) < LEAST_BASELINE_SHIFT_POINTS && !cutToFit ? 0 : shift; } } @@ -4999,7 +5024,8 @@ private static void setTextBrokenAtLines(XWPFRun run, String text) { * editor's own measure of its text, which in LibreOffice is taller than the page's — * where the page makes only the line holding the picture taller. A paragraph written with * no exact height — a page zone's, one with no layout — grows to its pictures on its own - * and is left alone.

+ * and is left alone. A paragraph of one line is held to the page's line instead where it + * can be ({@link #holdPicturesInTheLine}).

*/ private static void makeRoomForPictures(XWPFParagraph para, PictureReach pictures) { CTPPr properties = para.getCTP().getPPr(); @@ -5019,6 +5045,71 @@ private static void makeRoomForPictures(XWPFParagraph para, PictureReach picture spacing.setLine(BigInteger.valueOf(line)); } + /** + * Holds a paragraph of one line and its pictures at the page's height, where Word sets them + * as the page does. + * + *

The line stays exact, at the page's height of it: Word then sets its text on the + * page's baseline ({@link #seatInTheLine}) and the pictures with it, where the page puts + * them. Grown "at least" to a picture instead ({@link #makeRoomForPictures}), Word made the + * line its own height — {@code TimelineMinimal}'s contact lines, a 10.5pt icon beside + * smaller text, each came out 0.9pt taller, and the page under them 4.4pt low; LibreOffice, + * measuring its text taller still, 11pt low.

+ * + *

A picture's ink reaching past the page's line is drawn on the page in the gaps around + * it; an exact Word line cuts it off. So the line reaches as far, taking that room from the + * space written above it and from the space above what follows ({@link #hangingBelow}), + * and keeps its pitch. The picture's raise and the line's seat are each rounded to a half + * point, so the line keeps {@link #INK_ROOM_POINTS} past the ink on either side, taken the + * same way, as far as the space above goes: an icon as tall as its line lost 0.2 to 0.4pt at + * an edge without it. A line whose space above is shorter than the ink's own reach is grown + * as before. The vertical seat + * ({@link #seatShift}) moves the pictures with the text, as on the page. LibreOffice stands + * a picture on the baseline, higher than the page does, and cuts what passes the line's + * top.

+ * + * @param seat how far the page seats the line's text and pictures off its baseline, in + * points, raised + * @return how far above the page's line the Word line now starts, in points, or NaN when + * the line is left to {@link #makeRoomForPictures} + */ + private double holdPicturesInTheLine(XWPFParagraph para, PictureReach pictures, double seat) { + CTPPr properties = para.getCTP().getPPr(); + // A picture inside its text keeps the exact line it has (makeRoomForPictures). + if (pictures == null || !pictures.overText() || !(pictures.reach() > 0) || !(pictures.pageLine() > 0) + || properties == null || !properties.isSetSpacing()) { + return Double.NaN; + } + CTSpacing spacing = properties.getSpacing(); + if (!spacing.isSetLineRule() || spacing.getLineRule() != STLineSpacingRule.EXACT) { + return Double.NaN; + } + long before = spacing.isSetBefore() ? twipsOf(spacing.getBefore()) : 0; + // The ink itself must fit under the space above; the room past it is taken as far as + // that space goes — a line opening its cell has none. + if (Math.round(Math.max(0, pictures.above() + seat) * POINT_TO_TWIP) > before) { + return Double.NaN; + } + long up = Math.min(before, Math.round(Math.max(0, pictures.above() + seat + INK_ROOM_POINTS) * POINT_TO_TWIP)); + Long current = writtenTwips(spacing.getLine()); + long page = Math.round(pictures.pageLine() * POINT_TO_TWIP); + long down = Math.round(Math.max(0, pictures.below() - seat + INK_ROOM_POINTS) * POINT_TO_TWIP); + spacing.setLine(BigInteger.valueOf((current == null ? page : Math.max(current, page)) + up + down)); + if (up > 0) { + spacing.setBefore(BigInteger.valueOf(before - up)); + } + if (down > 0) { + hangingBelow = Math.max(hangingBelow, down / POINT_TO_TWIP); + } + return up / POINT_TO_TWIP; + } + + /** + * The room a line held to its pictures keeps past their ink on either side, in points: the + * picture's raise and the line's seat are rounded to a half point each. + */ + private static final double INK_ROOM_POINTS = 0.5; + /** * Writes an inline picture or icon where it sits in the line, as a picture in its own run. * @@ -5125,7 +5216,7 @@ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, Docum + ") is the picture's description rather than a character in the line"); } if (line.isEmpty()) { - return new PictureReach(height, false); + return PictureReach.unplaced(height); } long raise = Math.round(bottom * 2); if (raise != 0) { @@ -5155,13 +5246,29 @@ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, Docum * line — below the text's ascent where Word puts it, above it on the baseline — counts as * rising: in LibreOffice it lost 1.7pt of its top at the exact height.

* + *

The page's own line holds most of a picture already: a contact line's icon, taller + * than its text and hanging below it, makes the line as tall as the icon, and what its ink + * reaches past the line the page draws in the gaps around it ({@link #holdPicturesInTheLine}).

+ * * @param reach how far above the line's bottom the highest picture reaches, in points * @param overText whether a picture passes the text's ascent or descent + * @param pageLine the page's height of the line, 0 when where the pictures stand is not + * known, and NaN when no picture was written + * @param above how far the highest picture's ink reaches above the page's line where Word + * 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 */ - record PictureReach(double reach, boolean overText) { + record PictureReach(double reach, boolean overText, double pageLine, double above, double below) { /** No picture written. */ - static final PictureReach NONE = new PictureReach(0, false); + static final PictureReach NONE = new PictureReach(0, false, Double.NaN, + Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY); + + /** A picture in a line the page laid out none of: where it stands is not known. */ + static PictureReach unplaced(double height) { + return new PictureReach(height, false, 0, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY); + } static PictureReach of(double bottomFromBaseline, double height, com.demcha.compose.document.layout.payloads.ParagraphLine line) { @@ -5186,12 +5293,20 @@ static PictureReach of(double bottomFromBaseline, double height, double wordTop = bottomFromBaseline + height - inset; double top = Math.max(wordTop, height - inset); boolean passes = top > line.textAscent() || -(bottomFromBaseline + inset) > descent; - return new PictureReach(descent + wordTop, passes); + double above = descent + wordTop - line.lineHeight(); + double below = -(bottomFromBaseline + inset) - descent; + return new PictureReach(descent + wordTop, passes, line.lineHeight(), above, below); } PictureReach max(PictureReach other) { - return other == null ? this - : new PictureReach(Math.max(reach, other.reach), overText || other.overText); + if (other == null) { + return this; + } + double line = Double.isNaN(pageLine) ? other.pageLine + : 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)); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureTest.java index c4b0c342a..a91520622 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlinePictureTest.java @@ -111,24 +111,217 @@ void aPictureOnTheBaselineIsRaisedByItsOffset() throws Exception { } @Test - void aLineHoldingAPictureTallerThanItsTextGrowsToIt() throws Exception { - try (XWPFDocument document = export(page -> page.addParagraph(p -> p - .inlineText("Tall ").inlineImage(DocumentImageData.fromBytes(png(30, 30)), 30, 30)))) { - var spacing = document.getParagraphs().get(0).getCTP().getPPr().getSpacing(); + void aLineHoldingAPictureTallerThanItsTextIsThePagesLineGrownToIt() throws Exception { + // The page makes the line as tall as the picture: one line holding it is written exactly + // that tall, and Word sets the picture in it where the page does. The paragraph above + // leaves it room above for half a point past the picture's edge. + try (XWPFDocument document = export(page -> page + .addParagraph(p -> p.text("Above").margin(new DocumentInsets(0, 0, 6, 0))) + .addParagraph(p -> p.inlineText("Tall ").inlineImage(DocumentImageData.fromBytes(png(30, 30)), 30, 30)))) { + var spacing = document.getParagraphs().get(1).getCTP().getPPr().getSpacing(); assertThat(spacing.getLineRule()) - .as("at least, so the editor grows the line rather than clip the picture") - .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.AT_LEAST); + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); assertThat(DocxTwips.of(spacing.getLine())).isGreaterThanOrEqualTo(30 * 20L); } } @Test - void anIconAsTallAsItsTextGrowsTheLineForTheEditorThatStandsItOnTheBaseline() throws Exception { - // A 12pt icon the page centres on a 14pt line: under the text's ascent where Word - // lowers it, above it where LibreOffice stands it on the baseline — and clipped there. + void anIconLineIsWrittenAtThePagesHeightNotGrownToWordsOwn() throws Exception { + // A 12pt icon the page centres on a 14pt line stands inside it where Word puts it. Grown + // "at least" instead, Word made TimelineMinimal's contact lines 0.9pt taller each. + try (XWPFDocument plain = export(page -> page.addParagraph(p -> p.inlineText("+44 20 7946 0000"))); + XWPFDocument iconed = export(page -> page.addParagraph(p -> p + .inlineSvgIcon(ICON, 12).inlineText(" +44 20 7946 0000")))) { + var spacing = iconed.getParagraphs().get(0).getCTP().getPPr().getSpacing(); + + assertThat(spacing.getLineRule()) + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); + assertThat(DocxTwips.of(spacing.getLine())).as("the page's line, which the icon fits") + .isEqualTo(DocxTwips.of(plain.getParagraphs().get(0).getCTP().getPPr().getSpacing().getLine())); + } + } + + @Test + void contactLinesStepAsThePageStepsThemInWord() throws Exception { + // TimelineMinimal's contact stack: a 10.5pt icon lowered beside 7pt text, 3pt apart. + // Grown "at least" to the icon, each line came out 0.9pt taller in Word. + com.demcha.compose.document.style.DocumentTextStyle small = + com.demcha.compose.document.style.DocumentTextStyle.DEFAULT.withSize(7); + try (DocumentSession session = GraphCompose.document().pageSize(400, 400) + .margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addSection("Contact", contact -> { + contact.spacing(3); + for (String line : List.of("London, UK", "+44 20 5555 1000", "GitHub")) { + contact.addParagraph(p -> p.name("Line").textStyle(small).inlineText(line + " ", small) + .inlineSvgIcon(ICON, 10.5, InlineImageAlignment.CENTER, -1.35, null)); + } + })); + List tops = session.layoutGraph().nodes().stream() + .filter(node -> node.nodeKind().equals("ParagraphNode")) + .map(node -> node.placementY() + node.placementHeight()) + .toList(); + XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(session.export(new DocxSemanticBackend()))); + List lines = document.getParagraphs(); + + for (int i = 1; i < 3; i++) { + var above = lines.get(i - 1).getCTP().getPPr().getSpacing(); + var spacing = lines.get(i).getCTP().getPPr().getSpacing(); + assertThat(spacing.getLineRule()).as("exact, at the page's height") + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); + double pageStep = tops.get(i - 1) - tops.get(i); + // Each line starts as far above the page's as the next, the same icon in each. + double wordStep = (DocxTwips.of(above.getLine()) + + (spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0)) / 20.0; + assertThat(wordStep).as("line %d steps as the page's", i).isCloseTo(pageStep, within(0.1)); + } + } + } + + @Test + void anIconsInkAboveThePagesLineComesOutOfTheSpaceAboveIt() throws Exception { + // Raised 1.5pt, the centred icon's ink stands above the page's line: the Word line starts + // that much and half a point higher, and the 3pt above it is written that much shorter. + Between line = betweenTwoLines(InlineImageAlignment.CENTER, 1.5); + + assertThat(line.up()).as("the space above gives up the ink's reach").isGreaterThan(1.5); + assertThat(line.down()).as("nothing hangs below").isZero(); + assertThat(line.line()).as("the page's line and that reach").isCloseTo(line.pageLine() + line.up(), within(0.06)); + } + + @Test + void anIconsInkBelowThePagesLineComesOutOfTheSpaceBelowIt() throws Exception { + // Lowered 1.35pt, as TimelineMinimal's contact icons are, the ink hangs below the page's + // line: the Word line ends that much and half a point lower, and the space above the + // next line is written that much shorter. + Between line = betweenTwoLines(InlineImageAlignment.CENTER, -1.35); + + assertThat(line.down()).as("the space below gives up the ink's reach").isGreaterThan(1.35); + assertThat(line.up()).as("nothing stands above").isZero(); + assertThat(line.line()).as("the page's line and that reach").isCloseTo(line.pageLine() + line.down(), within(0.06)); + } + + @Test + void anIconAsTallAsItsLineKeepsHalfAPointOfRoomAtEitherEdge() throws Exception { + // The picture's raise and the line's seat round to half points each: an icon whose ink + // meets both edges of the page's line lost 0.2 to 0.4pt at one of them held without room. + Between line = betweenTwoLines(InlineImageAlignment.CENTER, 0); + + assertThat(line.up()).isCloseTo(0.5, within(0.06)); + assertThat(line.down()).isCloseTo(0.5, within(0.06)); + } + + @Test + void aLineHeldToItsIconIsSeatedHoweverLittleItsTextMoves() throws Exception { + // Held with its ink half a point from the line's edges, Word's baseline stands less than + // the half point from the page's that a line of body text is left at: moved by nothing, + // the ink would pass an edge by that much. + Between line = betweenTwoLines(InlineImageAlignment.CENTER, -0.75); + + assertThat(line.textPosition()).as("the text is moved, the icon with it").isNotZero(); + } + + @Test + void aLineOpeningThePageIsHeldWhereItsInkFitsTakingWhatRoomThereIs() throws Exception { + // As TimelineMinimal's first contact line: the icon's ink meets the line's top and does + // not pass it, so the line is held, with no room above it to take past the ink. + Laid laid = contactLines(InlineImageAlignment.CENTER, 0); + var first = laid.lines().get(0).getCTP().getPPr().getSpacing(); + + assertThat(first.getLineRule()) + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); + assertThat(first.isSetBefore() ? DocxTwips.of(first.getBefore()) : 0L).as("nothing above to take").isZero(); + } + + @Test + void aLineWhoseSpaceAboveIsShorterThanItsReachIsGrownAsBefore() throws Exception { + // The first line opens the page: nothing above it gives up the raised icon's reach. + Laid laid = contactLines(InlineImageAlignment.CENTER, 1.5); + var first = laid.lines().get(0).getCTP().getPPr().getSpacing(); + + assertThat(first.getLineRule()) + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.AT_LEAST); + assertThat(first.isSetBefore() ? DocxTwips.of(first.getBefore()) : 0L).as("nothing taken above").isZero(); + } + + /** + * An icon line between two lines of text 3pt apart: how far its Word line reaches above + * and below the page's, read off the space written above it and above the line after it. + */ + private record Between(double pageLine, double line, double up, double down, int textPosition) { + } + + private static Between betweenTwoLines(InlineImageAlignment alignment, double offset) throws Exception { + com.demcha.compose.document.style.DocumentTextStyle small = + com.demcha.compose.document.style.DocumentTextStyle.DEFAULT.withSize(7); + try (DocumentSession session = GraphCompose.document().pageSize(400, 400) + .margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addSection("Contact", contact -> { + contact.spacing(3); + contact.addParagraph(p -> p.name("Above").textStyle(small).text("Above")); + contact.addParagraph(p -> p.name("Iconed").textStyle(small).inlineText("London ", small) + .inlineSvgIcon(ICON, 10.5, alignment, offset, null)); + contact.addParagraph(p -> p.name("Below").textStyle(small).text("Below")); + })); + double pageLine = session.layoutGraph().nodes().stream() + .filter(node -> "Iconed".equals(node.semanticName())) + .findFirst().orElseThrow().placementHeight(); + XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(session.export(new DocxSemanticBackend()))); + var iconed = document.getParagraphs().get(1).getCTP().getPPr().getSpacing(); + var after = document.getParagraphs().get(2).getCTP().getPPr().getSpacing(); + assertThat(iconed.getLineRule()) + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); + XWPFRun text = document.getParagraphs().get(1).getRuns().get(0); + int textPosition = text.getCTR().isSetRPr() && text.getCTR().getRPr().sizeOfPositionArray() > 0 + ? ((Number) text.getCTR().getRPr().getPositionArray(0).getVal()).intValue() : 0; + return new Between(pageLine, DocxTwips.of(iconed.getLine()) / 20.0, + 3 - DocxTwips.of(iconed.getBefore()) / 20.0, + 3 - (after.isSetBefore() ? DocxTwips.of(after.getBefore()) : 0) / 20.0, textPosition); + } + } + + @Test + void aLineHoldingOnlyAPictureIsGrownAsBefore() throws Exception { + // With no text on it the line is not seated on the page's baseline, and held exact the + // picture would stand where Word's baseline puts it, its top cut by the line's. try (XWPFDocument document = export(page -> page.addParagraph(p -> p - .inlineSvgIcon(ICON, 12).inlineText(" +44 20 7946 0000")))) { + .inlineImage(DocumentImageData.fromBytes(png(30, 30)), 30, 30)))) { + assertThat(document.getParagraphs().get(0).getCTP().getPPr().getSpacing().getLineRule()) + .isNotEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); + } + } + + /** The Word paragraphs of three contact lines, and the page's height of each. */ + private record Laid(List lines, List heights) { + } + + private static Laid contactLines(InlineImageAlignment alignment, double offset) throws Exception { + com.demcha.compose.document.style.DocumentTextStyle small = + com.demcha.compose.document.style.DocumentTextStyle.DEFAULT.withSize(7); + try (DocumentSession session = GraphCompose.document().pageSize(400, 400) + .margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addSection("Contact", contact -> { + contact.spacing(3); + for (String line : List.of("London, UK", "+44 20 5555 1000", "GitHub")) { + contact.addParagraph(p -> p.textStyle(small).inlineText(line + " ", small) + .inlineSvgIcon(ICON, 10.5, alignment, offset, null)); + } + })); + List heights = session.layoutGraph().nodes().stream() + .filter(node -> node.nodeKind().equals("ParagraphNode")) + .map(node -> node.placementHeight()) + .toList(); + XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(session.export(new DocxSemanticBackend()))); + return new Laid(document.getParagraphs(), heights); + } + } + + @Test + void anIconInAParagraphOfSeveralLinesGrowsTheLineForTheEditorThatStandsItOnTheBaseline() throws Exception { + // Word has one line height for a paragraph, and the page makes only the icon's line + // taller: such a paragraph is left "at least", for an editor to grow the line it needs. + try (XWPFDocument document = export(page -> page.addParagraph(p -> p + .inlineSvgIcon(ICON, 12).inlineText(" +44 20 7946 0000".repeat(8))))) { assertThat(document.getParagraphs().get(0).getCTP().getPPr().getSpacing().getLineRule()) .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.AT_LEAST); } @@ -208,11 +401,31 @@ void anItemsLineIsAsTallAsItsTextOrItsTallestGraphic() { @Test void aParagraphLeavesItsTextWhenAnyOfItsPicturesDoes() { - var inside = new DocxSemanticBackend.PictureReach(10, false); - var leaving = new DocxSemanticBackend.PictureReach(8, true); + var inside = new DocxSemanticBackend.PictureReach(10, false, 12, 0, 0); + var leaving = new DocxSemanticBackend.PictureReach(8, true, 12, 0, 1); + + assertThat(inside.max(leaving)).isEqualTo(new DocxSemanticBackend.PictureReach(10, true, 12, 0, 1)); + assertThat(leaving.max(inside)).isEqualTo(new DocxSemanticBackend.PictureReach(10, true, 12, 0, 1)); + } + + @Test + void aLineWhosePicturesStandUnknownHasNoPagesLine() { + var placed = new DocxSemanticBackend.PictureReach(10, true, 12, 1, 0); + var unplaced = DocxSemanticBackend.PictureReach.unplaced(8); + + assertThat(placed.max(unplaced).pageLine()).as("one picture stands where it is not known").isZero(); + assertThat(unplaced.max(placed).pageLine()).isZero(); + assertThat(DocxSemanticBackend.PictureReach.NONE.max(placed)).as("no picture is no answer").isEqualTo(placed); + assertThat(placed.max(DocxSemanticBackend.PictureReach.NONE)).isEqualTo(placed); + } + + @Test + void aLineOfSeveralPicturesIsTheTallestAndReachesAsFarAsAnyOfThem() { + var tall = new DocxSemanticBackend.PictureReach(10, true, 14, -1, 0.5); + var high = new DocxSemanticBackend.PictureReach(9, true, 12, 0.8, -2); - assertThat(inside.max(leaving)).isEqualTo(new DocxSemanticBackend.PictureReach(10, true)); - assertThat(leaving.max(inside)).isEqualTo(new DocxSemanticBackend.PictureReach(10, true)); + assertThat(tall.max(high)).isEqualTo(new DocxSemanticBackend.PictureReach(10, true, 14, 0.8, 0.5)); + assertThat(high.max(tall)).isEqualTo(new DocxSemanticBackend.PictureReach(10, true, 14, 0.8, 0.5)); } private static List picturePositions(XWPFDocument document) { @@ -258,16 +471,22 @@ void aPictureLeavesTheTextWhenEitherEditorPutsItPastTheAscentOrTheDescent() { // Lowered 2pt, an 8pt picture tops out at 6pt in Word and at 8pt on LibreOffice's // baseline: within the ascent either way. The line asks for Word's reach, 2 + 6. + // Both stand inside the page's 14pt line where Word puts them: 6pt below its top, and + // on its foot. assertThat(DocxSemanticBackend.PictureReach.of(-2, 8, line)) - .isEqualTo(new DocxSemanticBackend.PictureReach(8, false)); + .isEqualTo(new DocxSemanticBackend.PictureReach(8, false, 14, -6, 0)); // A 10pt one tops out at 8pt in Word, inside, but at 10pt on the baseline, outside: the // line is "at least" Word's 2 + 8, and LibreOffice grows it to its own placement. assertThat(DocxSemanticBackend.PictureReach.of(-2, 10, line)) - .isEqualTo(new DocxSemanticBackend.PictureReach(10, true)); - // A 14pt one is outside in both. + .isEqualTo(new DocxSemanticBackend.PictureReach(10, true, 14, -4, 0)); + // A 14pt one is outside in both, and meets the top of the page's 14pt line; a 16pt one + // passes it by 2pt. assertThat(DocxSemanticBackend.PictureReach.of(-2, 14, line).overText()).isTrue(); - // One lowered 4pt hangs past the 2pt descent in Word. + assertThat(DocxSemanticBackend.PictureReach.of(-2, 14, line).above()).isCloseTo(0, within(1e-9)); + assertThat(DocxSemanticBackend.PictureReach.of(-2, 16, line).above()).isCloseTo(2, within(1e-9)); + // One lowered 4pt hangs past the 2pt descent in Word, 2pt below the line's foot. assertThat(DocxSemanticBackend.PictureReach.of(-4, 5, line).overText()).isTrue(); + assertThat(DocxSemanticBackend.PictureReach.of(-4, 5, line).below()).isCloseTo(2, within(1e-9)); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineShapeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineShapeTest.java index 473df0c8b..d98a49231 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineShapeTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineShapeTest.java @@ -240,7 +240,9 @@ void aStrokedShapeOnTheBaselineHangsItsHalfStrokeBelowIt() throws Exception { .shape(ShapeOutline.circle(8), INK, DocumentStroke.of(DocumentColor.rgb(0, 0, 0), 2.4), com.demcha.compose.document.node.InlineImageAlignment.BASELINE, 0, null)))) { // Half the 2.4pt stroke and the frame: 1.45pt, which is 2.9 half-points — not a tie. - assertThat(position(document.getParagraphs().get(0).getRuns().get(1))) + // Read off the text's position, which the line's seat moves the picture with. + var runs = document.getParagraphs().get(0).getRuns(); + assertThat(position(runs.get(1)) - position(runs.get(0))) .isEqualTo(Math.round(-(1.2 + DocxShapePictures.EDGE) * 2)); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java index 01ed9dfa6..64a6bc12d 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphMarkTest.java @@ -111,12 +111,12 @@ void aMarkLargerThanTheDocumentsTextIsSetInTheTextsFace() throws Exception { @Test void aLineGrownToAPictureSizesItsMarkToo() throws Exception { - // Small print with an icon rising above it is written "at least" the icon's height; a - // mark at the document's size would grow it further. + // Two lines of small print with an icon rising above them are written "at least" the + // icon's height; a mark at the document's size would grow them further. try (XWPFDocument document = export(page -> page - .addParagraph(p -> p.text("Body text sets the document's size")) + .addParagraph(p -> p.text("Body text sets the document's size. ".repeat(12))) .addParagraph(p -> p.textStyle(DocumentTextStyle.DEFAULT.withSize(6)) - .inlineText("Tiny ") + .inlineText("Tiny print that runs on past the end of its line ".repeat(4)) .inlineImage(com.demcha.compose.document.image.DocumentImageData.fromBytes(png()), 14, 14)))) { CTPPr small = document.getParagraphs().get(1).getCTP().getPPr(); @@ -127,13 +127,13 @@ void aLineGrownToAPictureSizesItsMarkToo() throws Exception { @Test void aMarkIsSizedAsTheTextThatEndsTheLineNotAsTheParagraphsUnusedStyle() throws Exception { - // A contact line: an icon and runs styled on their own, the paragraph's style left at its - // default. A mark in that default grew every such line in both editors. + // A contact block: an icon and runs styled on their own, the paragraph's style left at its + // default, on two lines. A mark in that default grew every such line in both editors. try (XWPFDocument document = export(page -> page .addParagraph(p -> p.text("Body text sets the document's size")) .addParagraph(p -> p .inlineImage(com.demcha.compose.document.image.DocumentImageData.fromBytes(png()), 14, 14) - .inlineText(" billing@example.com", DocumentTextStyle.DEFAULT.withSize(6))))) { + .inlineText(" billing@example.com".repeat(12), DocumentTextStyle.DEFAULT.withSize(6))))) { CTPPr line = document.getParagraphs().get(1).getCTP().getPPr(); assertThat(line.getSpacing().getLineRule()).hasToString("atLeast");