From 2b479243adde4eb8a0839aaa376e2d2cce8ec770 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 12 Oct 2026 18:38:13 +0100
Subject: [PATCH 1/4] fix(docx): lower a Word list's item Word sets high by the
space above it
Word draws a Word list's marker from the paragraph's mark, and
LibreOffice does not move it by the mark's position, so such an item is
given no position, and one Word set high by half a point or more stayed
there: MidnightNavy's two-line duties stood up to 1.06pt high in Word
and 1.04pt in LibreOffice, NavySidebar's up to 0.47pt.
Such an item is now lowered by growing the space written above it,
which moves its marker and its text together in both editors. The items
under it give that back from the space above them, before their own
raise, one standing as high taking it back by its own lowering. What
the list's last items still owe hangs below the list, each list's on
its own, as a line hanging below a block does: the space above what
follows takes it, and at a cell's end the space the cell owes, its
row's room or a card's padding. Where that hang is let go - below a
table's composed cell or a layer stack's column, under a column's later
layer, at a block on a later page, at a section's end - the lowering is
taken back, and the items stand as before. Not an item the layout opens
a page with, one of a list set over two pages in a cell, one opening a
card, nor one in an overlay laying one thing over another; a stack of
one layer is in the flow.
The duties now stand within 0.2pt in Word and LibreOffice, everything
after them where it stood; the other 60 corpus documents are
byte-identical, the report's notes unchanged, and the Word and
LibreOffice-on-Windows baselines change only there.
---
CHANGELOG.md | 29 +-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../libreoffice-windows-lines.tsv | 48 +-
.../docx-fidelity/libreoffice-windows.tsv | 4 +-
.../docx-fidelity/word-windows-lines.tsv | 48 +-
.../resources/docx-fidelity/word-windows.tsv | 4 +-
.../semantic/docx/DocxSemanticBackend.java | 316 +++++++-
.../semantic/docx/DocxComposedListTest.java | 14 +-
.../semantic/docx/DocxLayerColumnsTest.java | 73 ++
.../semantic/docx/DocxListItemLowerTest.java | 683 ++++++++++++++++++
.../semantic/docx/DocxListItemSeatTest.java | 3 +-
12 files changed, 1158 insertions(+), 68 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7a2d4e41..2ae203df7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,30 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export lowers a Word list's item that Word sets high by the space above it, its marker
+ with its text.** Word draws a Word list's marker from the paragraph's mark, and LibreOffice does
+ not move it by the mark's position, so an item the export's measure of Word's seat found high by
+ half a point or more was left there rather than given a position: `MidnightNavy`'s two-line
+ duties, 0.9pt high by that measure, stood 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in
+ LibreOffice, `NavySidebar`'s, 0.51pt by it, 0.30 to 0.47pt. Such an item is now lowered by growing
+ the space written above it, which moves its marker and its text together in both editors. The
+ items under it take as much back out of the space above them, one standing as high taking it back
+ by its own lowering. What the list's last items still owe hangs below the list as a line hanging
+ below a block does: the space above what follows takes it, the report naming what that space and
+ that block's own top edge cannot give, and at the end of a cell the space the cell owes under the
+ list, the room under it in its row or a card's padding, each list's on its own. Wherever the hang
+ is let go instead — among them below a table's composed cell or a layer stack's column, under a
+ column's later layer, which takes its place from the page, at a block the layout sets on a later
+ page, which takes nothing of the hang left on the page above, at a section's end — the lowering
+ is taken back, those items standing as high as before and what follows where it stood. Not
+ lowered: an item the layout opens a page with, one of a list it sets over two pages in a cell, an
+ item opening a card, whose top border the space above it takes, and one in an overlay whose writer
+ measures what follows from the page — a stack laying one layer over another, a band, a shape
+ container; a stack of one layer, in which a template wraps an entry, is written in the flow, and
+ its items are lowered as there. The duties now stand 0.10pt high to 0.20pt low in Word and 0.14pt
+ high to 0.08pt low in LibreOffice on Windows and 24.2, `NavySidebar`'s 0.03 to 0.19pt and 0.03 to
+ 0.05pt low, everything after them where it stood; no other document of the corpus changed.
+
- **A DOCX export writes a list composed in a table cell on the lines its table laid out for it, as
it does a paragraph there.** A composed cell's content is laid out under its table's path, so a
list there has no lines at a path of its own, and its items were matched to none: written at no
@@ -48,9 +72,8 @@ follow semantic versioning; release dates are ISO 8601.
would be the space above the next page's first, inside the cell. An item whose marker Word draws,
a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark,
which LibreOffice does not move by the mark's position — lowered a point by both, `MidnightNavy`'s
- duties stood 1.15pt under their bullets there — so its line keeps what the space above does not
- take of it, and `MidnightNavy`'s and `NavySidebar`'s duties stand as high as before. An item the
- layout's lines are not matched to is not seated.
+ duties stood 1.15pt under their bullets there — so a line of it Word sets low keeps what the space
+ above does not take of it. An item the layout's lines are not matched to is not seated.
`ClassicInvoice`'s notes moved up 0.24 to 0.36pt in Word and 0.3pt in LibreOffice, to within
0.05pt of the page, `BoxedSections`' and `ModernProfessional`'s skills and projects 0.12 to 0.24pt
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 9c655a57b..ca1b2f601 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -63,7 +63,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 centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; 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, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it, in the flow and in cells, not in an overlay, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; what that space cannot give, and a line Word sets high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; 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; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
+| 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 centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; 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, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it, in the flow and in cells, not in an overlay, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; what that space cannot give, and a line Word sets high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; 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; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
| 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 | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell that its table's lines are not matched to, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named |
| 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 above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows 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; 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) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 9a3055254..1dffc11d8 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -185,7 +185,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, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). 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 that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — 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 an export without a layout writes — 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 on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line keeps what the space above does not take of it. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. 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, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). 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 that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — 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 an export without a layout writes — 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 on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell or a layer stack's column, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. 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 — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. 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 f70e4800b..2cec18a50 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -605,19 +605,19 @@ cv-midnight_navy 0:086c0e036e67 -0.56 -0.62 2021–present
cv-midnight_navy 0:009b5b6479fa -0.48 0.07 starwavesolutions
cv-midnight_navy 0:60cebc1ec80c -0.48 -0.58 newyork,ny
cv-midnight_navy 0:60cebc1ec80c#1 -0.28 -0.58 newyork,ny
-cv-midnight_navy 0:02cf5f2e28f2 -1.04 0.07 ·developandimplementcomprehensiv
-cv-midnight_navy 0:1dfea8c13a62 -1.02 0.10 awarenessby40%andleadgenerationb
-cv-midnight_navy 0:e0ee3cbb7b53 -1.01 0.07 ·manageateamof6marketingprofessi
-cv-midnight_navy 0:8ba85a74e016 -0.99 0.10 anddesignteamstodrivecampaignsuc
-cv-midnight_navy 0:407f710db042 -0.98 0.07 ·overseedigitalcampaignsacrossse
-cv-midnight_navy 0:308aef77a1c3 -0.96 0.10 resultingina25%increaseinroi.
-cv-midnight_navy 0:40f5daa37f9a -0.95 0.07 ·analyzemarkettrendsandcompetito
-cv-midnight_navy 0:af67fd7a2e8b -0.93 0.10 optimizemarketingefforts.
+cv-midnight_navy 0:02cf5f2e28f2 -0.14 0.07 ·developandimplementcomprehensiv
+cv-midnight_navy 0:1dfea8c13a62 -0.12 0.10 awarenessby40%andleadgenerationb
+cv-midnight_navy 0:e0ee3cbb7b53 -0.11 0.07 ·manageateamof6marketingprofessi
+cv-midnight_navy 0:8ba85a74e016 -0.09 0.10 anddesignteamstodrivecampaignsuc
+cv-midnight_navy 0:407f710db042 -0.08 0.07 ·overseedigitalcampaignsacrossse
+cv-midnight_navy 0:308aef77a1c3 -0.06 0.10 resultingina25%increaseinroi.
+cv-midnight_navy 0:40f5daa37f9a -0.05 0.07 ·analyzemarkettrendsandcompetito
+cv-midnight_navy 0:af67fd7a2e8b -0.03 0.10 optimizemarketingefforts.
cv-midnight_navy 0:7e121dceb78a -0.42 0.07 seniormarketingspecialist
cv-midnight_navy 0:f7d1049ec64b -0.36 -0.45 2018–2021
cv-midnight_navy 0:4b8292fda43e -0.28 0.07 brightlinetechnologies
-cv-midnight_navy 0:a246f7ba3f71 -0.84 0.07 ·executedmulti-channelmarketingc
-cv-midnight_navy 0:58b162e46dda -0.82 0.10 engagementby30%.
+cv-midnight_navy 0:a246f7ba3f71 0.06 0.07 ·executedmulti-channelmarketingc
+cv-midnight_navy 0:58b162e46dda 0.08 0.10 engagementby30%.
cv-midnight_navy 0:ef77f2daef0f -0.23 0.07 ·managedsocialmediachannelsandco
cv-midnight_navy 0:a112a7b3c16b -0.21 0.07 ·coordinatedwithcreativeteamstod
cv-midnight_navy 0:990b9f22bb48 -0.25 0.07 marketingcoordinator
@@ -913,28 +913,28 @@ cv-navy_sidebar 0:8a39b996b27c 0.14 0.16 jan2022-present
cv-navy_sidebar 0:f97eeb2fe120 -0.20 0.10 brightwavesolutions,newyork,ny
cv-navy_sidebar 0:f97eeb2fe120#1 -0.22 0.10 brightwavesolutions,newyork,ny
cv-navy_sidebar 0:f97eeb2fe120#2 -0.25 0.10 brightwavesolutions,newyork,ny
-cv-navy_sidebar 0:a4beef0c22d9 -0.45 0.10 •developandimplementintegratedma
-cv-navy_sidebar 0:234eaa6417f7 -0.45 0.09 awarenessby35%andleadgenerationb
-cv-navy_sidebar 0:dfd245470d6e -0.45 0.10 •manageateamof4marketingspeciali
-cv-navy_sidebar 0:94d0b8d13763 -0.45 0.09 anddesignteamstoalignstrategies.
-cv-navy_sidebar 0:5dabf21df91b -0.45 0.10 •analyzecampaignperformanceusing
-cv-navy_sidebar 0:1cb810ca6189 -0.45 0.09 optimizeroiandreporting.
+cv-navy_sidebar 0:a4beef0c22d9 0.05 0.10 •developandimplementintegratedma
+cv-navy_sidebar 0:234eaa6417f7 0.05 0.09 awarenessby35%andleadgenerationb
+cv-navy_sidebar 0:dfd245470d6e 0.05 0.10 •manageateamof4marketingspeciali
+cv-navy_sidebar 0:94d0b8d13763 0.05 0.09 anddesignteamstoalignstrategies.
+cv-navy_sidebar 0:5dabf21df91b 0.05 0.10 •analyzecampaignperformanceusing
+cv-navy_sidebar 0:1cb810ca6189 0.05 0.09 optimizeroiandreporting.
cv-navy_sidebar 0:3b172b9ab75e -0.26 0.10 marketingspecialist
cv-navy_sidebar 0:d514872487af 0.12 0.15 jun2020-dec2021
-cv-navy_sidebar 0:a13f57f4d861 -0.47 0.10 •executeddigitalmarketingcampaig
-cv-navy_sidebar 0:4b308a27b878 -0.47 0.09 channels.
-cv-navy_sidebar 0:39b87fda4a82 -0.47 0.10 •conductedmarketresearchandcompe
-cv-navy_sidebar 0:b088780ed500 -0.47 0.09 andinformstrategy.
-cv-navy_sidebar 0:bafd8ea30ebe -0.47 0.10 •createdengagingcontentforblogs,
-cv-navy_sidebar 0:88a47597543f -0.47 0.09 engagementby40%.
+cv-navy_sidebar 0:a13f57f4d861 0.03 0.10 •executeddigitalmarketingcampaig
+cv-navy_sidebar 0:4b308a27b878 0.03 0.09 channels.
+cv-navy_sidebar 0:39b87fda4a82 0.03 0.10 •conductedmarketresearchandcompe
+cv-navy_sidebar 0:b088780ed500 0.03 0.09 andinformstrategy.
+cv-navy_sidebar 0:bafd8ea30ebe 0.03 0.10 •createdengagingcontentforblogs,
+cv-navy_sidebar 0:88a47597543f 0.03 0.09 engagementby40%.
cv-navy_sidebar 0:dcf0600524d2 -0.29 0.10 marketingintern
cv-navy_sidebar 0:73a85d4a71b0 0.09 0.18 jan2020-may2020
cv-navy_sidebar 0:e31c3dfebb27 -0.25 0.10 •assistedinplanningandexecutingm
cv-navy_sidebar 0:f7ee178c4b93 -0.25 0.10 •supportedcontentcreationandsoci
cv-navy_sidebar 0:3ffec90452c5 -0.25 0.10 •analyzeddataandpreparedreportst
cv-navy_sidebar 0:6ee18b42fbf1 -0.26 0.10 achievements
-cv-navy_sidebar 0:cff13cc228a6 -0.45 0.10 •increasedwebsitetrafficby60%wit
-cv-navy_sidebar 0:fae41a639f96 -0.45 0.09 strategy.
+cv-navy_sidebar 0:cff13cc228a6 0.05 0.10 •increasedwebsitetrafficby60%wit
+cv-navy_sidebar 0:fae41a639f96 0.05 0.09 strategy.
cv-navy_sidebar 0:b46da9584f4f -0.20 0.10 •launchedasuccessfulemailmarketi
cv-navy_sidebar 0:3654c4d2721e -0.20 0.10 •received“employeeoftheyear”awar
cv-navy_sidebar 0:e831beb04c00 -0.21 0.10 certifications
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index 4f9c5c0eb..1491bdea3 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -9,12 +9,12 @@ cv-compact_mono 1 1 58 58 0.17 0.29 0
cv-editorial_blue 2 2 82 82 0.14 0.30 0
cv-engineering_resume 1 1 57 57 0.26 0.38 0
cv-executive 2 2 60 60 0.26 0.30 0
-cv-midnight_navy 1 1 86 84 0.28 0.87 0
+cv-midnight_navy 1 1 86 84 0.24 0.48 0
cv-minimal_underlined 2 2 59 59 0.21 0.45 0
cv-mint_editorial 2 2 59 59 0.07 0.30 0
cv-modern_professional 2 2 61 59 0.04 0.09 0
cv-monogram_sidebar 1 1 53 50 0.20 0.44 0
-cv-navy_sidebar 1 1 71 71 0.20 0.45 0
+cv-navy_sidebar 1 1 71 71 0.12 0.25 0
cv-nordic_clean 1 1 63 58 0.21 0.38 0
cv-orange_ops 1 2 108 5 77.92 77.92 5
cv-panel 1 1 79 74 0.26 0.55 0
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 c5a8ca599..ed84bded0 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -605,19 +605,19 @@ cv-midnight_navy 0:086c0e036e67 -0.49 -0.57 2021–present
cv-midnight_navy 0:009b5b6479fa -0.52 0.02 starwavesolutions
cv-midnight_navy 0:60cebc1ec80c -0.52 -0.54 newyork,ny
cv-midnight_navy 0:60cebc1ec80c#1 -0.22 -0.54 newyork,ny
-cv-midnight_navy 0:02cf5f2e28f2 -1.06 0.02 ·developandimplementcomprehensiv
-cv-midnight_navy 0:1dfea8c13a62 -0.85 0.10 awarenessby40%andleadgenerationb
-cv-midnight_navy 0:e0ee3cbb7b53 -0.92 0.02 ·manageateamof6marketingprofessi
-cv-midnight_navy 0:8ba85a74e016 -0.86 0.10 anddesignteamstodrivecampaignsuc
-cv-midnight_navy 0:407f710db042 -0.81 0.02 ·overseedigitalcampaignsacrossse
-cv-midnight_navy 0:308aef77a1c3 -0.87 0.10 resultingina25%increaseinroi.
-cv-midnight_navy 0:40f5daa37f9a -0.82 0.02 ·analyzemarkettrendsandcompetito
-cv-midnight_navy 0:af67fd7a2e8b -0.88 0.10 optimizemarketingefforts.
+cv-midnight_navy 0:02cf5f2e28f2 -0.10 0.02 ·developandimplementcomprehensiv
+cv-midnight_navy 0:1dfea8c13a62 -0.01 0.10 awarenessby40%andleadgenerationb
+cv-midnight_navy 0:e0ee3cbb7b53 0.04 0.02 ·manageateamof6marketingprofessi
+cv-midnight_navy 0:8ba85a74e016 -0.02 0.10 anddesignteamstodrivecampaignsuc
+cv-midnight_navy 0:407f710db042 0.03 0.02 ·overseedigitalcampaignsacrossse
+cv-midnight_navy 0:308aef77a1c3 0.09 0.10 resultingina25%increaseinroi.
+cv-midnight_navy 0:40f5daa37f9a 0.02 0.02 ·analyzemarkettrendsandcompetito
+cv-midnight_navy 0:af67fd7a2e8b 0.08 0.10 optimizemarketingefforts.
cv-midnight_navy 0:7e121dceb78a -0.28 0.02 seniormarketingspecialist
cv-midnight_navy 0:f7d1049ec64b -0.31 -0.46 2018–2021
cv-midnight_navy 0:4b8292fda43e -0.22 0.02 brightlinetechnologies
-cv-midnight_navy 0:a246f7ba3f71 -0.76 0.02 ·executedmulti-channelmarketingc
-cv-midnight_navy 0:58b162e46dda -0.70 0.10 engagementby30%.
+cv-midnight_navy 0:a246f7ba3f71 0.20 0.02 ·executedmulti-channelmarketingc
+cv-midnight_navy 0:58b162e46dda 0.14 0.10 engagementby30%.
cv-midnight_navy 0:ef77f2daef0f -0.16 0.02 ·managedsocialmediachannelsandco
cv-midnight_navy 0:a112a7b3c16b -0.10 0.02 ·coordinatedwithcreativeteamstod
cv-midnight_navy 0:990b9f22bb48 -0.10 0.02 marketingcoordinator
@@ -911,28 +911,28 @@ cv-navy_sidebar 0:8a39b996b27c 0.13 0.07 jan2022-present
cv-navy_sidebar 0:f97eeb2fe120 -0.15 0.03 brightwavesolutions,newyork,ny
cv-navy_sidebar 0:f97eeb2fe120#1 -0.17 0.03 brightwavesolutions,newyork,ny
cv-navy_sidebar 0:f97eeb2fe120#2 -0.21 0.03 brightwavesolutions,newyork,ny
-cv-navy_sidebar 0:a4beef0c22d9 -0.40 0.03 •developandimplementintegratedma
-cv-navy_sidebar 0:234eaa6417f7 -0.43 0.06 awarenessby35%andleadgenerationb
-cv-navy_sidebar 0:dfd245470d6e -0.41 0.03 •manageateamof4marketingspeciali
-cv-navy_sidebar 0:94d0b8d13763 -0.44 0.06 anddesignteamstoalignstrategies.
-cv-navy_sidebar 0:5dabf21df91b -0.30 0.03 •analyzecampaignperformanceusing
-cv-navy_sidebar 0:1cb810ca6189 -0.33 0.06 optimizeroiandreporting.
+cv-navy_sidebar 0:a4beef0c22d9 0.08 0.03 •developandimplementintegratedma
+cv-navy_sidebar 0:234eaa6417f7 0.17 0.06 awarenessby35%andleadgenerationb
+cv-navy_sidebar 0:dfd245470d6e 0.19 0.03 •manageateamof4marketingspeciali
+cv-navy_sidebar 0:94d0b8d13763 0.16 0.06 anddesignteamstoalignstrategies.
+cv-navy_sidebar 0:5dabf21df91b 0.18 0.03 •analyzecampaignperformanceusing
+cv-navy_sidebar 0:1cb810ca6189 0.15 0.06 optimizeroiandreporting.
cv-navy_sidebar 0:3b172b9ab75e -0.20 0.03 marketingspecialist
cv-navy_sidebar 0:d514872487af 0.10 0.00 jun2020-dec2021
-cv-navy_sidebar 0:a13f57f4d861 -0.45 0.03 •executeddigitalmarketingcampaig
-cv-navy_sidebar 0:4b308a27b878 -0.36 0.06 channels.
-cv-navy_sidebar 0:39b87fda4a82 -0.34 0.03 •conductedmarketresearchandcompe
-cv-navy_sidebar 0:b088780ed500 -0.35 0.06 andinformstrategy.
-cv-navy_sidebar 0:bafd8ea30ebe -0.33 0.03 •createdengagingcontentforblogs,
-cv-navy_sidebar 0:88a47597543f -0.36 0.06 engagementby40%.
+cv-navy_sidebar 0:a13f57f4d861 0.03 0.03 •executeddigitalmarketingcampaig
+cv-navy_sidebar 0:4b308a27b878 0.12 0.06 channels.
+cv-navy_sidebar 0:39b87fda4a82 0.14 0.03 •conductedmarketresearchandcompe
+cv-navy_sidebar 0:b088780ed500 0.13 0.06 andinformstrategy.
+cv-navy_sidebar 0:bafd8ea30ebe 0.15 0.03 •createdengagingcontentforblogs,
+cv-navy_sidebar 0:88a47597543f 0.12 0.06 engagementby40%.
cv-navy_sidebar 0:dcf0600524d2 -0.23 0.03 marketingintern
cv-navy_sidebar 0:73a85d4a71b0 0.07 0.07 jan2020-may2020
cv-navy_sidebar 0:e31c3dfebb27 -0.13 0.03 •assistedinplanningandexecutingm
cv-navy_sidebar 0:f7ee178c4b93 -0.11 0.03 •supportedcontentcreationandsoci
cv-navy_sidebar 0:3ffec90452c5 -0.09 0.03 •analyzeddataandpreparedreportst
cv-navy_sidebar 0:6ee18b42fbf1 -0.20 0.03 achievements
-cv-navy_sidebar 0:cff13cc228a6 -0.34 0.03 •increasedwebsitetrafficby60%wit
-cv-navy_sidebar 0:fae41a639f96 -0.37 0.06 strategy.
+cv-navy_sidebar 0:cff13cc228a6 0.14 0.03 •increasedwebsitetrafficby60%wit
+cv-navy_sidebar 0:fae41a639f96 0.11 0.06 strategy.
cv-navy_sidebar 0:b46da9584f4f -0.07 0.03 •launchedasuccessfulemailmarketi
cv-navy_sidebar 0:3654c4d2721e -0.01 0.03 •received“employeeoftheyear”awar
cv-navy_sidebar 0:e831beb04c00 -0.15 0.03 certifications
diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv
index d5be57e0c..8e185198c 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv
@@ -9,12 +9,12 @@ cv-compact_mono 1 1 58 58 0.15 0.30 0
cv-editorial_blue 2 2 82 82 0.08 0.21 0
cv-engineering_resume 1 1 57 57 0.22 0.34 0
cv-executive 2 2 60 60 0.17 0.29 0
-cv-midnight_navy 1 1 86 84 0.22 0.82 0
+cv-midnight_navy 1 1 86 84 0.16 0.49 0
cv-minimal_underlined 2 2 59 59 0.21 0.50 0
cv-mint_editorial 2 2 59 59 0.08 0.19 0
cv-modern_professional 2 2 61 59 0.15 0.21 0
cv-monogram_sidebar 1 1 53 50 0.19 0.39 0
-cv-navy_sidebar 1 1 71 69 0.13 0.36 0
+cv-navy_sidebar 1 1 71 69 0.11 0.20 0
cv-nordic_clean 1 1 63 58 0.16 0.35 0
cv-orange_ops 1 1 108 106 0.22 0.47 0
cv-panel 1 1 79 74 0.31 0.63 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 01c6c0a54..09874692f 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
@@ -276,6 +276,10 @@ public final class DocxSemanticBackend implements SemanticBackend {
// (writePanelPiece); a stack written as columns does not, and that much still makes its
// table taller.
private double cellOverhang;
+ // The spaces grown to lower a list's items that hang below the cell written last with that
+ // (lowerIntoTheSpaceAbove): a row or a panel hands them on with what of it hangs below itself,
+ // a writer that lets the rest go takes them back (letTheLoweringGo).
+ private List cellLoweringHung = new ArrayList<>();
// The border below a panel that the space above the paragraph hangingOver opens did not take,
// in points, named with what that paragraph's own top edge leaves of the hang, on the node
// the paragraph is written for (nameTheTailNotTaken).
@@ -544,6 +548,96 @@ public final class DocxSemanticBackend implements SemanticBackend {
/** The page the layout set the last item of the list being written on; -1 before its first. */
private int itemPage = -1;
+ /**
+ * The spaces above items of the list being written that were grown to lower their lines
+ * ({@link #lowerIntoTheSpaceAbove}) and that the items under them have not given back yet,
+ * the first grown first: the box of the item written last, and all under it, stands their sum
+ * lower than the page sets it.
+ */
+ private java.util.ArrayDeque itemsLowered = new java.util.ArrayDeque<>();
+
+ /**
+ * What nothing in the lists written last gave back, each list's on its own, held below them
+ * as a line hanging below a block is ({@link #hangingBelow}): kept where a block on the same
+ * page takes that hang, taken back where it is let go ({@link #takeTheLowering},
+ * {@link #letTheLoweringGo}).
+ */
+ private List loweringHung = new ArrayList<>();
+
+ /**
+ * A paragraph's space above, grown to lower its line.
+ *
+ * @param spacing the paragraph's spacing
+ * @param twips how much it was grown by
+ */
+ private record GrownSpace(CTSpacing spacing, long twips) {
+ }
+
+ /**
+ * The spaces grown to lower a list's items that its last item hangs below the list with, how
+ * far that hang still reaches past what was given back of it, and the page the list ends on.
+ */
+ private static final class HungLowering {
+ private final java.util.ArrayDeque spaces;
+ private long hang;
+ private final int page;
+
+ private HungLowering(java.util.ArrayDeque spaces, long hang, int page) {
+ this.spaces = spaces;
+ this.hang = hang;
+ this.page = page;
+ }
+ }
+
+ /** Counts {@code twips} of the space under each list in {@code hung} as giving its hang back. */
+ private static void giveToTheLowering(List hung, long twips) {
+ for (HungLowering lowering : hung) {
+ lowering.hang = Math.max(0, lowering.hang - Math.max(0, twips));
+ }
+ hung.removeIf(lowering -> lowering.hang == 0);
+ }
+
+ /**
+ * Settles what the lists in {@code hung} hang below themselves where a block takes that hang:
+ * kept lowered where the block is on the page a list ends on — its space above gives the hang
+ * back, and it names what that space does not give as standing it lower — and taken back where
+ * the block is on a later page, whose top Word sets it at, giving nothing back: the page above
+ * would run that much longer.
+ *
+ * @param page the page the block starts on, or -1 where the layout does not say
+ * @return whether the block is on a later page than a list: it then takes nothing of the hang
+ * below the page above, as a block Word moves to a new page does ({@link #forgetTheHang})
+ */
+ private static boolean takeTheLowering(List hung, int page) {
+ boolean later = false;
+ for (HungLowering lowering : hung) {
+ if (page >= 0 && lowering.page >= 0 && page > lowering.page) {
+ takeBack(lowering.spaces, lowering.hang);
+ later = true;
+ }
+ }
+ hung.clear();
+ return later;
+ }
+
+ /**
+ * Takes back what the lists in {@code hung} hang below themselves past {@code given} twips,
+ * where the hang is let go: nothing past that gives it back, and it would stand all that
+ * follows lower, unnamed.
+ */
+ private static void letTheLoweringGo(List hung, long given) {
+ for (HungLowering lowering : hung) {
+ takeBack(lowering.spaces, Math.max(0, lowering.hang - Math.max(0, given)));
+ }
+ hung.clear();
+ }
+
+ /** The page the layout starts {@code node} on, or -1 where it does not say. */
+ private int startPageOf(DocumentNode node) {
+ com.demcha.compose.document.layout.PlacedNode placed = node == null ? null : layout.placement(node);
+ return placed == null ? -1 : placed.startPage();
+ }
+
/** Where the list being written starts its items on the page, in points; NaN unknown. */
private double listTextLeft = Double.NaN;
/** Whether the layout's items are matched to the list's, one by one: each then has its place. */
@@ -849,6 +943,9 @@ private byte[] write(List sections, Path outputFile) throws Exc
oneLayerDepth = 0;
roomHeldAround = 0;
openOverlays.clear();
+ // An earlier export's lowerings are its own document's.
+ loweringHung = new ArrayList<>();
+ cellLoweringHung = new ArrayList<>();
forgetTheHang();
raisedRows.clear();
insetLeft = 0;
@@ -4277,10 +4374,13 @@ private void writeList(XWPFDocument document,
boolean previousOpensItsPage = listOpensItsPage;
boolean previousOnOnePage = listOnOnePage;
int previousItemPage = itemPage;
- // Where the layout set the list's items, for seating them (seatTheItem).
- listOpensItsPage = currentCell == null && overlayDepth == 0 && startsAPageOfItsOwn(list);
+ java.util.ArrayDeque previousLowered = itemsLowered;
+ // Where the layout set the list's items, for seating them (seatTheItem). Stacks of one
+ // layer only are the flow (lowerIntoTheSpaceAbove).
+ listOpensItsPage = currentCell == null && overlayDepth == oneLayerDepth && startsAPageOfItsOwn(list);
listOnOnePage = layout.onOnePage(list);
itemPage = -1;
+ itemsLowered = new java.util.ArrayDeque<>();
java.util.ArrayDeque previousItemLines =
listItemLines;
// The list's lineSpacing stands between the lines of an item that wraps, and only
@@ -4315,6 +4415,13 @@ private void writeList(XWPFDocument document,
int atTheirColumn;
try {
atTheirColumn = writeListItems(document, list, numId);
+ // What the last items still stand lower, the space above what follows gives back, as
+ // it gives a line hanging below the block above it: the line reaches that much further.
+ long left = grownBy(itemsLowered);
+ if (left > 0) {
+ hangingBelow += left / POINT_TO_TWIP;
+ loweringHung.add(new HungLowering(new java.util.ArrayDeque<>(itemsLowered), left, itemPage));
+ }
} finally {
insetLeft = outerLeft;
insetRight = outerRight;
@@ -4324,6 +4431,7 @@ private void writeList(XWPFDocument document,
listOpensItsPage = previousOpensItsPage;
listOnOnePage = previousOnOnePage;
itemPage = previousItemPage;
+ itemsLowered = previousLowered;
listItemLines = previousItemLines;
listTextLeft = previousTextLeft;
listItemsMatched = previousMatched;
@@ -4848,8 +4956,10 @@ private void writeListLine(XWPFDocument document, DocumentTextStyle style,
* is half a point or more, and a line set that much high, is the text's position — but not for
* an item whose marker Word draws: Word sets that marker from the paragraph's mark, and
* LibreOffice does not move it by the mark's position — {@code MidnightNavy}'s duties, lowered a
- * point by their text's and their mark's, stood 1.15pt under their bullets there (measured). Its
- * line keeps what the space above does not take of it.
+ * point by their text's and their mark's, stood 1.15pt under their bullets there (measured). Set
+ * low, its line keeps what the space above does not take of it; set high, it is lowered by the
+ * space above it instead, marker and text together, as far as the items under it and what
+ * follows give that back ({@link #lowerIntoTheSpaceAbove}).
*
* Not raised into the space above, though still moved by its position where it takes one: an
* item the layout opens a page with — the first of a list it starts on a new page in the flow, or
@@ -4872,12 +4982,132 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut,
boolean opensItsPage = itemPage < 0 ? listOpensItsPage : laidOut.page() > itemPage;
itemPage = laidOut.page();
boolean staysOnItsPage = !opensItsPage && (currentCell == null || listOnOnePage || inARowKeptWhole());
+ if (opensItsPage) {
+ // Word sets the first line of a page at its top: nothing on the page above gives back
+ // what the items there were lowered by, which would run that page that much longer.
+ takeBack(itemsLowered, grownBy(itemsLowered));
+ }
+ giveBack(itemsLowered, giveBackFromTheSpaceAbove(para, grownBy(itemsLowered)));
double shift = shiftToThePagesBaseline(para, laidOut.lines(), listLineGap, 0, false, true, staysOnItsPage);
- if (!wordDrawsTheMarker) {
+ if (wordDrawsTheMarker) {
+ lowerIntoTheSpaceAbove(para, staysOnItsPage, -shift);
+ } else {
+ // Its marker is its text, and its position moves the two together.
raiseRunsFrom(para, 0, Math.round(shift * HALF_POINTS_PER_POINT));
}
}
+ /** How far the spaces in {@code grown} were grown, together, in twips. */
+ private static long grownBy(java.util.Deque grown) {
+ long twips = 0;
+ for (GrownSpace space : grown) {
+ twips += space.twips();
+ }
+ return twips;
+ }
+
+ /** Counts {@code twips} given back of what the spaces in {@code grown} were grown by, the first first. */
+ private static void giveBack(java.util.Deque grown, long twips) {
+ while (twips > 0 && !grown.isEmpty()) {
+ GrownSpace first = grown.pollFirst();
+ if (first.twips() > twips) {
+ grown.addFirst(new GrownSpace(first.spacing(), first.twips() - twips));
+ return;
+ }
+ twips -= first.twips();
+ }
+ }
+
+ /**
+ * Shrinks the spaces in {@code grown} back by up to {@code twips} together, the last grown
+ * first: the lines under them stand as high as Word sets them, and what follows where it
+ * stood before they were lowered.
+ *
+ * @return how much they were shrunk by, in twips
+ */
+ private static long takeBack(java.util.Deque grown, long twips) {
+ long taken = 0;
+ while (taken < twips && !grown.isEmpty()) {
+ GrownSpace last = grown.pollLast();
+ long now = Math.min(last.twips(), twips - taken);
+ last.spacing().setBefore(BigInteger.valueOf(Math.max(0, twipsOf(last.spacing().getBefore()) - now)));
+ if (now < last.twips()) {
+ grown.addLast(new GrownSpace(last.spacing(), last.twips() - now));
+ }
+ taken += now;
+ }
+ return taken;
+ }
+
+ /**
+ * Takes up to {@code twips} off the space written above a paragraph, as far as it goes.
+ *
+ * @return how much it took, in twips
+ */
+ private static long giveBackFromTheSpaceAbove(XWPFParagraph para, long twips) {
+ CTPPr properties = para.getCTP().getPPr();
+ if (twips <= 0 || properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetBefore()) {
+ return 0;
+ }
+ CTSpacing spacing = properties.getSpacing();
+ long before = twipsOf(spacing.getBefore());
+ long given = Math.max(0, Math.min(before, twips));
+ if (given > 0) {
+ spacing.setBefore(BigInteger.valueOf(before - given));
+ }
+ return given;
+ }
+
+ /**
+ * Lowers the line of an item whose marker Word draws, set high, by growing the space written
+ * above it: its text and its marker move down together, in both editors, where a position
+ * would move the text alone in LibreOffice ({@link #seatTheItem}).
+ *
+ * A line moved down takes its room out of the space below it ({@link #itemsLowered}). The
+ * items under it give it back from the space above them, one standing as high taking it back
+ * by its own lowering; what is left at the list's end hangs below the list, and the space
+ * above what follows takes it as it takes a line hanging below the block above it
+ * ({@link #hangingBelow}), what that space and that block's own top edge cannot give named as
+ * standing it lower ({@link #nameTheTailNotTaken}). At the end of a cell the space the cell
+ * owes under the list takes it, and what hangs past that a row's room or a panel's padding,
+ * as they take any line hanging there ({@link #cellOverhang}), each list's on its own
+ * ({@link HungLowering}). Where nothing takes it — what hangs below a table's composed cell
+ * or a layer stack's column, whose writers let it go, or a row's cell whose room the layout
+ * does not say ({@link #takeBackWhatTheCellHangs}), a column's later layer, whose place is the
+ * page's ({@link #resumeHere}), a block on a later page than the list ({@link #takeTheLowering}),
+ * an item set on a later page than the one before, the section's end — the lowering is taken
+ * back as far as it is not given, those lines standing as high as Word sets them and what
+ * follows where it stood: Word sets a page's first line at its top, and the page above would
+ * run that much longer.
+ *
+ * Only where its position would have moved it, half a point and more, and only an item
+ * whose space above stays above it on its page, as for a raise
+ * ({@link #raiseIntoTheSpaceAbove}). Not an item opening a panel, whose top border is taken
+ * inside that space ({@link #takeTheTopBorderInside}). Not in an overlay — a stack laying one
+ * layer over another, a band, a shape container, a canvas, text over the flow — whose writer
+ * measures what follows from the page; a stack of one layer is written in the flow, laying
+ * nothing over anything, and its items are lowered as there: {@code MidnightNavy} and
+ * {@code NavySidebar} wrap each job in one. A raise is not made there either way.
+ *
+ * @param lower how far the line's position would lower it, in points: 0 or less where the
+ * whole difference is under half a point ({@link #shiftToThePagesBaseline}), or
+ * the line is set low
+ */
+ private void lowerIntoTheSpaceAbove(XWPFParagraph para, boolean staysOnItsPage, double lower) {
+ // A panel's top border is taken inside its first line's space above (takeTheTopBorderInside).
+ if (!(lower > 0) || overlayDepth > oneLayerDepth || !staysOnItsPage || opensThePanel(para)) {
+ return;
+ }
+ // What the items above owe stands it that much lower already. A line set at an exact
+ // height, as one with a position to take is, has its spacing written.
+ long more = Math.max(0, Math.round(lower * POINT_TO_TWIP) - grownBy(itemsLowered));
+ if (more > 0) {
+ CTSpacing spacing = para.getCTP().getPPr().getSpacing();
+ spacing.setBefore(BigInteger.valueOf(twipsOf(spacing.isSetBefore() ? spacing.getBefore() : null) + more));
+ itemsLowered.addLast(new GrownSpace(spacing, more));
+ }
+ }
+
/**
* Sets a list item's lines in a measure as much wider or narrower than the page's as Word
* sets its text, as {@link #measureAtWordsSize} does a paragraph's.
@@ -5610,6 +5840,11 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
double carried = takeTheCarriedBorder(heldWhole ? 0 : carriedBorder);
double fromThePaddingBelow = takeOffTheCellMargin(cell, false, hang + carried);
hangingBelow = Math.max(hangingBelow, hang - fromThePaddingBelow);
+ // A list's items lowered into the hang: what the padding took of it is given back,
+ // the rest hangs below the panel with it.
+ giveToTheLowering(cellLoweringHung, Math.round(Math.min(hang, fromThePaddingBelow) * POINT_TO_TWIP));
+ loweringHung.addAll(cellLoweringHung);
+ cellLoweringHung = new ArrayList<>();
carried = Math.max(0, carried - Math.max(0, fromThePaddingBelow - hang));
// Its own border below reaches past its box as well, and a margin below zero pulls what
// follows up, as a paragraph's does: neither is the other.
@@ -5623,6 +5858,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container
// What of the border the space below does not take, the padding left takes, at the
// border's place (nameTheTailNotTaken).
borderBelowCell = cell;
+ } else {
+ // A piece the panel goes on from on the next page lets what hangs below it go.
+ takeBackWhatTheCellHangs();
}
cellOverhang = 0;
}
@@ -6832,6 +7070,12 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) {
// Text that hung below the band above this paragraph already took that much of the
// gap (see writeLinePair); whatever the owed space cannot give back, the paragraph's
// own margin gives (applyVerticalSpacing).
+ // A list's items lowered into it stay lowered on the page they end, what this paragraph
+ // does not give back standing it lower, named with the rest (lowerIntoTheSpaceAbove);
+ // a paragraph on a later page takes nothing of the hang left on the page above.
+ if (takeTheLowering(loweringHung, startPageOf(writing))) {
+ hangingBelow = 0;
+ }
double overhang = hangingBelow;
hangingBelow = 0;
double taken = Math.min(Math.max(0, above), overhang);
@@ -6904,6 +7148,9 @@ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room, b
pendingSpacingAfter = 0;
carriedSpacingBefore = 0;
pullBelow = 0;
+ // Nothing under the section's last list gives back what its items were lowered by, and
+ // its last page would run that much longer.
+ letTheLoweringGo(loweringHung, 0);
List body = document.getBodyElements();
if (!body.isEmpty() && body.get(body.size() - 1) instanceof XWPFTable table) {
dropTheSpaceBelow(table);
@@ -9984,8 +10231,10 @@ private double shiftToThePagesBaseline(XWPFParagraph para,
*
* Only a raise: a line moved down takes its room out of the space below it, which is not
* known until what follows is written, and what that space could not take would stand all of
- * it lower. Only a line of its own, as its caller raises: not a line pair's, a container's
- * stacked lines, a text box's, nor one cut to its pictures. Only a paragraph in the flow or in
+ * it lower — but for a Word list's item, which no position lowers in both editors
+ * ({@link #lowerIntoTheSpaceAbove}). Only a line of its own, as its caller raises: not a line
+ * pair's, a container's stacked lines, a text box's, nor one cut to its pictures. Only a
+ * paragraph in the flow or in
* a cell, where the space owed below it is the space above what follows: a band, a layer
* stack, a shape container, a canvas and the text laid over the flow — the overlays — measure
* what follows them from the page ({@link #writeOverlayBand}), and the space a line owed
@@ -12768,6 +13017,9 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
}
try {
writeCellBody(cell, source.content());
+ // The layout does not say how tall the cell's content is against its row: what
+ // hangs below it goes, and the items lowered into it stand as Word sets them.
+ takeBackWhatTheCellHangs();
} finally {
currentCellWidth = previous;
composedCellBoxes = outerBoxes;
@@ -13441,6 +13693,7 @@ private void writeRow(XWPFDocument document, RowNode node, boolean inItsPanel) t
double rowReach = 0;
double rowOverhang = 0;
double rowBorder = 0;
+ List rowLowered = new ArrayList<>();
// A row's cells are unshaded: inside a panel — its own, where it paints (writePaintedRow), or
// a container's — it is a table nested in the panel's cell, and shows the panel's through them.
for (int i = 0; i < node.children().size(); i++) {
@@ -13468,6 +13721,16 @@ private void writeRow(XWPFDocument document, RowNode node, boolean inItsPanel) t
double roomBelow = roomBelowInItsRow(child, placedRow, node);
boolean told = !Double.isInfinite(roomBelow);
double overhang = told ? Math.max(0, cellOverhang - roomBelow) : 0;
+ // A list's items lowered into the cell's hang: what the room under the cell takes is
+ // given back, the rest hangs below the row with it; lowered into a line only taken to
+ // fit, they are taken back.
+ if (!told) {
+ takeBackWhatTheCellHangs();
+ } else {
+ giveToTheLowering(cellLoweringHung, Math.round(roomBelow * POINT_TO_TWIP));
+ rowLowered.addAll(cellLoweringHung);
+ cellLoweringHung = new ArrayList<>();
+ }
// What of the border the room does not hold, the card's padding takes first.
double border = takeTheCarriedBorder(
Math.max(0, carriedBorder - (told ? Math.max(0, roomBelow - cellOverhang) : 0)));
@@ -13501,6 +13764,8 @@ private void writeRow(XWPFDocument document, RowNode node, boolean inItsPanel) t
// the page under the header with it. A card's border below the last thing in a cell makes
// the row taller too, after the line: Word's row is as tall as the cell reaching furthest.
hangingBelow = Math.max(hangingBelow, rowOverhang);
+ // Each cell's list keeps its own hang: the row's is the furthest of them.
+ loweringHung.addAll(rowLowered);
}
/**
@@ -13632,6 +13897,8 @@ private void drawLayersOverTheColumns(XWPFDocument document, List
double carried = carriedSpacingBefore;
double owed = pendingSpacingAfter;
double hanging = hangingBelow;
+ List loweringHanging = loweringHung;
+ loweringHung = new ArrayList<>();
double pull = pullBelow;
double outerLeft = insetLeft;
double outerRight = insetRight;
@@ -13649,6 +13916,7 @@ private void drawLayersOverTheColumns(XWPFDocument document, List
carriedSpacingBefore = carried;
pendingSpacingAfter = owed;
hangingBelow = hanging;
+ loweringHung = loweringHanging;
pullBelow = pull;
insetLeft = outerLeft;
insetRight = outerRight;
@@ -13777,6 +14045,8 @@ < availableWidth() - EDITOR_COLUMN_SLACK_POINTS - 0.5) {
currentCellWidth = previous;
resumeSpacing = Double.NaN;
}
+ // The columns' row reads nothing of what hangs below a column (cellOverhang).
+ takeBackWhatTheCellHangs();
if (!layers.isEmpty()) {
nameTheBorderEndingACell(layers.get(layers.size() - 1), columns.size() == 1);
}
@@ -13836,6 +14106,10 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay
// paragraph or a table there does.
double hang = currentCell == null && overlayDepth == 0 && startsAPageOfItsOwn(stack) ? 0 : hangingBelow;
resumeSpacing = above - Math.min(Math.max(0, above), hang);
+ // A list's items lowered into it are lowered as far as that space takes it, and the
+ // rest taken back, as all of it is where the band opens a page (lowerIntoTheSpaceAbove).
+ giveToTheLowering(loweringHung, Math.round(Math.min(Math.max(0, above), hang) * POINT_TO_TWIP));
+ letTheLoweringGo(loweringHung, 0);
}
carriedSpacingBefore = 0;
pendingSpacingAfter = 0;
@@ -15136,6 +15410,11 @@ private XWPFTable newTable(XWPFDocument document, int rows, int columns) {
double pullNotGiven = Math.max(0, pullBelow - Math.max(0, pendingSpacingAfter));
pendingSpacingAfter = Math.max(0, pendingSpacingAfter - pullBelow);
pullBelow = 0;
+ // A table on a later page than the lists above it takes nothing of the hang they left on
+ // the page above, their lowering taken back (lowerIntoTheSpaceAbove).
+ if (takeTheLowering(loweringHung, startPageOf(writing))) {
+ hangingBelow = 0;
+ }
double hangNotTaken = Math.max(0, hangingBelow - pendingSpacingAfter);
double borderNotTaken = Math.max(0, borderBelow - Math.max(0, pendingSpacingAfter - hangingBelow));
pendingSpacingAfter = Math.max(0, pendingSpacingAfter - hangingBelow - borderBelow);
@@ -15523,6 +15802,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
double previousBorderBelow = borderBelow;
XWPFTableCell previousBorderBelowCell = borderBelowCell;
double previousHangingBelow = hangingBelow;
+ List previousLoweringHung = loweringHung;
XWPFParagraph previousHangingOver = hangingOver;
double previousHangingOverBy = hangingOverBy;
double previousBorderNotTakenOver = borderNotTakenOver;
@@ -15541,6 +15821,8 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
pullBelow = 0;
borderBelow = 0;
borderBelowCell = null;
+ // The hang outside the cell is the writer's around it, put back after it.
+ loweringHung = new ArrayList<>();
forgetTheHang();
pullLeftOn = null;
pullLeft = 0;
@@ -15552,6 +15834,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
cellTextShift = cellHang;
cellHang = 0;
double overhang = 0;
+ List lowered = new ArrayList<>();
try {
content.write();
// A cell of space and nothing written — padding round a drawing the export keeps
@@ -15571,6 +15854,10 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
double taken = Math.min(Math.max(0, pendingSpacingAfter - pullBelow), hang);
pendingSpacingAfter -= taken;
overhang = hang - taken;
+ // A list's items lowered into that hang, past what the cell owes under them, hang
+ // below the cell with it, for the row or the panel holding the cell to take.
+ giveToTheLowering(loweringHung, Math.round(taken * POINT_TO_TWIP));
+ lowered = loweringHung;
// A cell ends where it ends: its last gap cannot land on whatever the body
// writes next, and the body's cannot land inside it.
flushSpacingAfter();
@@ -15580,6 +15867,7 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
carriedSpacingBefore = previousCarried;
pendingSpacingAfter = previousOwed;
pullBelow = previousPull;
+ loweringHung = previousLoweringHung;
// A card's border below the last thing in a cell reaches past the cell too, where
// Word makes the row as tall as its tallest cell: the writer of the cell says how far
// past its row, which it alone knows. The cell beside it starts clear.
@@ -15600,6 +15888,17 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
tableCloser = previousCloser;
}
cellOverhang = overhang;
+ cellLoweringHung = lowered;
+ }
+
+ /**
+ * Takes back what a list's items were lowered into the hang below the cell written last, where
+ * the writer of that cell lets it go ({@link #cellOverhang}): a table's composed cell, a column
+ * of a layer stack.
+ */
+ private void takeBackWhatTheCellHangs() {
+ letTheLoweringGo(cellLoweringHung, 0);
+ cellLoweringHung = new ArrayList<>();
}
/** Whether anything under a node was written in an earlier layer's stand-in. */
@@ -15738,6 +16037,9 @@ private void writeSpacer(XWPFDocument document, SpacerNode node) throws Exceptio
/** Clears the text hanging below a band: it reaches the next block only (see {@link #writeLinePair}). */
private void forgetTheHang() {
+ // A hang dropped gives nothing back: a list's items lowered into it stand as high as
+ // before (lowerIntoTheSpaceAbove).
+ letTheLoweringGo(loweringHung, 0);
hangingBelow = 0;
hangingOver = null;
hangingOverBy = 0;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java
index 5969ce49b..ffc042f4e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java
@@ -19,6 +19,7 @@
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
/**
* A list composed in a table's cell is written on the lines its table laid out for it, as a
@@ -47,18 +48,25 @@ void aListInACellIsWrittenAtTheHeightThePageSetsItsLines() throws Exception {
@Test
void aWrappedItemInACellTakesTheListsLineSpacingBetweenItsLines() throws Exception {
- // As in the flow: the gap goes into the item's line and comes off the space above it.
+ // As in the flow: the gap goes into the item's line and comes off the space above it. Its
+ // lines then stand high in Word, and the space above lowers them, which the item under it
+ // gives back.
String wrapped = "Training that runs long enough to wrap onto a second line of the cell";
try (XWPFDocument document = inACellBeside(new ListBuilder().name("Items").bullet().itemSpacing(3).lineSpacing(2)
.textStyle(size(9)).items("Setup", wrapped, "Support").build())) {
XWPFParagraph one = inACell(document, "Setup");
XWPFParagraph two = inACell(document, wrapped);
XWPFParagraph three = inACell(document, "Support");
+ long lowered = before(two) - (60 - 40);
+ // Word's lines are the gap taller than the page's, and it seats their middle four
+ // fifths of the way down; the page sets the first line Lato's ascent below its top.
+ double line = DocxTwips.of(spacing(two).getLine()) / 20.0;
assertThat(DocxTwips.of(spacing(two).getLine()) - DocxTwips.of(spacing(one).getLine()))
.as("2pt between its lines").isEqualTo(40);
- assertThat(before(two)).as("3pt above it, less the gap").isEqualTo(60 - 40);
- assertThat(before(three)).isEqualTo(60);
+ assertThat(lowered).as("3pt above it, less the gap, and its lowering")
+ .isCloseTo(Math.round((2 + 0.987 * 9 - 0.8 * line) * 20), within(1L)).isPositive();
+ assertThat(before(three)).as("3pt above it, less what the item above took").isEqualTo(60 - lowered);
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnsTest.java
index ee2b568e8..7e1c41d35 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumnsTest.java
@@ -100,6 +100,79 @@ void aLaterLayerStandsBelowTheLineAboveItAsFarAsThatLineWasRaised() throws Excep
}
}
+ @Test
+ void aLaterLayerTakesBackTheLoweringOfTheListEndingTheLayerAboveIt() throws Exception {
+ // The first layer ends with a Word list's item that Word sets high, lowered by the space
+ // above it. The later layer measures the gap to itself from the page, which gives none of
+ // it back: the item stands as Word sets it, and the later layer where it stood.
+ com.demcha.compose.document.style.DocumentTextStyle lato24 = com.demcha.compose.document.style
+ .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.LATO).size(24).build();
+ com.demcha.compose.document.style.DocumentTextStyle lato = com.demcha.compose.document.style
+ .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.LATO).size(10).build();
+ try (Export export = export(stack -> stack
+ .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addList(list -> list.name("Duties")
+ .textStyle(lato24).margin(DocumentInsets.top(20)).items("Heading"))), LayerAlign.TOP_LEFT)
+ .layer(column("Sidebar", 0, MAIN, side -> side.addParagraph("Contact")), LayerAlign.TOP_LEFT)
+ .layer(column("MainLayer", SIDEBAR, 0, main -> main
+ .addSpacer(spacer -> spacer.name("TitlePlace").width(100).height(80))
+ .addParagraph(p -> p.name("Role").text("Engineer").textStyle(lato))), LayerAlign.TOP_LEFT))) {
+ XWPFTableCell main = export.document().getTables().get(0).getRow(0).getCell(1);
+ XWPFParagraph heading = main.getParagraphs().get(0);
+ PlacedNode duties = export.placed("Duties");
+ PlacedNode role = export.placed("Role");
+ double gap = duties.placementY() - (role.placementY() + role.placementHeight());
+
+ assertThat(main.getParagraphs()).extracting(XWPFParagraph::getText).containsExactly("Heading", "Engineer");
+ assertThat(heading.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(spacingBefore(heading)).as("its space above, as written").isEqualTo(20 * 20);
+ assertThat(spacingBefore(main.getParagraphs().get(1)))
+ .isCloseTo(Math.round(gap * 20), org.assertj.core.data.Offset.offset(2L));
+ }
+ // A paragraph under the list in its own layer gives the lowering back.
+ try (Export export = export(stack -> stack
+ .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addList(list -> list.name("Duties")
+ .textStyle(lato24).margin(DocumentInsets.top(20)).items("Heading"))
+ .addParagraph(p -> p.text("Under").textStyle(lato).margin(DocumentInsets.top(10)))),
+ LayerAlign.TOP_LEFT)
+ .layer(column("Sidebar", 0, MAIN, side -> side.addParagraph("Contact")), LayerAlign.TOP_LEFT)
+ .layer(column("MainLayer", SIDEBAR, 0, main -> main
+ .addSpacer(spacer -> spacer.name("TitlePlace").width(100).height(120))
+ .addParagraph(p -> p.name("Role").text("Engineer").textStyle(lato))), LayerAlign.TOP_LEFT))) {
+ XWPFParagraph heading = export.document().getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0);
+
+ assertThat(heading.getText()).as("the premise: the column opens with the item").isEqualTo("Heading");
+ assertThat(spacingBefore(heading)).as("lowered").isGreaterThan(20 * 20 + 10);
+ }
+ }
+
+ @Test
+ void aColumnEndingWithAListTakesItsLoweringBack() throws Exception {
+ // Nothing in the sidebar's cell stands under its list, and the columns' row reads nothing of
+ // what hangs below a column: the item stands as Word sets it, the row as tall as before.
+ com.demcha.compose.document.style.DocumentTextStyle lato24 = com.demcha.compose.document.style
+ .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.LATO).size(24).build();
+ try (Export export = export(stack -> stack
+ .layer(column("Sidebar", 0, MAIN, side -> side.addList(list -> list.textStyle(lato24)
+ .margin(DocumentInsets.top(10)).items("Heading"))), LayerAlign.TOP_LEFT)
+ .layer(column("Main", SIDEBAR, 0, main -> main.addParagraph(LONG)), LayerAlign.TOP_LEFT))) {
+ XWPFParagraph heading = export.document().getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
+
+ assertThat(heading.getText()).as("the premise: the sidebar holds the item").isEqualTo("Heading");
+ assertThat(heading.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(spacingBefore(heading)).as("its space above, as written").isEqualTo(10 * 20);
+ }
+ // A paragraph under the list in the same column gives the lowering back.
+ try (Export export = export(stack -> stack
+ .layer(column("Sidebar", 0, MAIN, side -> side.addList(list -> list.textStyle(lato24)
+ .margin(DocumentInsets.top(10)).items("Heading")).addParagraph(p -> p.text("Under")
+ .margin(DocumentInsets.top(10)))), LayerAlign.TOP_LEFT)
+ .layer(column("Main", SIDEBAR, 0, main -> main.addParagraph(LONG)), LayerAlign.TOP_LEFT))) {
+ XWPFParagraph heading = export.document().getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
+
+ assertThat(spacingBefore(heading)).as("lowered").isGreaterThan(10 * 20 + 10);
+ }
+ }
+
@Test
void aLaterLayerThatWritesNothingLeavesTheRaiseOwedInTheCell() throws Exception {
// The later layer only holds the title's place: written of it is nothing, and the raised
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
new file mode 100644
index 000000000..9fe081c33
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
@@ -0,0 +1,683 @@
+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.dsl.ListBuilder;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.dsl.ShapeBuilder;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.node.LayerAlign;
+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.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * A Word list's item Word sets high is lowered by the space above it, its marker with its text.
+ *
+ * Word sets a Word list's marker from the paragraph's mark, and LibreOffice does not move it by
+ * the mark's position: lowered by its position, the text would stand off its bullet there. The
+ * space above moves both, in both editors; the items under it, and what follows the list, give it
+ * back from the space above them.
+ */
+class DocxListItemLowerTest {
+
+ /** Spectral's ascent, in ems: 1059 of 1000 units. */
+ private static final double SPECTRAL_ASCENT = 1.059;
+ /** Lato's ascent, in ems: 1974 of 2000 units. */
+ private static final double LATO_ASCENT = 0.987;
+
+ @Test
+ void anItemWhoseMarkerWordDrawsIsLoweredByTheSpaceAboveItAndWhatFollowsGivesItBack() throws Exception {
+ // Lato's 24pt line stands 0.65pt high in Word: the space above lowers it, marker and text,
+ // and the paragraph under the list takes as much out of the space above it.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph item = paragraph(exported, "Heading");
+ long lower = Math.round(-(0.8 * line(item) - LATO_ASCENT * 24) * 20);
+
+ assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(lower).as("the premise: Word's baseline is the higher, by more than half a point")
+ .isGreaterThan(10);
+ assertThat(before(item)).isEqualTo(200 + lower);
+ assertThat(position(item)).isZero();
+ assertThat(markPosition(item)).isZero();
+ assertThat(before(paragraph(exported, "Body"))).as("the space above it, less what the item took")
+ .isEqualTo(200 - lower);
+ assertThat(exported.report().bySubject()).doesNotContainKey("space above");
+ }
+
+ @Test
+ void theItemsUnderAnItemLoweredGiveItBackFromTheSpaceAboveThem() throws Exception {
+ // Each item stands as high: the second and third are lowered by what the one above them
+ // owes, their space above as written; the paragraph under the list gives back the last's.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(24)).itemSpacing(10).margin(DocumentInsets.top(10))
+ .items("One", "Two", "Three"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph one = paragraph(exported, "One");
+ long lower = Math.round(-(0.8 * line(one) - LATO_ASCENT * 24) * 20);
+
+ assertThat(before(one)).isEqualTo(200 + lower);
+ assertThat(before(paragraph(exported, "Two"))).isEqualTo(200);
+ assertThat(before(paragraph(exported, "Three"))).isEqualTo(200);
+ assertThat(before(paragraph(exported, "Body"))).isEqualTo(200 - lower);
+ }
+
+ @Test
+ void itemsWithNoSpaceAboveThemToGiveItBackTakeItBackByTheirOwnLowering() throws Exception {
+ // Items of two lines 3pt apart, 3pt apart themselves: the lines take the whole gap above
+ // each, and stand high. The first is lowered by its space above; the others, with none to
+ // give, stand as low as the one above leaves them, which is their own lowering.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(10)).lineSpacing(3).itemSpacing(3).margin(DocumentInsets.top(10))
+ .items("One\nmore", "Two\nmore", "Three\nmore"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph one = starting(exported, "One");
+ long lowered = before(one) - (10 - 3) * 20;
+
+ assertThat(lowered).as("the 3pt the lines took above the page's, and the face's own seat")
+ .isCloseTo(highTakingTheGap(one, 3, LATO_ASCENT * 10), within(1L)).isGreaterThan(10);
+ assertThat(before(starting(exported, "Two"))).isZero();
+ assertThat(before(starting(exported, "Three"))).isZero();
+ assertThat(before(paragraph(exported, "Body"))).isEqualTo(200 - lowered);
+ }
+
+ @Test
+ void anItemUnderOneLoweredGivesItBackBeforeItRises() throws Exception {
+ // Spectral's 10pt line stands 1.6pt low in Word, and an item of two lines 15pt apart stands
+ // high: the lines take 12pt of their gap from the space above them. The one-line item under
+ // it gives back what the first owes, then rises into what is left of the gap above it.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(spectral(10)).lineSpacing(15).itemSpacing(20)
+ .margin(DocumentInsets.top(20)).items("First\nSecond", "Third"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph first = exported.document().getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().contains("First")).findFirst().orElseThrow();
+ XWPFParagraph third = paragraph(exported, "Third");
+ long lowered = before(first) - (20 - 15) * 20;
+ long raise = Math.round((0.8 * line(third) - SPECTRAL_ASCENT * 10) * 20);
+
+ assertThat(lowered).as("the item of two lines, lowered")
+ .isCloseTo(highTakingTheGap(first, 15, SPECTRAL_ASCENT * 10), within(1L)).isGreaterThan(10);
+ assertThat(raise).as("the premise: the one-line item stands low").isGreaterThan(10);
+ assertThat(before(third)).as("the gap, less what the first owed and the raise")
+ .isEqualTo(20 * 20 - lowered - raise);
+ assertThat(after(third)).as("what it rose, owed below it").isZero();
+ assertThat(before(paragraph(exported, "Body"))).as("the space above it and the raise").isEqualTo(200 + raise);
+ }
+
+ @Test
+ void whatTheSpaceAboveWhatFollowsCannotGiveBackIsNamed() throws Exception {
+ // Nothing stands between the list and the paragraph under it: lowered, the item stands that
+ // paragraph lower too, and the report says so.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.name("Duties").textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addParagraph(p -> p.name("Body").text("Body").textStyle(lato(10))));
+ XWPFParagraph item = paragraph(exported, "Heading");
+ long lower = Math.round(-(0.8 * line(item) - LATO_ASCENT * 24) * 20);
+
+ assertThat(before(item)).isEqualTo(200 + lower);
+ assertThat(exported.report().bySubject().get("space above")).singleElement().satisfies(note -> {
+ assertThat(note.path()).contains("Body");
+ assertThat(note.detail()).contains("does not hold the line hanging below the block above it");
+ });
+ }
+
+ @Test
+ void anItemLoweredAtTheEndOfACellTakesItOutOfTheSpaceTheCellOwesUnderIt() throws Exception {
+ Exported exported = export(page -> page
+ .addParagraph("Before")
+ .addSection("Card", card -> card.fillColor(DocumentColor.rgb(238, 243, 249))
+ .padding(DocumentInsets.of(8)).addParagraph("Title").addList(list -> list.textStyle(lato(24))
+ .margin(new DocumentInsets(10, 0, 4, 0)).items("Inside"))));
+ XWPFTableCell panel = exported.document().getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph inside = panel.getParagraphs().get(1);
+ long lower = Math.round(-(0.8 * line(inside) - LATO_ASCENT * 24) * 20);
+
+ assertThat(inside.getText()).as("the premise: the card ends with the item").isEqualTo("Inside");
+ assertThat(lower).as("the premise: Word's baseline is the higher").isGreaterThan(10);
+ assertThat(before(inside)).as("the list's 10pt, what the title rose and owes, and the lowering")
+ .isEqualTo(200 + DocxExports.DEFAULT_LINE_RAISE + lower);
+ assertThat(after(inside)).as("the list's 4pt below it, less what the item took").isEqualTo(4 * 20 - lower);
+ assertThat(bottomMargin(panel)).as("its padding").isEqualTo(8 * 20);
+ }
+
+ @Test
+ void anItemLoweredAtTheEndOfACardTakesItOutOfTheCardsPaddingBelow() throws Exception {
+ // Nothing stands under the item in the card: what it hangs below the cell the card's
+ // padding takes, as it takes any line hanging there.
+ Exported exported = export(page -> page
+ .addParagraph("Before")
+ .addSection("Card", card -> card.fillColor(DocumentColor.rgb(238, 243, 249))
+ .padding(DocumentInsets.of(8)).addParagraph("Title")
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Inside"))));
+ XWPFTableCell panel = exported.document().getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph inside = panel.getParagraphs().get(1);
+ long lower = Math.round(-(0.8 * line(inside) - LATO_ASCENT * 24) * 20);
+
+ assertThat(lower).as("the premise: Word's baseline is the higher").isGreaterThan(10);
+ assertThat(before(inside)).as("the list's 10pt, what the title rose and owes, and the lowering")
+ .isEqualTo(200 + DocxExports.DEFAULT_LINE_RAISE + lower);
+ assertThat(bottomMargin(panel)).as("its padding, less what the item took").isEqualTo(8 * 20 - lower);
+ }
+
+ @Test
+ void anItemOpeningACardIsNotLowered() throws Exception {
+ // A card's top border is taken inside the space above its first line, which a lowering
+ // would then be shrunk back out of.
+ Exported exported = export(page -> page
+ .addParagraph("Before")
+ .addSection("Card", card -> card.fillColor(DocumentColor.rgb(238, 243, 249))
+ .padding(DocumentInsets.of(8)).addList(list -> list.textStyle(lato(24)).items("Inside"))));
+ XWPFTableCell panel = exported.document().getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph inside = panel.getParagraphs().get(0);
+
+ assertThat(inside.getText()).as("the premise: the item opens the card").isEqualTo("Inside");
+ assertThat(-(0.8 * line(inside) - LATO_ASCENT * 24)).as("the premise: Word's baseline is the higher")
+ .isGreaterThan(0.5);
+ assertThat(before(inside)).isZero();
+ assertThat(bottomMargin(panel)).isEqualTo(8 * 20);
+ }
+
+ @Test
+ void anItemLoweredAtTheEndOfTheDocumentIsTakenBack() throws Exception {
+ // Nothing under it gives it back, and the last page would run that much longer.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(24)).itemSpacing(10).margin(DocumentInsets.top(10))
+ .items("One", "Two")));
+ long lower = Math.round(-(0.8 * line(paragraph(exported, "One")) - LATO_ASCENT * 24) * 20);
+
+ assertThat(before(paragraph(exported, "One"))).as("lowered, given back by the second").isEqualTo(200 + lower);
+ assertThat(before(paragraph(exported, "Two"))).as("the gap, less what the first took").isEqualTo(200 - lower);
+ }
+
+ @Test
+ void theLastItemOfAListEndingATablesComposedCellIsTakenBack() throws Exception {
+ // The first item is lowered, and the second gives that back; the second's own lowering
+ // nothing under it in the cell gives back.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t
+ .columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(200))
+ .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(new ListBuilder()
+ .textStyle(lato(24)).itemSpacing(10).items("One", "Two").build()))))) {
+ XWPFParagraph one = inACell(document, "One");
+ long lower = Math.round(-(0.8 * line(one) - LATO_ASCENT * 24) * 20);
+
+ assertThat(one.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(lower).as("the premise: Word's baseline is the higher").isGreaterThan(10);
+ assertThat(before(one)).isEqualTo(lower);
+ assertThat(before(inACell(document, "Two"))).as("the gap, less what the first took").isEqualTo(200 - lower);
+ }
+ }
+
+ @Test
+ void anItemLoweredAboveAParagraphTheLayoutOpensAPageWithIsTakenBack() throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .spacer(1, 480)
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addParagraph(p -> p.name("Next").text("Next").textStyle(lato(10)).margin(DocumentInsets.top(30))));
+ assertThat(startPage(session, "Next")).as("the premise: the paragraph under the list opens the next page")
+ .isEqualTo(1);
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ assertThat(before(paragraph(document, "Heading")))
+ .as("the spacer's height past its hairline and the list's 10pt, as written")
+ .isCloseTo(Math.round((480 - 0.1 + 10) * 20), within(1L));
+ }
+ }
+ }
+
+ @Test
+ void anItemLoweredIntoTheSpaceAboveAParagraphStaysLoweredWhereALaterBlockOpensAPage() throws Exception {
+ // The paragraph under the list gave its lowering back: a block after it that opens the
+ // next page has nothing of it to take back.
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .spacer(1, 440)
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10)))
+ .addParagraph(p -> p.name("Next").text("Next").textStyle(lato(10)).margin(DocumentInsets.top(50))));
+ assertThat(startPage(session, "Next")).as("the premise: the last paragraph opens the next page").isEqualTo(1);
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ XWPFParagraph item = paragraph(document, "Heading");
+ long lower = Math.round(-(0.8 * line(item) - LATO_ASCENT * 24) * 20);
+
+ assertThat(before(item)).isCloseTo(Math.round((440 - 0.1 + 10) * 20) + lower, within(1L));
+ assertThat(before(paragraph(document, "Body"))).isEqualTo(200 - lower);
+ }
+ }
+ }
+
+ @Test
+ void anItemLoweredAtTheEndOfARowsCellIsTakenBackWhereTheBlockUnderTheRowOpensAPage() throws Exception {
+ // The row hangs what the item was lowered by below itself. A paragraph under it on the same
+ // page gives that back; one opening the next page, where Word sets its line at the top, does not.
+ Consumer lowered = column -> column.add(new ListBuilder().textStyle(lato(24))
+ .margin(DocumentInsets.top(10)).items("Heading").build());
+ // Lato's line, a quarter point high, lifts the row out of nothing.
+ Consumer side = column -> column.addParagraph(p -> p.text("Side").textStyle(lato(10)));
+ assertThat(headingAbove(10, page -> page.addRow(row -> row.addSection(lowered).addSection(side))))
+ .as("lowered, under a paragraph on its page").isEqualTo(200 + latoLowering());
+ assertThat(headingAbove(50, page -> page.addRow(row -> row.addSection(lowered).addSection(side))))
+ .as("its space above, as written").isEqualTo(200);
+ // A taller column beside it leaves room under its cell in the row, which takes the hang.
+ Consumer tall = column -> {
+ for (String line : List.of("One", "Two", "Three", "Four")) {
+ column.addParagraph(p -> p.text(line).textStyle(lato(10)));
+ }
+ };
+ assertThat(headingAbove(50, page -> page.addRow(row -> row.addSection(lowered).addSection(tall))))
+ .as("lowered, the room under its cell giving it back").isEqualTo(200 + latoLowering());
+ }
+
+ @Test
+ void anItemLoweredAtTheEndOfACardWithNoPaddingIsTakenBackWhereTheBlockUnderItOpensAPage() throws Exception {
+ Consumer card = page -> page.addSection("Card", section -> section
+ .fillColor(DocumentColor.rgb(238, 243, 249)).addParagraph("Title")
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading")));
+ assertThat(headingAbove(10, card)).as("lowered, under a paragraph on its page").isEqualTo(200 + latoLowering());
+ assertThat(headingAbove(50, card)).as("its space above, as written").isEqualTo(200);
+ }
+
+ @Test
+ void theItemsOfListsSideBySideInARowAreEachTakenBack() throws Exception {
+ // Each column's list hangs its own lowering below the row: the row's hang let go, both are
+ // taken back, not as one run of the two.
+ Consumer row = page -> page.addRow(r -> r
+ .addSection(column -> column.add(new ListBuilder().textStyle(lato(24))
+ .margin(DocumentInsets.top(10)).items("Heading").build()))
+ .addSection(column -> column.add(new ListBuilder().textStyle(lato(24))
+ .margin(DocumentInsets.top(10)).items("Beside").build())));
+ assertThat(headingAbove(10, row)).as("lowered, under a paragraph on its page").isEqualTo(200 + latoLowering());
+ assertThat(headingAbove(50, row)).as("its space above, as written").isEqualTo(200);
+ assertThat(paragraphAbove(50, row, "Beside")).as("the one beside it, as written").isEqualTo(200);
+ }
+
+ @Test
+ void anItemLoweredAboveAListTheLayoutOpensAPageWithIsTakenBack() throws Exception {
+ assertThat(flowItemAbove(page -> page.addList(list -> list.name("Next").textStyle(lato(10))
+ .margin(DocumentInsets.top(10)).items("Next item")))).isEqualTo(200 + latoLowering());
+ assertThat(flowItemAbove(page -> page.addList(list -> list.name("Next").textStyle(lato(10))
+ .margin(DocumentInsets.top(50)).items("Next item")))).isEqualTo(200);
+ }
+
+ @Test
+ void aBlockOnALaterPageTakesNothingOfTheHangTheListAboveLeft() throws Exception {
+ // The list under it opens the next page: its space above is written whole, and nothing of
+ // a hang taken back is named.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10))).spacer(1, 460)
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.textStyle(lato(10)).margin(DocumentInsets.top(50)).items("Next item")));
+
+ assertThat(before(paragraph(exported, "Next item"))).as("its 50pt, whole").isEqualTo(50 * 20);
+ assertThat(exported.report().bySubject()).doesNotContainKey("space above");
+ }
+
+ @Test
+ void anItemLoweredAboveAParagraphOpeningAPageInAStackOfOneLayerIsTakenBack() throws Exception {
+ assertThat(flowItemAbove(page -> page.addLayerStack(stack -> stack.layer(new com.demcha.compose.document.dsl
+ .ParagraphBuilder().name("Next").text("Next").textStyle(lato(10)).margin(DocumentInsets.top(10))
+ .build(), LayerAlign.TOP_LEFT)))).isEqualTo(200 + latoLowering());
+ assertThat(flowItemAbove(page -> page.addLayerStack(stack -> stack.layer(new com.demcha.compose.document.dsl
+ .ParagraphBuilder().name("Next").text("Next").textStyle(lato(10)).margin(DocumentInsets.top(50))
+ .build(), LayerAlign.TOP_LEFT)))).isEqualTo(200);
+ }
+
+ @Test
+ void anItemLoweredAboveABandKeepsWhatTheBandsSpaceAboveTakesAndTheRestIsTakenBack() throws Exception {
+ // A band resumes the space the page measures above its first block, the hang taken out of
+ // it; with no space above the band, nothing gives the lowering back.
+ assertThat(aboveABand(10)).as("lowered, the band's 10pt above taking it").isEqualTo(200 + latoLowering());
+ assertThat(aboveABand(0)).as("its space above, as written").isEqualTo(200);
+ }
+
+ private static long aboveABand(double gap) throws Exception {
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addLayerStack(stack -> stack.name("Band").margin(DocumentInsets.top(gap))
+ .back(new com.demcha.compose.document.node.SpacerNode("Room", 200, 40,
+ DocumentInsets.zero(), DocumentInsets.zero()))
+ .layer(new com.demcha.compose.document.dsl.ParagraphBuilder().name("Next").text("Next")
+ .build(), LayerAlign.TOP_LEFT)));
+ return before(paragraph(exported, "Heading"));
+ }
+
+ /** The space above the item "Heading" of a list near a page's foot, {@code next} written under it. */
+ private static long flowItemAbove(Consumer next) throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("Lead").textStyle(lato(10))).spacer(1, 460)
+ .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"));
+ next.accept(page);
+ });
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ return before(paragraph(document, "Heading")) - Math.round((460 - 0.1) * 20);
+ }
+ }
+ }
+
+ private static long paragraphAbove(double nextAbove, Consumer block, String text) throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("Lead").textStyle(lato(10))).spacer(1, 460);
+ block.accept(page);
+ page.addParagraph(p -> p.name("Next").text("Next").textStyle(lato(10))
+ .margin(DocumentInsets.top(nextAbove)));
+ });
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ return before(inACell(document, text));
+ }
+ }
+ }
+
+ /**
+ * The space above the cell paragraph "Heading" a block near a page's foot ends with, a
+ * paragraph {@code nextAbove} points under it; at 50 that paragraph opens the next page.
+ */
+ private static long headingAbove(double nextAbove, Consumer block) throws Exception {
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("Lead").textStyle(lato(10))).spacer(1, 460);
+ block.accept(page);
+ page.addParagraph(p -> p.name("Next").text("Next").textStyle(lato(10))
+ .margin(DocumentInsets.top(nextAbove)));
+ });
+ assertThat(startPage(session, "Next")).as("the premise: where the paragraph under the block starts")
+ .isEqualTo(nextAbove > 30 ? 1 : 0);
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ return before(inACell(document, "Heading"));
+ }
+ }
+ }
+
+ /** How far Word sets a line of Lato at 24pt above the page's, in twips. */
+ private static long latoLowering() {
+ return Math.round(-(0.8 * 28.8 - LATO_ASCENT * 24) * 20);
+ }
+
+ @Test
+ void anItemLoweredAboveATableTakesItOutOfTheSpaceAboveTheTable() throws Exception {
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(24)).margin(new DocumentInsets(10, 0, 10, 0)).items("Heading"))
+ .addTable(t -> t.columns(com.demcha.compose.document.table.DocumentTableColumn.fixed(200))
+ .row("Cell")));
+ XWPFParagraph item = paragraph(exported, "Heading");
+ long lower = Math.round(-(0.8 * line(item) - LATO_ASCENT * 24) * 20);
+
+ assertThat(before(item)).isEqualTo(200 + lower);
+ assertThat(after(item)).as("the list's 10pt below it, less what the item took").isEqualTo(200 - lower);
+ }
+
+ @Test
+ void anItemWordSetsHighByLessThanHalfAPointIsNotLowered() throws Exception {
+ // Lato's 10pt line stands a quarter point high in Word: where its position would move it
+ // nothing, the space above does not either.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(10)).margin(DocumentInsets.top(10)).items("Item")));
+ XWPFParagraph item = paragraph(exported, "Item");
+ double high = -(0.8 * line(item) - LATO_ASCENT * 10);
+
+ assertThat(high).as("the premise: high by under half a point").isBetween(0.1, 0.5);
+ assertThat(before(item)).isEqualTo(200);
+ }
+
+ @Test
+ void anItemTheLayoutSetsOnANewPageIsNotLoweredAndTheItemsAboveItAreTakenBack() throws Exception {
+ // Word drops a paragraph's space above at the top of a page, and sets the page's first line
+ // at its top: nothing on the page above gives back what the first item was lowered by.
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.spacer(1, 500)
+ .addList(list -> list.name("Duties").textStyle(lato(24)).itemSpacing(10)
+ .margin(DocumentInsets.top(10)).items("One", "Two", "Three"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ List pages = session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.path().contains("Duties"))
+ .map(PlacedFragment::pageIndex).toList();
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ XWPFParagraph two = paragraph(document, "Two");
+ long lower = Math.round(-(0.8 * line(two) - LATO_ASCENT * 24) * 20);
+
+ assertThat(pages).as("the premise: the second item opens the next page").containsExactly(0, 1, 1);
+ assertThat(before(paragraph(document, "One")))
+ .as("the spacer's height past its hairline and the list's 10pt, as written")
+ .isCloseTo(Math.round((500 - 0.1 + 10) * 20), within(1L));
+ assertThat(before(two)).as("the gap, as written").isEqualTo(200);
+ assertThat(before(paragraph(document, "Three"))).as("the gap and its own lowering").isEqualTo(200 + lower);
+ }
+ }
+ }
+
+ @Test
+ void theItemsOfAListTheLayoutSetsOverTwoPagesInACellKeepTheSpaceAboveThem() throws Exception {
+ // A card the page breaks is one cell going on over the next page: what an item owed would be
+ // the space above the one opening that page, inside the cell.
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.spacer(1, 460)
+ .addSection("Card", card -> card.fillColor(DocumentColor.rgb(238, 243, 249))
+ .addList(list -> list.name("Duties").textStyle(lato(24)).itemSpacing(10)
+ .margin(DocumentInsets.top(10)).items("One", "Two", "Three"))));
+ List pages = session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.path().contains("Duties"))
+ .map(PlacedFragment::pageIndex).toList();
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ assertThat(pages).as("the premise: the list goes on over the next page").contains(0, 1);
+ assertThat(document.getTables()).as("the premise: one cell over both pages").hasSize(1);
+ for (String text : List.of("One", "Two", "Three")) {
+ assertThat(before(inACell(document, text))).as("the space above %s, as written", text).isEqualTo(200);
+ }
+ }
+ }
+ }
+
+ @Test
+ void anItemInAStackOfOneLayerIsLoweredAsInTheFlow() throws Exception {
+ // A template wraps an entry in a stack of one layer, which lays nothing over anything.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addLayerStack(stack -> stack.name("Entry")
+ .layer(new ListBuilder().textStyle(lato(24)).margin(DocumentInsets.top(10))
+ .items("Heading").build(), LayerAlign.TOP_LEFT))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph item = paragraph(exported, "Heading");
+ long lower = Math.round(-(0.8 * line(item) - LATO_ASCENT * 24) * 20);
+
+ assertThat(lower).as("the premise: Word's baseline is the higher").isGreaterThan(10);
+ assertThat(before(item)).isEqualTo(200 + lower);
+ assertThat(before(paragraph(exported, "Body"))).isEqualTo(200 - lower);
+ }
+
+ @Test
+ void theFirstItemOfAStackOfOneLayerTheLayoutMovesToANewPageIsNotLowered() throws Exception {
+ // Word drops the space above the page's first line, so lowered by it the first item would
+ // stand as high as before, and the second, giving back what the first owed, as high too.
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.spacer(1, 500)
+ .addLayerStack(stack -> stack.name("Entry")
+ .layer(new ListBuilder().name("Duties").textStyle(lato(24)).itemSpacing(10)
+ .margin(DocumentInsets.top(10)).items("One", "Two").build(), LayerAlign.TOP_LEFT))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ List pages = session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.path().contains("Duties"))
+ .map(PlacedFragment::pageIndex).toList();
+ try (XWPFDocument document = new XWPFDocument(
+ new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ XWPFParagraph two = paragraph(document, "Two");
+ long lower = Math.round(-(0.8 * line(two) - LATO_ASCENT * 24) * 20);
+
+ assertThat(pages).as("the premise: the stack's list opens the next page").containsExactly(1, 1);
+ assertThat(before(paragraph(document, "One")))
+ .as("the spacer's height past its hairline and the list's 10pt, as written")
+ .isCloseTo(Math.round((500 - 0.1 + 10) * 20), within(1L));
+ assertThat(before(two)).as("the gap and its own lowering, nothing owed above it").isEqualTo(200 + lower);
+ }
+ }
+ }
+
+ @Test
+ void anItemInAStackOverADrawingKeepsTheSpaceAboveIt() throws Exception {
+ // A stack laying its list over a shape measures what follows it from the page: what an item
+ // owed below it would not reach what follows.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Above").textStyle(lato(10)))
+ .addLayerStack(stack -> stack.name("Stack")
+ .back(new ShapeBuilder().name("Backdrop").size(200, 80).build())
+ .layer(new ListBuilder().textStyle(lato(24)).margin(DocumentInsets.top(10))
+ .items("Heading").build(), LayerAlign.TOP_LEFT))
+ .addParagraph(p -> p.text("After").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph item = anywhere(exported.document(), "Heading");
+
+ assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(0.8 * line(item) - LATO_ASCENT * 24).as("the premise: Word's baseline is the higher")
+ .isLessThan(-0.5);
+ assertThat(before(item)).as("nothing grown above it").isZero();
+ assertThat(position(item)).isZero();
+ }
+
+ /**
+ * How far Word sets an item of two lines high, in twips, its lines taking the whole gap
+ * between them from the space above: Word's lines are that gap taller than the page's, and it
+ * seats their middle four fifths of the way down, where the page sets the first line its
+ * ascent below the top it took the gap above.
+ */
+ private static long highTakingTheGap(XWPFParagraph item, double gap, double ascent) {
+ double line = line(item);
+ return Math.round((gap + ascent - 0.8 * line) * 20);
+ }
+
+ private static DocumentTextStyle spectral(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(size).build();
+ }
+
+ private static DocumentTextStyle lato(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.LATO).size(size).build();
+ }
+
+ private record Exported(XWPFDocument document, DocxExportReport report) {
+ }
+
+ private static int startPage(DocumentSession session, String name) {
+ return session.layoutGraph().nodes().stream()
+ .filter(node -> name.equals(node.semanticName())).findFirst().orElseThrow().startPage();
+ }
+
+ private static Exported export(Consumer content) throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(content::accept);
+ docx = session.export(new DocxSemanticBackend(report::set));
+ }
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get());
+ }
+
+ private static XWPFParagraph paragraph(Exported exported, String text) {
+ return paragraph(exported.document(), text);
+ }
+
+ private static XWPFParagraph paragraph(XWPFDocument document, String text) {
+ return document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().equals(text)).findFirst().orElseThrow();
+ }
+
+ private static XWPFParagraph starting(Exported exported, String text) {
+ return exported.document().getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().startsWith(text)).findFirst().orElseThrow();
+ }
+
+ /** The paragraph holding exactly {@code text}, in the body or a table's cell. */
+ private static XWPFParagraph anywhere(XWPFDocument document, String text) {
+ return document.getParagraphs().stream().filter(paragraph -> paragraph.getText().equals(text)).findFirst()
+ .orElseGet(() -> inACell(document, text));
+ }
+
+ /** The paragraph of a table's cell, at any depth, holding exactly {@code text}. */
+ private static XWPFParagraph inACell(XWPFDocument document, String text) {
+ java.util.ArrayDeque tables = new java.util.ArrayDeque<>(document.getTables());
+ while (!tables.isEmpty()) {
+ for (var row : tables.pop().getRows()) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ for (XWPFParagraph paragraph : cell.getParagraphs()) {
+ if (paragraph.getText().equals(text)) {
+ return paragraph;
+ }
+ }
+ tables.addAll(cell.getTables());
+ }
+ }
+ }
+ throw new AssertionError("no cell holds " + text);
+ }
+
+ private static double line(XWPFParagraph paragraph) {
+ return DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine()) / 20.0;
+ }
+
+ private static CTSpacing spacing(XWPFParagraph paragraph) {
+ return paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getSpacing();
+ }
+
+ private static long before(XWPFParagraph paragraph) {
+ CTSpacing spacing = spacing(paragraph);
+ return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore());
+ }
+
+ private static long after(XWPFParagraph paragraph) {
+ CTSpacing spacing = spacing(paragraph);
+ return spacing == null || !spacing.isSetAfter() ? 0 : DocxTwips.of(spacing.getAfter());
+ }
+
+ private static long bottomMargin(XWPFTableCell cell) {
+ var properties = cell.getCTTc().getTcPr();
+ return properties == null || !properties.isSetTcMar() || !properties.getTcMar().isSetBottom() ? 0
+ : DocxTwips.of(properties.getTcMar().getBottom().getW());
+ }
+
+ /** The {@code w:position} of a paragraph's first run, in half points; 0 for none. */
+ private static long position(XWPFParagraph paragraph) {
+ var properties = paragraph.getCTP().getRArray(0).getRPr();
+ return properties == null || properties.sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getPositionArray(0).getVal()).longValue();
+ }
+
+ /** The {@code w:position} of a paragraph's mark, in half points; 0 for none. */
+ private static long markPosition(XWPFParagraph paragraph) {
+ var properties = paragraph.getCTP().getPPr();
+ return properties == null || !properties.isSetRPr() || properties.getRPr().sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getRPr().getPositionArray(0).getVal()).longValue();
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java
index dcb5b5237..0d85f89e5 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java
@@ -75,7 +75,8 @@ void anItemWhoseMarkerIsWrittenAsTextIsLoweredWithItByItsPosition() throws Excep
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addParagraph(p -> p.text("Lead").textStyle(lato(10)))
.addList(list -> list.textStyle(lato(24)).marker("•").hangingIndent(true).markerGap(6)
- .margin(DocumentInsets.top(10)).addItem(text -> text.style("Heading", lato(24)))))) {
+ .margin(DocumentInsets.top(10)).addItem(text -> text.style("Heading", lato(24))))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))))) {
XWPFParagraph item = document.getParagraphs().stream()
.filter(paragraph -> paragraph.getText().contains("Heading")).findFirst().orElseThrow();
double raise = 0.8 * line(item) - LATO_ASCENT * 24;
From ca653a3c74f787e67c90baded1f67279aaa21bca Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 12 Oct 2026 18:46:27 +0100
Subject: [PATCH 2/4] test(qa): rewrite the Linux LibreOffice fidelity baseline
from this change's CI run
- Taken from the DOCX Fidelity artifact of this change's CI run. Against
the run before it, only cv-midnight_navy and cv-navy_sidebar moved:
their duties, 0.82 to 1.04pt and 0.45 to 0.47pt high, now stand 0.14pt
high to 0.08pt low and 0.03 to 0.05pt low, every other line where it
stood; 52 of 62 documents still pass.
- Without it, the gate's half point a line would let those lines slide
back to where they stood.
---
.../docx-fidelity/libreoffice-linux-lines.tsv | 48 +++++++++----------
.../docx-fidelity/libreoffice-linux.tsv | 4 +-
2 files changed, 26 insertions(+), 26 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 5e7ed4d38..73fa27e96 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -605,19 +605,19 @@ cv-midnight_navy 0:086c0e036e67 -0.56 -0.67 2021–present
cv-midnight_navy 0:009b5b6479fa -0.48 0.07 starwavesolutions
cv-midnight_navy 0:60cebc1ec80c -0.48 -0.63 newyork,ny
cv-midnight_navy 0:60cebc1ec80c#1 -0.28 -0.63 newyork,ny
-cv-midnight_navy 0:02cf5f2e28f2 -1.04 0.07 ·developandimplementcomprehensiv
-cv-midnight_navy 0:1dfea8c13a62 -1.02 0.10 awarenessby40%andleadgenerationb
-cv-midnight_navy 0:e0ee3cbb7b53 -1.01 0.07 ·manageateamof6marketingprofessi
-cv-midnight_navy 0:8ba85a74e016 -0.99 0.10 anddesignteamstodrivecampaignsuc
-cv-midnight_navy 0:407f710db042 -0.98 0.07 ·overseedigitalcampaignsacrossse
-cv-midnight_navy 0:308aef77a1c3 -0.96 0.10 resultingina25%increaseinroi.
-cv-midnight_navy 0:40f5daa37f9a -0.95 0.07 ·analyzemarkettrendsandcompetito
-cv-midnight_navy 0:af67fd7a2e8b -0.93 0.10 optimizemarketingefforts.
+cv-midnight_navy 0:02cf5f2e28f2 -0.14 0.07 ·developandimplementcomprehensiv
+cv-midnight_navy 0:1dfea8c13a62 -0.12 0.10 awarenessby40%andleadgenerationb
+cv-midnight_navy 0:e0ee3cbb7b53 -0.11 0.07 ·manageateamof6marketingprofessi
+cv-midnight_navy 0:8ba85a74e016 -0.09 0.10 anddesignteamstodrivecampaignsuc
+cv-midnight_navy 0:407f710db042 -0.08 0.07 ·overseedigitalcampaignsacrossse
+cv-midnight_navy 0:308aef77a1c3 -0.06 0.10 resultingina25%increaseinroi.
+cv-midnight_navy 0:40f5daa37f9a -0.05 0.07 ·analyzemarkettrendsandcompetito
+cv-midnight_navy 0:af67fd7a2e8b -0.03 0.10 optimizemarketingefforts.
cv-midnight_navy 0:7e121dceb78a -0.42 0.07 seniormarketingspecialist
cv-midnight_navy 0:f7d1049ec64b -0.36 -0.50 2018–2021
cv-midnight_navy 0:4b8292fda43e -0.28 0.07 brightlinetechnologies
-cv-midnight_navy 0:a246f7ba3f71 -0.84 0.07 ·executedmulti-channelmarketingc
-cv-midnight_navy 0:58b162e46dda -0.82 0.10 engagementby30%.
+cv-midnight_navy 0:a246f7ba3f71 0.06 0.07 ·executedmulti-channelmarketingc
+cv-midnight_navy 0:58b162e46dda 0.08 0.10 engagementby30%.
cv-midnight_navy 0:ef77f2daef0f -0.23 0.07 ·managedsocialmediachannelsandco
cv-midnight_navy 0:a112a7b3c16b -0.21 0.07 ·coordinatedwithcreativeteamstod
cv-midnight_navy 0:990b9f22bb48 -0.25 0.07 marketingcoordinator
@@ -913,28 +913,28 @@ cv-navy_sidebar 0:8a39b996b27c 0.14 0.16 jan2022-present
cv-navy_sidebar 0:f97eeb2fe120 -0.20 0.10 brightwavesolutions,newyork,ny
cv-navy_sidebar 0:f97eeb2fe120#1 -0.22 0.10 brightwavesolutions,newyork,ny
cv-navy_sidebar 0:f97eeb2fe120#2 -0.25 0.10 brightwavesolutions,newyork,ny
-cv-navy_sidebar 0:a4beef0c22d9 -0.45 0.10 •developandimplementintegratedma
-cv-navy_sidebar 0:234eaa6417f7 -0.45 0.09 awarenessby35%andleadgenerationb
-cv-navy_sidebar 0:dfd245470d6e -0.45 0.10 •manageateamof4marketingspeciali
-cv-navy_sidebar 0:94d0b8d13763 -0.45 0.09 anddesignteamstoalignstrategies.
-cv-navy_sidebar 0:5dabf21df91b -0.45 0.10 •analyzecampaignperformanceusing
-cv-navy_sidebar 0:1cb810ca6189 -0.45 0.09 optimizeroiandreporting.
+cv-navy_sidebar 0:a4beef0c22d9 0.05 0.10 •developandimplementintegratedma
+cv-navy_sidebar 0:234eaa6417f7 0.05 0.09 awarenessby35%andleadgenerationb
+cv-navy_sidebar 0:dfd245470d6e 0.05 0.10 •manageateamof4marketingspeciali
+cv-navy_sidebar 0:94d0b8d13763 0.05 0.09 anddesignteamstoalignstrategies.
+cv-navy_sidebar 0:5dabf21df91b 0.05 0.10 •analyzecampaignperformanceusing
+cv-navy_sidebar 0:1cb810ca6189 0.05 0.09 optimizeroiandreporting.
cv-navy_sidebar 0:3b172b9ab75e -0.26 0.10 marketingspecialist
cv-navy_sidebar 0:d514872487af 0.12 0.15 jun2020-dec2021
-cv-navy_sidebar 0:a13f57f4d861 -0.47 0.10 •executeddigitalmarketingcampaig
-cv-navy_sidebar 0:4b308a27b878 -0.47 0.09 channels.
-cv-navy_sidebar 0:39b87fda4a82 -0.47 0.10 •conductedmarketresearchandcompe
-cv-navy_sidebar 0:b088780ed500 -0.47 0.09 andinformstrategy.
-cv-navy_sidebar 0:bafd8ea30ebe -0.47 0.10 •createdengagingcontentforblogs,
-cv-navy_sidebar 0:88a47597543f -0.47 0.09 engagementby40%.
+cv-navy_sidebar 0:a13f57f4d861 0.03 0.10 •executeddigitalmarketingcampaig
+cv-navy_sidebar 0:4b308a27b878 0.03 0.09 channels.
+cv-navy_sidebar 0:39b87fda4a82 0.03 0.10 •conductedmarketresearchandcompe
+cv-navy_sidebar 0:b088780ed500 0.03 0.09 andinformstrategy.
+cv-navy_sidebar 0:bafd8ea30ebe 0.03 0.10 •createdengagingcontentforblogs,
+cv-navy_sidebar 0:88a47597543f 0.03 0.09 engagementby40%.
cv-navy_sidebar 0:dcf0600524d2 -0.29 0.10 marketingintern
cv-navy_sidebar 0:73a85d4a71b0 0.09 0.13 jan2020-may2020
cv-navy_sidebar 0:e31c3dfebb27 -0.25 0.10 •assistedinplanningandexecutingm
cv-navy_sidebar 0:f7ee178c4b93 -0.25 0.10 •supportedcontentcreationandsoci
cv-navy_sidebar 0:3ffec90452c5 -0.25 0.10 •analyzeddataandpreparedreportst
cv-navy_sidebar 0:6ee18b42fbf1 -0.26 0.10 achievements
-cv-navy_sidebar 0:cff13cc228a6 -0.45 0.10 •increasedwebsitetrafficby60%wit
-cv-navy_sidebar 0:fae41a639f96 -0.45 0.09 strategy.
+cv-navy_sidebar 0:cff13cc228a6 0.05 0.10 •increasedwebsitetrafficby60%wit
+cv-navy_sidebar 0:fae41a639f96 0.05 0.09 strategy.
cv-navy_sidebar 0:b46da9584f4f -0.20 0.10 •launchedasuccessfulemailmarketi
cv-navy_sidebar 0:3654c4d2721e -0.20 0.10 •received“employeeoftheyear”awar
cv-navy_sidebar 0:e831beb04c00 -0.21 0.10 certifications
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
index 482482edc..6995d97e2 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
@@ -9,12 +9,12 @@ cv-compact_mono 1 1 58 58 0.17 0.29 0
cv-editorial_blue 2 2 82 82 0.14 0.30 0
cv-engineering_resume 1 1 57 57 0.26 0.38 0
cv-executive 2 2 60 60 0.26 0.30 0
-cv-midnight_navy 1 1 86 84 0.28 0.87 0
+cv-midnight_navy 1 1 86 84 0.24 0.48 0
cv-minimal_underlined 2 2 59 59 0.21 0.45 0
cv-mint_editorial 2 2 59 59 0.07 0.30 0
cv-modern_professional 2 2 61 59 0.04 0.06 0
cv-monogram_sidebar 1 1 53 50 0.20 0.44 0
-cv-navy_sidebar 1 1 71 71 0.20 0.45 0
+cv-navy_sidebar 1 1 71 71 0.12 0.25 0
cv-nordic_clean 1 1 63 58 0.25 0.43 0
cv-orange_ops 1 1 108 106 0.18 0.38 0
cv-panel 1 1 79 74 0.26 0.55 0
From ab4ed4c50326deacc186231ddac8b8ec7d9da4bf Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 12 Oct 2026 19:43:10 +0100
Subject: [PATCH 3/4] fix(docx): take a list item's lowering back where the
item under it, or the space ending a section, cannot give it back
- An item that cannot give back from the space above it what the items
above it were lowered by, and stands less high itself, has them take
the rest back, the last lowered first: it stands where it would have,
and nothing under it is pushed down. Every item settles the lowering
above it, its marker drawn by Word or not.
- What the space below a cell's last paragraph gave back of a lowering
is recorded with that paragraph. Where the section's end leaves that
space out of its last table's last row, the lowering is taken back
too, and the row stands as tall as before.
---
CHANGELOG.md | 19 +++--
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 77 ++++++++++++++-----
.../semantic/docx/DocxListItemLowerTest.java | 70 +++++++++++++++++
4 files changed, 140 insertions(+), 28 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ae203df7..49c0549d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,14 +16,17 @@ follow semantic versioning; release dates are ISO 8601.
LibreOffice, `NavySidebar`'s, 0.51pt by it, 0.30 to 0.47pt. Such an item is now lowered by growing
the space written above it, which moves its marker and its text together in both editors. The
items under it take as much back out of the space above them, one standing as high taking it back
- by its own lowering. What the list's last items still owe hangs below the list as a line hanging
- below a block does: the space above what follows takes it, the report naming what that space and
- that block's own top edge cannot give, and at the end of a cell the space the cell owes under the
- list, the room under it in its row or a card's padding, each list's on its own. Wherever the hang
- is let go instead — among them below a table's composed cell or a layer stack's column, under a
- column's later layer, which takes its place from the page, at a block the layout sets on a later
- page, which takes nothing of the hang left on the page above, at a section's end — the lowering
- is taken back, those items standing as high as before and what follows where it stood. Not
+ by its own lowering; one that cannot, and stands less high itself, has the lowering above it taken
+ back as far, and stands where it would have. What the list's last items still owe hangs below the
+ list as a line hanging below a block does: the space above what follows takes it, the report
+ naming what that space and that block's own top edge cannot give, and at the end of a cell the
+ space the cell owes under the list, the room under it in its row or a card's padding, each list's
+ on its own. Wherever the hang is let go instead — among them below a table's composed cell or a
+ layer stack's column, under a column's later layer, which takes its place from the page, at a
+ block the layout sets on a later page, which takes nothing of the hang left on the page above, at
+ a section's end, where the space below its last row's cells that gave it back is left out too —
+ the lowering is taken back, those items standing as high as before and what follows where it
+ stood. Not
lowered: an item the layout opens a page with, one of a list it sets over two pages in a cell, an
item opening a card, whose top border the space above it takes, and one in an overlay whose writer
measures what follows from the page — a stack laying one layer over another, a band, a shape
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 1dffc11d8..50170826f 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -185,7 +185,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, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). 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 that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — 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 an export without a layout writes — 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 on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell or a layer stack's column, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. 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, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). 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 that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — 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 an export without a layout writes — 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 on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them — one that cannot, and stands less high itself, has it taken back as far — and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell or a layer stack's column, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. 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 — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. 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/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 09874692f..3e9e347e1 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
@@ -589,6 +589,23 @@ private HungLowering(java.util.ArrayDeque spaces, long hang, int pag
}
}
+ /**
+ * What a cell's last paragraph's space below gave back of the lowering of a list's items above
+ * it, by that paragraph ({@link #writeInCell}): taken back where that space is left out
+ * ({@link #dropTheSpaceBelow}).
+ */
+ private final java.util.Map>
+ givenByTheSpaceBelow = new java.util.IdentityHashMap<>();
+
+ /**
+ * Twips a space below gave back of the lowering grown into {@code spaces}.
+ *
+ * @param spaces the grown spaces of the list's items
+ * @param twips how much that space gave back
+ */
+ private record GivenBack(java.util.ArrayDeque spaces, long twips) {
+ }
+
/** Counts {@code twips} of the space under each list in {@code hung} as giving its hang back. */
private static void giveToTheLowering(List hung, long twips) {
for (HungLowering lowering : hung) {
@@ -946,6 +963,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
// An earlier export's lowerings are its own document's.
loweringHung = new ArrayList<>();
cellLoweringHung = new ArrayList<>();
+ givenByTheSpaceBelow.clear();
forgetTheHang();
raisedRows.clear();
insetLeft = 0;
@@ -4989,12 +5007,11 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut,
}
giveBack(itemsLowered, giveBackFromTheSpaceAbove(para, grownBy(itemsLowered)));
double shift = shiftToThePagesBaseline(para, laidOut.lines(), listLineGap, 0, false, true, staysOnItsPage);
- if (wordDrawsTheMarker) {
- lowerIntoTheSpaceAbove(para, staysOnItsPage, -shift);
- } else {
+ if (!wordDrawsTheMarker) {
// Its marker is its text, and its position moves the two together.
raiseRunsFrom(para, 0, Math.round(shift * HALF_POINTS_PER_POINT));
}
+ lowerIntoTheSpaceAbove(para, staysOnItsPage, wordDrawsTheMarker ? -shift : 0);
}
/** How far the spaces in {@code grown} were grown, together, in twips. */
@@ -5065,7 +5082,9 @@ private static long giveBackFromTheSpaceAbove(XWPFParagraph para, long twips) {
*
* A line moved down takes its room out of the space below it ({@link #itemsLowered}). The
* items under it give it back from the space above them, one standing as high taking it back
- * by its own lowering; what is left at the list's end hangs below the list, and the space
+ * by its own lowering; one that cannot give back all it is owed, and stands less high itself,
+ * has the items above take the rest back, the last lowered first, and stands where it would
+ * have without them. What is left at the list's end hangs below the list, and the space
* above what follows takes it as it takes a line hanging below the block above it
* ({@link #hangingBelow}), what that space and that block's own top edge cannot give named as
* standing it lower ({@link #nameTheTailNotTaken}). At the end of a cell the space the cell
@@ -5090,21 +5109,26 @@ private static long giveBackFromTheSpaceAbove(XWPFParagraph para, long twips) {
* {@code NavySidebar} wrap each job in one. A raise is not made there either way.
*
* @param lower how far the line's position would lower it, in points: 0 or less where the
- * whole difference is under half a point ({@link #shiftToThePagesBaseline}), or
- * the line is set low
+ * whole difference is under half a point ({@link #shiftToThePagesBaseline}), the
+ * line is set low, or its marker is its text, which its position moves with it
*/
private void lowerIntoTheSpaceAbove(XWPFParagraph para, boolean staysOnItsPage, double lower) {
// A panel's top border is taken inside its first line's space above (takeTheTopBorderInside).
- if (!(lower > 0) || overlayDepth > oneLayerDepth || !staysOnItsPage || opensThePanel(para)) {
- return;
- }
- // What the items above owe stands it that much lower already. A line set at an exact
- // height, as one with a position to take is, has its spacing written.
- long more = Math.max(0, Math.round(lower * POINT_TO_TWIP) - grownBy(itemsLowered));
- if (more > 0) {
+ boolean lowered = lower > 0 && overlayDepth <= oneLayerDepth && staysOnItsPage && !opensThePanel(para);
+ long wanted = lowered ? Math.round(lower * POINT_TO_TWIP) : 0;
+ // What the items above still owe stands it that much lower already. Past what it is to
+ // be lowered by itself, it would stand everything under it lower: the items above give
+ // it back, standing as high as that leaves them.
+ long owed = grownBy(itemsLowered);
+ if (owed > wanted) {
+ takeBack(itemsLowered, owed - wanted);
+ } else if (wanted > owed) {
+ // A line set at an exact height, as one with a position to take is, has its spacing
+ // written.
CTSpacing spacing = para.getCTP().getPPr().getSpacing();
- spacing.setBefore(BigInteger.valueOf(twipsOf(spacing.isSetBefore() ? spacing.getBefore() : null) + more));
- itemsLowered.addLast(new GrownSpace(spacing, more));
+ spacing.setBefore(BigInteger.valueOf(
+ twipsOf(spacing.isSetBefore() ? spacing.getBefore() : null) + wanted - owed));
+ itemsLowered.addLast(new GrownSpace(spacing, wanted - owed));
}
}
@@ -7216,8 +7240,12 @@ private static double roomBelowTheFlow(com.demcha.compose.document.layout.Layout
return Double.isNaN(lowest) ? 0 : lowest - graph.canvas().margin().bottom();
}
- /** Leaves out the space below the last line of each cell of a table's last row that shows none of it. */
- private static void dropTheSpaceBelow(XWPFTable table) {
+ /**
+ * Leaves out the space below the last line of each cell of a table's last row that shows none
+ * of it, and takes back what that space gave back of a list's items lowered above it
+ * ({@link #givenByTheSpaceBelow}): the row would stand that much taller.
+ */
+ private void dropTheSpaceBelow(XWPFTable table) {
if (table.getRows().isEmpty() || drawn(tableBottom(table))) {
return;
}
@@ -7236,6 +7264,9 @@ private static void dropTheSpaceBelow(XWPFTable table) {
CTPPr lastProperties = paragraph.getCTP().getPPr();
if (lastProperties != null && lastProperties.isSetSpacing() && lastProperties.getSpacing().isSetAfter()) {
lastProperties.getSpacing().unsetAfter();
+ for (GivenBack given : givenByTheSpaceBelow.getOrDefault(paragraph.getCTP(), List.of())) {
+ takeBack(given.spaces(), given.twips());
+ }
}
// A cell cannot end with a table in Word, so one it ends with is followed by
// the paragraph that closes it.
@@ -15855,8 +15886,16 @@ private void writeInCell(XWPFTableCell cell, CellContent content) throws Excepti
pendingSpacingAfter -= taken;
overhang = hang - taken;
// A list's items lowered into that hang, past what the cell owes under them, hang
- // below the cell with it, for the row or the panel holding the cell to take.
- giveToTheLowering(loweringHung, Math.round(taken * POINT_TO_TWIP));
+ // below the cell with it, for the row or the panel holding the cell to take. What the
+ // cell owes is written below its last paragraph, which may yet be left out.
+ long given = Math.round(taken * POINT_TO_TWIP);
+ if (given > 0 && lastBodyParagraph != null) {
+ for (HungLowering lowering : loweringHung) {
+ givenByTheSpaceBelow.computeIfAbsent(lastBodyParagraph.getCTP(), key -> new ArrayList<>())
+ .add(new GivenBack(lowering.spaces, Math.min(lowering.hang, given)));
+ }
+ }
+ giveToTheLowering(loweringHung, given);
lowered = loweringHung;
// A cell ends where it ends: its last gap cannot land on whatever the body
// writes next, and the body's cannot land inside it.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
index 9fe081c33..db6e28639 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
@@ -102,6 +102,76 @@ void itemsWithNoSpaceAboveThemToGiveItBackTakeItBackByTheirOwnLowering() throws
assertThat(before(paragraph(exported, "Body"))).isEqualTo(200 - lowered);
}
+ @Test
+ void anItemSetHigherThanTheOneAboveItIsLoweredByTheDifference() throws Exception {
+ // Lato's 24pt line stands 0.65pt high; the item of two lines 3pt apart under it, its gap
+ // taken from the space above it, higher still. What the first owes stands the second that
+ // much lower already, so it is lowered by the rest.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> list.textStyle(lato(24)).lineSpacing(3).itemSpacing(3).margin(DocumentInsets.top(10))
+ .items("One", "Two\nmore"))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ XWPFParagraph two = starting(exported, "Two");
+ long first = latoLowering();
+ long second = highTakingTheGap(two, 3, LATO_ASCENT * 24);
+
+ assertThat(second).as("the premise: the second stands the higher").isGreaterThan(first + 5);
+ assertThat(before(paragraph(exported, "One"))).isEqualTo(200 + first);
+ assertThat(before(two)).as("its gap taken by its lines, and the rest of its lowering")
+ .isCloseTo(second - first, within(1L));
+ assertThat(before(paragraph(exported, "Body"))).isCloseTo(200 - second, within(1L));
+ }
+
+ @Test
+ void anItemThatCannotGiveTheLoweringBackHasTheItemAboveTakeItBack() throws Exception {
+ // The first item, of two lines, stands high; the two under it, a quarter point high, are
+ // not lowered. With no space between the items, lowered, the first would stand them lower
+ // than Word sets them: it is taken back. With 3pt between them, it is given back.
+ Consumer items = list -> list.textStyle(lato(10)).lineSpacing(3).margin(DocumentInsets.top(10))
+ .items("One\nmore", "Two", "Three");
+ Exported flush = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> items.accept(list.itemSpacing(0)))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ Exported apart = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(lato(10)))
+ .addList(list -> items.accept(list.itemSpacing(3)))
+ .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))));
+ long lower = highTakingTheGap(starting(apart, "One"), 3, LATO_ASCENT * 10);
+
+ assertThat(lower).as("the premise: the first item stands high").isGreaterThan(10);
+ assertThat(before(starting(apart, "One"))).as("lowered, given back below").isEqualTo((10 - 3) * 20 + lower);
+ assertThat(before(paragraph(apart, "Two"))).isEqualTo(3 * 20 - lower);
+ assertThat(before(starting(flush, "One"))).as("taken back").isEqualTo((10 - 3) * 20);
+ assertThat(before(paragraph(flush, "Two"))).isZero();
+ assertThat(before(paragraph(flush, "Body"))).as("the space above it, whole").isEqualTo(200);
+ }
+
+ @Test
+ void anItemLoweredIntoTheSpaceBelowARowEndingTheDocumentIsTakenBack() throws Exception {
+ // The list owes 4pt below it in its column, which gives the lowering back; at the end of
+ // the document that space is left out of the row, and the lowering with it.
+ Consumer row = page -> page.addRow(r -> r
+ .addSection(column -> column.add(new ListBuilder().textStyle(lato(24))
+ .margin(new DocumentInsets(10, 0, 4, 0)).items("Heading").build()))
+ .addSection(column -> column.addParagraph(p -> p.text("Side").textStyle(lato(10)))));
+ Exported ending = export(page -> {
+ page.addParagraph(p -> p.text("Lead").textStyle(lato(10)));
+ row.accept(page);
+ });
+ Exported followed = export(page -> {
+ page.addParagraph(p -> p.text("Lead").textStyle(lato(10)));
+ row.accept(page);
+ page.addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10)));
+ });
+
+ assertThat(before(inACell(followed.document(), "Heading"))).as("lowered, followed")
+ .isEqualTo(200 + latoLowering());
+ assertThat(after(inACell(followed.document(), "Heading"))).isEqualTo(4 * 20 - latoLowering());
+ assertThat(before(inACell(ending.document(), "Heading"))).as("taken back at the end").isEqualTo(200);
+ }
+
@Test
void anItemUnderOneLoweredGivesItBackBeforeItRises() throws Exception {
// Spectral's 10pt line stands 1.6pt low in Word, and an item of two lines 15pt apart stands
From 90b55b9cde8ae33bc0dfeacdbee5dffeec80a6cd Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 12 Oct 2026 20:14:13 +0100
Subject: [PATCH 4/4] fix(docx): take back what a cell's space below gave a
list's lowering where the section's end leaves it out, written or all taken
- Where the space a cell owes under its last paragraph gave all of it
to a list's lowering, nothing is written below the paragraph. Leaving
the section's last row's space out then skipped its record, and the row
stood the lowering taller. The record is now taken back wherever the
section's end reaches that paragraph, and is removed once read.
---
CHANGELOG.md | 7 ++++---
.../backend/semantic/docx/DocxSemanticBackend.java | 9 +++++++--
.../backend/semantic/docx/DocxListItemLowerTest.java | 10 ++++++++++
3 files changed, 21 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49c0549d9..f7efe6a01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,9 +24,10 @@ follow semantic versioning; release dates are ISO 8601.
on its own. Wherever the hang is let go instead — among them below a table's composed cell or a
layer stack's column, under a column's later layer, which takes its place from the page, at a
block the layout sets on a later page, which takes nothing of the hang left on the page above, at
- a section's end, where the space below its last row's cells that gave it back is left out too —
- the lowering is taken back, those items standing as high as before and what follows where it
- stood. Not
+ a section's end, where the space a cell of its last row owes under its own last paragraph is left
+ out, and with it what that space gave back — the lowering is taken back, those items standing as
+ high as before and what follows where it stood; not yet where the room under a cell in that row
+ is the space below a cell beside it, left out there as well. Not
lowered: an item the layout opens a page with, one of a list it sets over two pages in a cell, an
item opening a card, whose top border the space above it takes, and one in an overlay whose writer
measures what follows from the page — a stack laying one layer over another, a band, a shape
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 3e9e347e1..d781ecb89 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
@@ -7264,8 +7264,13 @@ private void dropTheSpaceBelow(XWPFTable table) {
CTPPr lastProperties = paragraph.getCTP().getPPr();
if (lastProperties != null && lastProperties.isSetSpacing() && lastProperties.getSpacing().isSetAfter()) {
lastProperties.getSpacing().unsetAfter();
- for (GivenBack given : givenByTheSpaceBelow.getOrDefault(paragraph.getCTP(), List.of())) {
- takeBack(given.spaces(), given.twips());
+ }
+ // What gave it back was that space, written below the paragraph or, all taken,
+ // not written at all.
+ List given = givenByTheSpaceBelow.remove(paragraph.getCTP());
+ if (given != null) {
+ for (GivenBack back : given) {
+ takeBack(back.spaces(), back.twips());
}
}
// A cell cannot end with a table in Word, so one it ends with is followed by
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
index db6e28639..9a68d52c8 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
@@ -170,6 +170,16 @@ void anItemLoweredIntoTheSpaceBelowARowEndingTheDocumentIsTakenBack() throws Exc
.isEqualTo(200 + latoLowering());
assertThat(after(inACell(followed.document(), "Heading"))).isEqualTo(4 * 20 - latoLowering());
assertThat(before(inACell(ending.document(), "Heading"))).as("taken back at the end").isEqualTo(200);
+ // Half a point below the list is all taken, and nothing is written below it to leave out.
+ Exported halfAPoint = export(page -> {
+ page.addParagraph(p -> p.text("Lead").textStyle(lato(10)));
+ page.addRow(r -> r
+ .addSection(column -> column.add(new ListBuilder().textStyle(lato(24))
+ .margin(new DocumentInsets(10, 0, 0.5, 0)).items("Heading").build()))
+ .addSection(column -> column.addParagraph(p -> p.text("Side").textStyle(lato(10)))));
+ });
+ assertThat(before(inACell(halfAPoint.document(), "Heading"))).as("taken back at the end, all of it")
+ .isEqualTo(200);
}
@Test