From a15fdfdeb96c3d7cc3b73fa5590e5556e6f9d07b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 08:50:16 +0000
Subject: [PATCH 1/3] fix(docx): grow a Word list's item where its face holds
its marker
- DocxGrownLines reads a Word list's item with its marker, a run of its
first line in its level's properties over its paragraph mark's, where
the level writes letters its face holds: not a number, a picture, an
overridden level or one a numbering style defines.
- DocxFontTable.ShippedLines.holds asks a shipped face's character map
for a text's letters; Word sets a letter its face lacks in a face of
its own, whose line is taller.
- Tests: an item grows with a marker in its text's face and size, in its
mark's face, not with a larger one or one its face lacks; the lowering
tests keep their items exact by a marker Lato and Spectral lack.
---
CHANGELOG.md | 31 +++-
docs/recipes/docx-export.md | 2 +-
.../backend/semantic/docx/DocxFontTable.java | 78 +++++++++-
.../backend/semantic/docx/DocxGrownLines.java | 144 +++++++++++++++---
.../semantic/docx/DocxSemanticBackend.java | 2 +-
.../semantic/docx/DocxComposedListTest.java | 29 +++-
.../docx/DocxGrownLinesExportTest.java | 23 ++-
.../semantic/docx/DocxGrownLinesTest.java | 135 +++++++++++++++-
.../semantic/docx/DocxLayerColumnsTest.java | 15 +-
.../semantic/docx/DocxListItemLowerTest.java | 91 +++++++----
10 files changed, 472 insertions(+), 78 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29a574406..32ef0b802 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,33 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export's Word list items grow with their text where their face holds their marker.**
+ - A Word list's item, whose marker Word draws, was written at an exact height, which Word keeps
+ whatever its text holds.
+ - Word draws the marker as a run of the item's first line, in its level's face and size over its
+ paragraph mark's, and LibreOffice does too (measured in Word and LibreOffice 24.2: a bullet, a
+ white bullet and a small square in 8pt Lato, a bullet and an en dash in 9pt Fira Sans Condensed,
+ left exact and least lines as tall, with a space or a tab after it, its face its level's or its
+ mark's; a bullet at twice the size grew a least line by its height). A marker whose face holds no glyph for it is set in another face in each editor: Word
+ set an arrowhead beside 8pt Lato in Segoe UI Symbol, its least line 0.72pt taller.
+ - An item is now read with its marker, by the rule a paragraph's line grows by, where its level
+ writes letters its face holds, not a number or a picture. An item whose marker its face holds
+ no glyph for, or whose level is overridden or defined by a numbering style, stays exact. A
+ least item needs none of the lowering the space above gave an exact one Word set half a point
+ or more high, as `MidnightNavy`'s and `NavySidebar`'s duties were.
+ - The corpus writes 96 more list items at a least height, in eight documents; the 44 Word list
+ items still exact are set tighter than their faces' own line. In Word, two points more on
+ `Normal` leaves 923 of the 1832 paragraphs it grows on lines shorter than 1.1 times their
+ letters, from 1005, and 15 documents pass, from 14.
+ - Of the eight documents' lines, 53 stand nearer the page in Word and 26 further, of 682, and 53
+ nearer and 23 further in LibreOffice 24.2, of 678. The step from a line to the next differs
+ from the page's by more than 0.2pt for 80 of 456 steps in Word, from 95, and 88 of 482 in
+ LibreOffice, from 107.
+ - Where an item's line is taller than its text, the gap between its lines written into it, and
+ the space above it does not take the difference, its text stands lower than as an exact line,
+ inside the quarter point a least line is held to: `OrangeOps`' duties and `SerifHeadline`'s
+ items about 0.25pt lower in both editors, the lowest 0.42pt in Word.
+
- **A DOCX export's lines holding a picture within their text grow with it.**
- A line holding a picture set in it, an icon or a drawn bullet beside its words, was written at
an exact height, which Word keeps whatever its text holds.
@@ -63,8 +90,8 @@ follow semantic versioning; release dates are ISO 8601.
the faces' own tables: the tallest run's reach above the baseline and the deepest run's below,
the paragraph's mark among them. It is so where the runs are set in faces the document ships,
all the same depth below the baseline, left to right, none raised or a script, no picture in
- the line past its text (see above), and the paragraph is seated on its baseline, not a Word
- list's item.
+ the line past its text, a Word list's marker in a face that holds it (see above), and the
+ paragraph is seated on its baseline.
- Word and LibreOffice keep a least line's spare above its text and stand its baseline the
runs' descent above its foot (measured on the templates' faces, Word and LibreOffice 24.2, to
0.15pt). That is where the page sets it, so such a line needs no position to stand there; it
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 1b4e1d838..de9737fa0 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -245,7 +245,7 @@ it cannot work out for itself:
| What | Where it lands |
|---|---|
-| Line height | `w:spacing w:line` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph's, a list item's or a table text cell's line is written as a least height (`w:lineRule="atLeast"`), which Word grows when the text is made larger, where it is at least as tall as its runs' own line — the faces' line by their own tables, the tallest run's reach above the baseline and the deepest run's below, the paragraph's mark among them — in faces the document ships, every run the same depth below its baseline, left to right, none raised or a script, no picture in the line that passes its text's own line, its faces' line gap included, where either editor stands it — Word lowers a picture by its run's position, in a least line too, and LibreOffice stands it a tenth of a point above the baseline whatever the position, and a picture run's own size sets none of the line (measured: a 9pt icon lowered 1.5pt beside 8pt Lato left a 9.95pt least line as tall in Word and grew it to 10.75pt in LibreOffice) — nor a picture in a paragraph of broken lines or beside spaces alone, the paragraph seated on its baseline and not a Word list's item, and where Word then stands its text within a quarter point of the page's: Word and LibreOffice keep a least line's spare above its text and stand its baseline the runs' descent above its foot (measured on the templates' faces, Word and LibreOffice 24.2, to 0.15pt), which is where the page sets it. Every other line is exact, which Word keeps whatever its text holds: one the page sets tighter than its faces' own — Poppins' and Gothic A1's, whose own line holds a line gap the page's does not, or one rounded to the twip below its own, which a least line would grow back on every line; one in a face the document does not ship, the default Helvetica's among them, or in one whose `hhea` extents, which the page measures by, are not the metrics its line is set on; a page zone's, a text box's, a stacked line and a line pair. The report names how many paragraphs of text are exact, once for the document (`line height`). 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 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 not owed or hung below its paragraph, as Word sets lines on a grid of its own, 0.12pt apart (measured), but carried on to the next paragraph's and settled with it once the two come to more; what is carried stays on its page, starts again where the space owed is set anew — a band's or a column's layer, below a band — and in each cell, whose end settles what its own lines carry there, however little — what they fall short of the page owed below its last block, what they pass it by hung below the cell as a line hanging there is — and a paragraph that owed for its lines and whose pictures then grow them takes that back and carries on what the lines above it carried (measured: `Panel`'s card lines, each written a fiftieth of a point taller than the page's, stood a median 0.46pt low in Word and 0.39pt in LibreOffice on Windows; carried, 0.35 and 0.34pt). A list's item is settled with the page as a paragraph is, its lines carried with the paragraphs' around it (measured: `Panel`'s skills and projects, items of two lines and more, each line a fiftieth of a point taller than the page's, ran 0.21 and 0.35pt taller in Word and grew their cards by as much; items and cells' ends settled, `Panel` stood a median 0.21pt from the page in Word and in LibreOffice on Windows). 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 opening a column of a row of columns or of a stack's columns takes it from the space above the row, which the row is lifted out of, its other cells starting as much lower inside (one opening with a table below a hairline paragraph holding the lift) and a row held to a height held as much taller — only where the lift raises the first line half a point or more above where sharing the gaps out sets it, a smaller rise leaving a cell's last paragraph high with nothing under it to lower it into; not on a page's first row or in an overlay (measured: `EditorialProposal`'s goal cells, three lines each, 1.46 and 0.56pt off the page in Word, stand within 0.16pt; `OrangeOps`' degree, two lines 0.45pt low and 0.45pt high, within 0.27pt). One with no room above it for the gap otherwise (a cell padded less, a gap too small) 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, and so do a band's later layer and the space below the band — a stack laying one layer over another, a shape container — which measure what follows them from the page too (measured: `ConsultingInvoice`'s contact lines, in a band, half a point low in Word and both LibreOffice builds and its address 0.3pt, within 0.03pt raised; a band's first paragraph or list item, where a band in the flow, a stack of one layer's included, opens its page, is not raised, as a paragraph's there is not). A line pair's shared line moves up into the space written above it, not a cell's padding, for both its halves, by the lesser of what they call for, where neither is moved by its position — moved for one alone, it would move the other too — and not where a half is cut to its own measure or the layout opens a page with the pair (measured: `ConsultingInvoice`'s bank details, a label and its value to a line, 0.7 to 0.8pt low in Word and both LibreOffice builds, stand 0.2 to 0.33pt low raised, the rest a drift all under its line-item table shares; `LumaStudioInvoice`'s, in Carlito, which both editors set higher than the export reckons, rise too far: in its short invoice the bank details stand 0.27pt high in Word and 0.34pt in LibreOffice, where they stood 0.03 and 0.14pt high). Not a line in another overlay — a canvas, a text box — whose writers measure what follows them from the page and pay nothing, though a stack of one layer is the flow, its lines raised and its rows lifted as there (measured: `SlateOrange`'s experience timeline, set in one, its employers' lines and their items 0.12 to 0.36pt nearer the page in Word and 0.2pt in LibreOffice, `SubscriptionInvoice`'s contact rows 0.12 to 0.24pt and 0.25pt); 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, the first of a stack of one layer it moves there whole among them or the first of a band in the flow that opens its 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. A line of its own Word sets higher than the page by a tenth of a point or more, and under half a point, is lowered by the space between it and what follows: its space above grows by what is left of that space once the block under it has taken its own of it — the gap between its lines, its own raise, the room its pictures reach into, a row's lift out of the space above its table — above that block, or below the line where a table or a cell's end follows, and the block under it stands where it did. What nothing gives — a block on a later page, whose space above Word drops, a block in an overlay, a page break, a block the layout moves to the next page, a column's later layer — it is not lowered by, and where the section's end leaves the space below a cell's last line out, the lowering that space gave is taken back with it. Not a list's item, which its own list lowers, nor a line in an overlay (a stack of one layer is the flow), one opening its page or a panel, cut to fit or not on one page (measured, with the rules below a heading hung as below: `LetterSidebarPortrait`'s lines, 0.41pt high by median in Word, stand within 0.07pt, `TimelineMinimal`'s within 0.05pt in Word and LibreOffice). What the space above cannot give of a raise, and a line Word sets half a point or more high, 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 in an exact line, as Poppins' is (a line as tall as its faces' own is a least line instead, which needs no position). 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 under its layer stack's column, 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, 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 exact 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, written exact and raised 4pt, stood 2.4pt high in LibreOffice; at its own height it is now a least line, which no position moves. 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:line` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph's, a list item's or a table text cell's line is written as a least height (`w:lineRule="atLeast"`), which Word grows when the text is made larger, where it is at least as tall as its runs' own line — the faces' line by their own tables, the tallest run's reach above the baseline and the deepest run's below, the paragraph's mark among them — in faces the document ships, every run the same depth below its baseline, left to right, none raised or a script, no picture in the line that passes its text's own line, its faces' line gap included, where either editor stands it — Word lowers a picture by its run's position, in a least line too, and LibreOffice stands it a tenth of a point above the baseline whatever the position, and a picture run's own size sets none of the line (measured: a 9pt icon lowered 1.5pt beside 8pt Lato left a 9.95pt least line as tall in Word and grew it to 10.75pt in LibreOffice) — nor a picture in a paragraph of broken lines or beside spaces alone, the paragraph seated on its baseline, a Word list's marker counted as a run of its first line in its level's face and size over its paragraph's mark's and only where that face holds a glyph for it (measured: a bullet, a white bullet, a small square or an en dash in the text's own face and size left exact and least lines as tall in Word and LibreOffice 24.2, a bullet at twice the size grew a least line by its height, and Word set an arrowhead Lato holds no glyph for in Segoe UI Symbol, its least line 0.72pt taller), and where Word then stands its text within a quarter point of the page's: Word and LibreOffice keep a least line's spare above its text and stand its baseline the runs' descent above its foot (measured on the templates' faces, Word and LibreOffice 24.2, to 0.15pt), which is where the page sets it. Every other line is exact, which Word keeps whatever its text holds: one the page sets tighter than its faces' own — Poppins' and Gothic A1's, whose own line holds a line gap the page's does not, or one rounded to the twip below its own, which a least line would grow back on every line; one in a face the document does not ship, the default Helvetica's among them, or in one whose `hhea` extents, which the page measures by, are not the metrics its line is set on; a page zone's, a text box's, a stacked line and a line pair. The report names how many paragraphs of text are exact, once for the document (`line height`). 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 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 not owed or hung below its paragraph, as Word sets lines on a grid of its own, 0.12pt apart (measured), but carried on to the next paragraph's and settled with it once the two come to more; what is carried stays on its page, starts again where the space owed is set anew — a band's or a column's layer, below a band — and in each cell, whose end settles what its own lines carry there, however little — what they fall short of the page owed below its last block, what they pass it by hung below the cell as a line hanging there is — and a paragraph that owed for its lines and whose pictures then grow them takes that back and carries on what the lines above it carried (measured: `Panel`'s card lines, each written a fiftieth of a point taller than the page's, stood a median 0.46pt low in Word and 0.39pt in LibreOffice on Windows; carried, 0.35 and 0.34pt). A list's item is settled with the page as a paragraph is, its lines carried with the paragraphs' around it (measured: `Panel`'s skills and projects, items of two lines and more, each line a fiftieth of a point taller than the page's, ran 0.21 and 0.35pt taller in Word and grew their cards by as much; items and cells' ends settled, `Panel` stood a median 0.21pt from the page in Word and in LibreOffice on Windows). 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 opening a column of a row of columns or of a stack's columns takes it from the space above the row, which the row is lifted out of, its other cells starting as much lower inside (one opening with a table below a hairline paragraph holding the lift) and a row held to a height held as much taller — only where the lift raises the first line half a point or more above where sharing the gaps out sets it, a smaller rise leaving a cell's last paragraph high with nothing under it to lower it into; not on a page's first row or in an overlay (measured: `EditorialProposal`'s goal cells, three lines each, 1.46 and 0.56pt off the page in Word, stand within 0.16pt; `OrangeOps`' degree, two lines 0.45pt low and 0.45pt high, within 0.27pt). One with no room above it for the gap otherwise (a cell padded less, a gap too small) 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, and so do a band's later layer and the space below the band — a stack laying one layer over another, a shape container — which measure what follows them from the page too (measured: `ConsultingInvoice`'s contact lines, in a band, half a point low in Word and both LibreOffice builds and its address 0.3pt, within 0.03pt raised; a band's first paragraph or list item, where a band in the flow, a stack of one layer's included, opens its page, is not raised, as a paragraph's there is not). A line pair's shared line moves up into the space written above it, not a cell's padding, for both its halves, by the lesser of what they call for, where neither is moved by its position — moved for one alone, it would move the other too — and not where a half is cut to its own measure or the layout opens a page with the pair (measured: `ConsultingInvoice`'s bank details, a label and its value to a line, 0.7 to 0.8pt low in Word and both LibreOffice builds, stand 0.2 to 0.33pt low raised, the rest a drift all under its line-item table shares; `LumaStudioInvoice`'s, in Carlito, which both editors set higher than the export reckons, rise too far: in its short invoice the bank details stand 0.27pt high in Word and 0.34pt in LibreOffice, where they stood 0.03 and 0.14pt high). Not a line in another overlay — a canvas, a text box — whose writers measure what follows them from the page and pay nothing, though a stack of one layer is the flow, its lines raised and its rows lifted as there (measured: `SlateOrange`'s experience timeline, set in one, its employers' lines and their items 0.12 to 0.36pt nearer the page in Word and 0.2pt in LibreOffice, `SubscriptionInvoice`'s contact rows 0.12 to 0.24pt and 0.25pt); 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, the first of a stack of one layer it moves there whole among them or the first of a band in the flow that opens its 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. A line of its own Word sets higher than the page by a tenth of a point or more, and under half a point, is lowered by the space between it and what follows: its space above grows by what is left of that space once the block under it has taken its own of it — the gap between its lines, its own raise, the room its pictures reach into, a row's lift out of the space above its table — above that block, or below the line where a table or a cell's end follows, and the block under it stands where it did. What nothing gives — a block on a later page, whose space above Word drops, a block in an overlay, a page break, a block the layout moves to the next page, a column's later layer — it is not lowered by, and where the section's end leaves the space below a cell's last line out, the lowering that space gave is taken back with it. Not a list's item, which its own list lowers, nor a line in an overlay (a stack of one layer is the flow), one opening its page or a panel, cut to fit or not on one page (measured, with the rules below a heading hung as below: `LetterSidebarPortrait`'s lines, 0.41pt high by median in Word, stand within 0.07pt, `TimelineMinimal`'s within 0.05pt in Word and LibreOffice). What the space above cannot give of a raise, and a line Word sets half a point or more high, 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 in an exact line, as Poppins' is (a line as tall as its faces' own is a least line instead, which needs no position). 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 under its layer stack's column, 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, 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 exact 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, written exact and raised 4pt, stood 2.4pt high in LibreOffice; at its own height it is now a least line, which no position moves. 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; read off the table's rows, so a table with an `auto` column and a column edge no row of it shows has none, and its columns are the editor's (measured on one such table: Word set them as the page did, LibreOffice on Windows otherwise), and the report names it (`TableNode`) |
| 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, 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. The column already holds the next child's left margin, and a paragraph's own right side gives what the paragraph does not write as an indent; a paragraph set left takes what that does not give from the gap after its column, which stands where the page sets it: widened instead, it moved what stood right of it a point right in Word, `ReceiptModern`'s leaders and `EditorialProposal`'s signature lines. What neither holds comes out of the row's weight columns, so the row keeps its width, and what stands at the edges between them moves up to that much — `ReceiptModern`'s amounts at the row's end 0.84 to 1.1pt left in Word and 0.86 to 1.02pt in LibreOffice — which the report names (`right end`) unless the weight columns giving it hold only spacers or it is under a tenth of a point. A row with no weight column gives what its gaps hold and no more; one with no auto 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/DocxFontTable.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java
index 0d202e90e..53fc7c442 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java
@@ -141,6 +141,11 @@ static final class ShippedLines {
private final Map families;
private final Map lines = new java.util.HashMap<>();
+ /** The file of each face with a line whose character map is not read yet. */
+ private final Map files = new java.util.HashMap<>();
+ /** Each face's character map, read the first time a text is asked of it; empty where unreadable. */
+ private final Map> characters =
+ new java.util.HashMap<>();
private ShippedLines(Map families, List embedded) {
this.families = families;
@@ -148,11 +153,60 @@ private ShippedLines(Map families, List map = characters.get(face);
+ if (map == null) {
+ byte[] file = files.remove(face);
+ if (file == null) {
+ return false;
+ }
+ map = characterMapOf(file);
+ characters.put(face, map);
+ }
+ org.apache.fontbox.ttf.CmapLookup read = map.orElse(null);
+ return read != null && text.codePoints().allMatch(letter -> read.getGlyphId(letter) > 0);
+ }
+
+ /** A face's Unicode character map; empty where it holds none, or cannot be read. */
+ private static java.util.Optional characterMapOf(byte[] file) {
+ try (org.apache.fontbox.ttf.TrueTypeFont font = new org.apache.fontbox.ttf.TTFParser()
+ .parse(new org.apache.pdfbox.io.RandomAccessReadBuffer(file))) {
+ return java.util.Optional.ofNullable(font.getUnicodeCmapLookup(true));
+ } catch (IOException | RuntimeException unreadable) {
+ return java.util.Optional.empty();
+ }
+ }
+
/**
* The line a face is set in.
*
@@ -162,8 +216,14 @@ private ShippedLines(Map families, List faces) {
}
/**
- * One face of a family: which slot it fills, its bytes, how the part refers to it, and the
- * line an editor sets it in.
+ * One face of a family: which slot it fills, its file and the bytes the part stores, how the
+ * part refers to it, and the line an editor sets it in.
*/
private static final class Face {
private final String element;
+ private final byte[] font;
private final byte[] bytes;
private final String fontKey;
private final DocxNaturalLine line;
private String relationshipId;
- Face(String element, byte[] bytes, String fontKey, DocxNaturalLine line) {
+ Face(String element, byte[] font, byte[] bytes, String fontKey, DocxNaturalLine line) {
this.element = element;
+ this.font = font;
this.bytes = bytes;
this.fontKey = fontKey;
this.line = line;
@@ -226,6 +288,12 @@ String element() {
return element;
}
+ /** The face's own file, as read. */
+ byte[] font() {
+ return font;
+ }
+
+ /** The file as the part stores it, obfuscated. */
byte[] bytes() {
return bytes;
}
@@ -384,7 +452,7 @@ private static void addFace(List faces,
}
}
DocxFontEmbedding.Obfuscated obfuscated = DocxFontEmbedding.obfuscate(bytes);
- faces.add(new Face(slot.element(), obfuscated.bytes(), obfuscated.fontKey(), DocxNaturalLine.of(bytes)));
+ faces.add(new Face(slot.element(), bytes, obfuscated.bytes(), obfuscated.fontKey(), DocxNaturalLine.of(bytes)));
}
/**
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
index df85f0287..34e58ba7a 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
@@ -2,15 +2,24 @@
import com.demcha.compose.document.style.DocumentTextStyle;
import org.apache.poi.ooxml.util.POIXMLUnits;
+import org.apache.poi.xwpf.usermodel.XWPFAbstractNum;
+import org.apache.poi.xwpf.usermodel.XWPFNum;
+import org.apache.poi.xwpf.usermodel.XWPFNumbering;
import org.apache.xmlbeans.XmlCursor;
import org.apache.xmlbeans.XmlObject;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTNumPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTOnOff;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat;
+import java.math.BigInteger;
import java.util.Set;
+import java.util.function.Supplier;
/**
* How tall a paragraph's lines stand in an editor, and where its text stands in them, from what is
@@ -40,12 +49,21 @@
* in both places — no higher than its text, its faces' line gap included, no lower than its text's
* descent — stands its text where it would without them (see {@link Reach#holdsItsPictures}).
*
+ * A Word list's marker is a run of its first line, set in its level's face and size over its
+ * paragraph's mark's, in Word and in LibreOffice: a marker as tall as the item's text leaves its
+ * line where it stands, and a taller one grows it (measured: a bullet, a white bullet and a small
+ * square in 8pt Lato, a bullet and an en dash in 9pt Fira Sans Condensed, left exact and least lines
+ * as tall, with a space or a tab after it, its face its level's or its mark's; a bullet at twice the
+ * size grew a least line by its height). A marker whose
+ * face holds no glyph for it is set in another face in each editor, and is not read: Word set an
+ * arrowhead beside 8pt Lato in Segoe UI Symbol, its least line 0.72pt taller.
+ *
* @author Artem Demchyshyn
*/
final class DocxGrownLines {
/** No face shipped, and no default style: every line is read as exact or not at all. */
- static final DocxGrownLines NONE = new DocxGrownLines(DocxFontTable.ShippedLines.NONE, null);
+ static final DocxGrownLines NONE = new DocxGrownLines(DocxFontTable.ShippedLines.NONE, null, () -> null);
/** What a paragraph may hold besides its runs and still be read. */
private static final Set PARAGRAPH_PARTS = Set.of("pPr", "r", "hyperlink", "bookmarkStart", "bookmarkEnd");
@@ -81,15 +99,25 @@ final class DocxGrownLines {
private final DocxFontTable.ShippedLines faces;
private final DocumentTextStyle defaults;
+ private final Supplier numbering;
+ /**
+ * The levels read, by an item's list and depth: a list's levels are written whole before its
+ * first item, and an item's lines are read several times.
+ */
+ private final java.util.Map levels = new java.util.HashMap<>();
/**
- * @param faces the line each face the document ships is set in
- * @param defaults the style the document's Normal style states, which a run that names no face
- * or size of its own is set in; null where it states none
+ * @param faces the line each face the document ships is set in
+ * @param defaults the style the document's Normal style states, which a run that names no face
+ * or size of its own is set in; null where it states none
+ * @param numbering the document's numbering part as it stands when a line is read, which a
+ * Word list's item takes its marker from; it supplies null where the document
+ * holds none yet
*/
- DocxGrownLines(DocxFontTable.ShippedLines faces, DocumentTextStyle defaults) {
+ DocxGrownLines(DocxFontTable.ShippedLines faces, DocumentTextStyle defaults, Supplier numbering) {
this.faces = faces;
this.defaults = defaults;
+ this.numbering = numbering;
}
/**
@@ -97,22 +125,22 @@ final class DocxGrownLines {
* line, in points.
*
* @param paragraph the written paragraph
- * @return the reach, its pictures' among it, or null where one of its runs is not read: a raise,
- * a face the document does not ship, a run Word sets right to left, a marker Word draws
- * itself, a drawing anchored beside pictures or holding none, a picture with no extent
- * written or whose run's position is not a number, or pictures in a
- * paragraph whose lines are broken, one of which may hold a picture alone, or that holds
- * no letter beside them
+ * @return the reach, its pictures' and its list marker's among it, or null where one of its runs
+ * is not read: a raise, a face the document does not ship, a run Word sets right to left,
+ * a list marker not read ({@link #withItsMarker}), a drawing anchored beside pictures or
+ * holding none, a picture with no extent written or whose run's position is not a
+ * number, or pictures in a paragraph whose lines are broken, one of which may hold a
+ * picture alone, or that holds no letter beside them
*/
Reach reachOf(CTP paragraph) {
CTPPr properties = paragraph.getPPr();
- if (properties != null && properties.isSetNumPr()) {
- // Word draws a list's marker in the numbering's own face.
- return null;
- }
+ Run mark = Run.of(properties != null && properties.isSetRPr() ? properties.getRPr() : null);
// The mark is set in what it states, or else in the Normal style, as a run is: Word counts
// it into the paragraph's last line.
- Reach reach = reachOf(properties != null && properties.isSetRPr() ? properties.getRPr() : null, Reach.NONE);
+ Reach reach = reachOf(mark, Reach.NONE, null);
+ if (reach != null && properties != null && properties.isSetNumPr()) {
+ reach = withItsMarker(properties.getNumPr(), mark, reach);
+ }
if (reach == null || !holdsOnly(paragraph, PARAGRAPH_PARTS)) {
return null;
}
@@ -155,7 +183,7 @@ Reach reachOf(CTP paragraph) {
lettered |= letter.getStringValue() != null && letter.getStringValue().codePoints()
.anyMatch(c -> !Character.isWhitespace(c) && !Character.isSpaceChar(c));
}
- reach = reachOf(text.isSetRPr() ? text.getRPr() : null, reach);
+ reach = reachOf(Run.of(text.isSetRPr() ? text.getRPr() : null), reach, null);
if (reach == null) {
return null;
}
@@ -274,8 +302,76 @@ Line lineOf(CTP paragraph) {
return new Line(height, height - reach.below());
}
- private Reach reachOf(XmlObject runProperties, Reach so) {
- Run run = Run.of(runProperties);
+ /**
+ * A Word list's item's reach with its marker's: a run of its first line, in its level's
+ * properties over its paragraph's mark's, as Word and LibreOffice set it.
+ *
+ * @return the reach, or null where the marker is not read: its level not found by the item's
+ * number and depth, or its list's levels overridden or a numbering style's; a level that
+ * writes a number or a picture; one whose letters its face holds no glyph of its own
+ * for, which each editor sets in a face of its own; or one whose face is not read
+ * ({@link #reachOf(Run, Reach, String)})
+ */
+ private Reach withItsMarker(CTNumPr item, Run mark, Reach reach) {
+ CTLvl level = levelOf(item);
+ if (level == null || !level.isSetNumFmt() || level.getNumFmt().getVal() != STNumberFormat.BULLET
+ || level.isSetLvlPicBulletId() || !level.isSetLvlText() || level.getLvlText().getVal() == null
+ // A percent sign before a digit names a level's number in a level's text; any is read so.
+ || level.getLvlText().getVal().contains("%")) {
+ return null;
+ }
+ Run over = Run.of(level.isSetRPr() ? level.getRPr() : null);
+ return over == null ? null : reachOf(over.over(mark), reach, level.getLvlText().getVal());
+ }
+
+ /** The level of the numbering part a list's item names, or null where none, or one overridden. */
+ private CTLvl levelOf(CTNumPr item) {
+ XWPFNumbering part = numbering.get();
+ if (part == null || !item.isSetNumId() || item.getNumId().getVal() == null) {
+ return null;
+ }
+ BigInteger depth = item.isSetIlvl() ? item.getIlvl().getVal() : BigInteger.ZERO;
+ String key = item.getNumId().getVal() + "/" + depth;
+ CTLvl read = levels.get(key);
+ if (read == null) {
+ read = levelOf(part, item.getNumId().getVal(), depth);
+ if (read != null) {
+ levels.put(key, read);
+ }
+ }
+ return read;
+ }
+
+ private static CTLvl levelOf(XWPFNumbering part, BigInteger numId, BigInteger depth) {
+ XWPFNum num = part.getNum(numId);
+ if (num == null || num.getCTNum().sizeOfLvlOverrideArray() > 0 || num.getCTNum().getAbstractNumId() == null) {
+ return null;
+ }
+ XWPFAbstractNum definition = part.getAbstractNum(num.getCTNum().getAbstractNumId().getVal());
+ if (definition == null) {
+ return null;
+ }
+ CTAbstractNum list = definition.getCTAbstractNum();
+ if (list.isSetNumStyleLink() || list.isSetStyleLink()) {
+ return null;
+ }
+ for (CTLvl level : list.getLvlList()) {
+ if (depth.equals(level.getIlvl())) {
+ return level;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * {@code so} with a run's reach: as high and as deep as the higher and deeper of the two, its
+ * descent the shallower.
+ *
+ * @param letters the run's letters, where they must each be its face's own glyph, or null
+ * @return the reach, or null where the run is not read: a raise or a face the document does not
+ * ship, or does not hold {@code letters}
+ */
+ private Reach reachOf(Run run, Reach so, String letters) {
if (run == null) {
return null;
}
@@ -291,6 +387,10 @@ private Reach reachOf(XmlObject runProperties, Reach so) {
if (line == null || !(size > 0)) {
return null;
}
+ if (letters != null && !(run.family() != null ? faces.holds(run.family(), slot, letters)
+ : faces.holds(defaults.fontName(), slot, letters))) {
+ return null;
+ }
return new Reach(Math.max(so.above(), line.ascent() * size), Math.max(so.below(), line.descent() * size),
Math.min(so.shallowest(), line.descent() * size), so.pictureTop(), so.pictureFoot());
}
@@ -423,6 +523,12 @@ static Run of(XmlObject properties) {
return new Run(family, halfPoints, bold, italic);
}
+ /** What these properties say over {@code under}'s: theirs where they state it, else its. */
+ Run over(Run under) {
+ return new Run(family != null ? family : under.family, halfPoints > 0 ? halfPoints : under.halfPoints,
+ bold != null ? bold : under.bold, italic != null ? italic : under.italic);
+ }
+
private static String attribute(XmlObject element, String name) {
try (XmlCursor cursor = element.newCursor()) {
return cursor.getAttributeText(new javax.xml.namespace.QName(W_NAMESPACE, name));
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 9daa98a57..aae170c89 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
@@ -1271,7 +1271,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
if (index == 0) {
writeStylesPart(document);
grownLines = new DocxGrownLines(DocxFontTable.write(document, whole, zoneContentsIn(sections),
- bandStylesIn(sections), fonts, report), documentDefaultStyle);
+ bandStylesIn(sections), fonts, report), documentDefaultStyle, document::getNumbering);
applyMetadata(document, metadataOf(sections));
reportUnwrittenOutputOptions(sections);
}
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 98daf3014..9e375787f 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
@@ -40,7 +40,8 @@ void aListInACellIsWrittenAtTheHeightThePageSetsItsLines() throws Exception {
for (String item : List.of("Setup", "Training", "Support")) {
XWPFParagraph composed = inACell(cell, item);
- assertThat(spacing(composed).getLineRule()).as(item).hasToString("exact");
+ assertThat(spacing(composed).getLineRule()).as(item).hasToString("atLeast")
+ .hasToString(spacing(inTheBody(flow, item)).getLineRule().toString());
assertThat(line(composed)).as(item + ", as in the flow").isEqualTo(line(inTheBody(flow, item)));
}
}
@@ -48,14 +49,27 @@ 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. Its
- // lines then stand high in Word, and the space above lowers them, which the item under it
- // gives back.
+ // As in the flow: the gap goes into the item's line and comes off the space above it. A
+ // least line keeps the gap above its text, where the page sets it, and nothing is lowered.
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);
+
+ assertThat(DocxTwips.of(spacing(two).getLine()) - DocxTwips.of(spacing(one).getLine()))
+ .as("2pt between its lines").isEqualTo(40);
+ assertThat(spacing(two).getLineRule()).hasToString("atLeast");
+ assertThat(before(two)).as("3pt above it, less the gap").isEqualTo(60 - 40);
+ assertThat(before(inACell(document, "Support"))).as("3pt above it").isEqualTo(60);
+ }
+ // An exact one's lines stand high in Word, and the space above lowers them, which the item
+ // under it gives back: its marker one Lato holds no glyph for, which Word draws in a face of
+ // its own.
+ try (XWPFDocument document = inACellBeside(new ListBuilder().name("Items").marker("➤").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
@@ -211,7 +225,9 @@ void aTreeOfItemsInACellIsWrittenOnItsLines() throws Exception {
assertThat(items).as("the premise: both items written").hasSize(2);
for (XWPFParagraph item : items) {
- assertThat(spacing(item).getLineRule()).as(item.getText()).hasToString("exact");
+ // A Word list's items, their markers Word's, each depth's in Lato, which holds it.
+ assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: " + item.getText()).isTrue();
+ assertThat(spacing(item).getLineRule()).as(item.getText()).hasToString("atLeast");
}
}
// With hangingIndent, each item's marker in a column of its own, its depth's.
@@ -222,7 +238,8 @@ void aTreeOfItemsInACellIsWrittenOnItsLines() throws Exception {
assertThat(items).as("the premise: both items written").hasSize(2);
for (XWPFParagraph item : items) {
- assertThat(spacing(item).getLineRule()).as(item.getText()).hasToString("exact");
+ assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: " + item.getText()).isTrue();
+ assertThat(spacing(item).getLineRule()).as(item.getText()).hasToString("atLeast");
}
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
index cb9eec859..3394cf787 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
@@ -141,8 +141,9 @@ void aParagraphSeatedOffItsBaselineIsWrittenExact() throws Exception {
}
@Test
- void aListsItemGrowsWhereItsMarkerIsItsTextAndNotWhereWordDrawsIt() throws Exception {
- // An item of runs, under a hanging marker, has its marker written as its first run.
+ void aListsItemGrowsWhetherItsMarkerIsItsTextOrWordDrawsIt() throws Exception {
+ // An item of runs, under a hanging marker, has its marker written as its first run; a
+ // Word list's has Word draw it in its level's face, the list's.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addList(list -> list.textStyle(face(FontName.LATO, 10)).marker("•").hangingIndent(true).markerGap(6)
.addItem(text -> text.style("Written", face(FontName.LATO, 10))))
@@ -153,8 +154,22 @@ void aListsItemGrowsWhereItsMarkerIsItsTextAndNotWhereWordDrawsIt() throws Excep
assertThat(written.getText()).as("the premise: its marker its text").contains("Written");
assertThat(spacing(written).getLineRule()).isEqualTo(STLineSpacingRule.AT_LEAST);
assertThat(drawn.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
- assertThat(spacing(drawn).getLineRule()).as("its marker in the numbering's own face")
- .isEqualTo(STLineSpacingRule.EXACT);
+ assertThat(spacing(drawn).getLineRule()).isEqualTo(STLineSpacingRule.AT_LEAST);
+ assertThat(positions(drawn)).containsOnly(0L);
+ }
+ }
+
+ @Test
+ void aWordListsItemWhoseMarkerItsFaceHoldsNoGlyphForStaysExact() throws Exception {
+ // Lato holds no arrowhead: Word draws it in a face of its own, whose line is taller.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addList(list -> list.textStyle(face(FontName.LATO, 10)).marker("➤").items("Arrowed")))) {
+ XWPFParagraph arrowed = paragraph(document, "Arrowed");
+
+ assertThat(arrowed.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(document.getNumbering().getAbstractNums().get(0).getCTAbstractNum().getLvlArray(0)
+ .getLvlText().getVal()).as("the premise: the arrowhead its marker").isEqualTo("➤");
+ assertThat(spacing(arrowed).getLineRule()).isEqualTo(STLineSpacingRule.EXACT);
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
index bc70fa645..066cdff46 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
@@ -3,14 +3,19 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.XWPFAbstractNum;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFNumbering;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat;
import java.math.BigInteger;
import java.util.List;
@@ -146,7 +151,7 @@ void aParagraphHoldingWhatALineIsNotReadByHasNoReach() throws Exception {
assertThat(lines.reachOf(script.getCTP())).as("a script").isNull();
assertThat(lines.reachOf(rightToLeft.getCTP())).as("right to left, in its complex-script face").isNull();
assertThat(lines.reachOf(substituted.getCTP())).as("a face the document does not ship").isNull();
- assertThat(lines.reachOf(listed.getCTP())).as("a marker Word draws in the numbering's face").isNull();
+ assertThat(lines.reachOf(listed.getCTP())).as("a list's item naming no list").isNull();
assertThat(DocxGrownLines.NONE.reachOf(paragraph().getCTP())).as("nothing shipped").isNull();
}
@@ -257,6 +262,126 @@ void aPictureRunHoldingAnythingButPicturesOfAWrittenSizeIsNotRead() throws Excep
assertThat(lines.reachOf(measured.getCTP())).as("a position in points, not half points").isNull();
}
+ @Test
+ void aWordListsItemIsReadWithItsMarkerInItsLevelsFaceOverItsMarks() throws Exception {
+ try (XWPFDocument numbered = new XWPFDocument()) {
+ DocxGrownLines lines = shipping(numbered::getNumbering, LATO, LATO, face(FontName.BARLOW, 10));
+ XWPFParagraph plain = lato(240, 10);
+ XWPFParagraph stated = item(numbered, "•", level -> face(level, "Lato", 20));
+ XWPFParagraph ofTheMark = item(numbered, "•", level -> { });
+ XWPFParagraph larger = item(numbered, "•", level -> face(level, "Lato", 40));
+ // A white bullet: Lato holds one, Barlow none.
+ XWPFParagraph ofABarlowMark = item(numbered, "◦", level -> { });
+ markIn(ofABarlowMark, "Barlow");
+ XWPFParagraph latoOverABarlowMark = item(numbered, "◦", level -> face(level, "Lato", 20));
+ markIn(latoOverABarlowMark, "Barlow");
+
+ assertThat(lines.reachOf(stated.getCTP())).as("a bullet in the text's face and size")
+ .isEqualTo(lines.reachOf(plain.getCTP()));
+ assertThat(lines.mayGrow(stated.getCTP())).isTrue();
+ assertThat(lines.reachOf(ofTheMark.getCTP())).as("its level stating none, in its mark's, Normal's Lato")
+ .isEqualTo(lines.reachOf(plain.getCTP()));
+ assertThat(lines.mayGrow(ofTheMark.getCTP())).isTrue();
+ assertThat(lines.reachOf(larger.getCTP()).above()).as("a 20pt bullet's line in the first")
+ .isCloseTo(LATO_UP * 20, within(0.01));
+ assertThat(lines.mayGrow(larger.getCTP())).isFalse();
+ assertThat(lines.reachOf(ofABarlowMark.getCTP())).as("in its mark's Barlow, which holds no white bullet")
+ .isNull();
+ assertThat(lines.reachOf(latoOverABarlowMark.getCTP())).as("in its level's Lato over its mark's Barlow")
+ .isNotNull();
+ }
+ }
+
+ /** Sets a paragraph's mark in {@code family}, at 10pt. */
+ private static void markIn(XWPFParagraph paragraph, String family) {
+ var mark = paragraph.getCTP().getPPr().isSetRPr() ? paragraph.getCTP().getPPr().getRPr()
+ : paragraph.getCTP().getPPr().addNewRPr();
+ mark.addNewRFonts().setAscii(family);
+ mark.addNewSz().setVal(BigInteger.valueOf(20));
+ }
+
+ @Test
+ void aWordListsMarkerNotSetInItsOwnFaceIsNotRead() throws Exception {
+ try (XWPFDocument numbered = new XWPFDocument()) {
+ DocxGrownLines lines = shipping(numbered::getNumbering, LATO, LATO);
+ XWPFParagraph arrowhead = item(numbered, "➤", level -> face(level, "Lato", 20));
+ XWPFParagraph numberedByWord = item(numbered, "%1.", level -> face(level, "Lato", 20));
+ XWPFParagraph decimal = item(numbered, "1.", level -> {
+ face(level, "Lato", 20);
+ level.getNumFmt().setVal(STNumberFormat.DECIMAL);
+ });
+ XWPFParagraph bold = item(numbered, "•", level -> face(level, "Lato", 20).addNewB());
+ XWPFParagraph raised = item(numbered, "•", level -> face(level, "Lato", 20).addNewPosition()
+ .setVal(BigInteger.valueOf(2)));
+ XWPFParagraph pictured = item(numbered, "•", level -> {
+ face(level, "Lato", 20);
+ level.addNewLvlPicBulletId().setVal(BigInteger.ZERO);
+ });
+ XWPFParagraph deeper = item(numbered, "•", level -> face(level, "Lato", 20));
+ deeper.getCTP().getPPr().getNumPr().getIlvl().setVal(BigInteger.ONE);
+ XWPFParagraph overridden = item(numbered, "•", level -> face(level, "Lato", 20));
+ numbered.getNumbering().getNum(overridden.getCTP().getPPr().getNumPr().getNumId().getVal())
+ .getCTNum().addNewLvlOverride().setIlvl(BigInteger.ZERO);
+ XWPFParagraph linked = item(numbered, "•", level -> face(level, "Lato", 20));
+ definitionOf(numbered, linked).addNewNumStyleLink().setVal("Bullets");
+ XWPFParagraph styled = item(numbered, "•", level -> face(level, "Lato", 20));
+ definitionOf(numbered, styled).addNewStyleLink().setVal("Bullets");
+ XWPFParagraph unknown = item(numbered, "•", level -> face(level, "Lato", 20));
+ unknown.getCTP().getPPr().getNumPr().getNumId().setVal(BigInteger.valueOf(99));
+ XWPFParagraph unnumbered = item(numbered, "•", level -> face(level, "Lato", 20));
+ DocxGrownLines withoutNumbering = shipping(LATO, LATO);
+
+ assertThat(lines.reachOf(arrowhead.getCTP())).as("an arrowhead Lato holds no glyph for").isNull();
+ assertThat(lines.reachOf(numberedByWord.getCTP())).as("a level's number").isNull();
+ assertThat(lines.reachOf(decimal.getCTP())).as("a level Word numbers").isNull();
+ assertThat(lines.reachOf(bold.getCTP())).as("in bold, a face the document does not ship").isNull();
+ assertThat(lines.reachOf(raised.getCTP())).as("raised").isNull();
+ assertThat(lines.reachOf(pictured.getCTP())).as("a picture bullet").isNull();
+ assertThat(lines.reachOf(deeper.getCTP())).as("a depth its list does not define").isNull();
+ assertThat(lines.reachOf(overridden.getCTP())).as("a level overridden").isNull();
+ assertThat(lines.reachOf(linked.getCTP())).as("a list a numbering style defines").isNull();
+ assertThat(lines.reachOf(styled.getCTP())).as("a list defining a numbering style").isNull();
+ assertThat(lines.reachOf(unknown.getCTP())).as("a list the numbering part does not hold").isNull();
+ assertThat(withoutNumbering.reachOf(unnumbered.getCTP())).as("no numbering part").isNull();
+ }
+ }
+
+ /** The list definition a numbered item's list takes its levels from. */
+ private static CTAbstractNum definitionOf(XWPFDocument numbered, XWPFParagraph item) {
+ var num = numbered.getNumbering().getNum(item.getCTP().getPPr().getNumPr().getNumId().getVal());
+ return numbered.getNumbering().getAbstractNum(num.getCTNum().getAbstractNumId().getVal()).getCTAbstractNum();
+ }
+
+ /**
+ * A list's item of one 10pt Lato line, exact at 240 twips, numbered by a new list of
+ * {@code numbered} whose one level writes {@code marker} as a bullet, as {@code level} states it.
+ */
+ private static XWPFParagraph item(XWPFDocument numbered, String marker,
+ java.util.function.Consumer level) {
+ CTAbstractNum definition = CTAbstractNum.Factory.newInstance();
+ definition.setAbstractNumId(BigInteger.valueOf(numbered.createNumbering().getAbstractNums().size()));
+ CTLvl first = definition.addNewLvl();
+ first.setIlvl(BigInteger.ZERO);
+ first.addNewNumFmt().setVal(STNumberFormat.BULLET);
+ first.addNewLvlText().setVal(marker);
+ level.accept(first);
+ BigInteger numId = numbered.getNumbering().addNum(
+ numbered.getNumbering().addAbstractNum(new XWPFAbstractNum(definition)));
+ XWPFParagraph paragraph = lato(240, 10);
+ paragraph.setNumID(numId);
+ paragraph.setNumILvl(BigInteger.ZERO);
+ return paragraph;
+ }
+
+ /** States a level's marker's face and size, {@code halfPoints} half points. */
+ private static org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr face(CTLvl level, String family,
+ int halfPoints) {
+ var properties = level.addNewRPr();
+ properties.addNewRFonts().setAscii(family);
+ properties.addNewSz().setVal(BigInteger.valueOf(halfPoints));
+ return properties;
+ }
+
/**
* Adds a run holding a picture {@code points} tall set in the line, its run's position
* {@code position} half points and, where not 0, its size {@code halfPoints}.
@@ -282,6 +407,12 @@ private static void picture(XWPFParagraph paragraph, double points, int position
* {@code normal}, as an export does: Normal is the style the most of its text is set in.
*/
private static DocxGrownLines shipping(DocumentTextStyle normal, DocumentTextStyle... faces) throws Exception {
+ return shipping(() -> null, normal, faces);
+ }
+
+ /** The same, its lists' items numbered by {@code numbering}. */
+ private static DocxGrownLines shipping(java.util.function.Supplier numbering,
+ DocumentTextStyle normal, DocumentTextStyle... faces) throws Exception {
var graph = DocxExports.graphOf(page -> {
page.addParagraph(p -> p.text("Normal").textStyle(normal));
for (DocumentTextStyle face : faces) {
@@ -290,7 +421,7 @@ private static DocxGrownLines shipping(DocumentTextStyle normal, DocumentTextSty
});
try (XWPFDocument document = new XWPFDocument()) {
return new DocxGrownLines(DocxFontTable.write(document, graph, List.of(), List.of(), null,
- new DocxExportReport.Builder()), normal);
+ new DocxExportReport.Builder()), normal, numbering);
}
}
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 b0de24bc9..c23f26f98 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
@@ -37,6 +37,11 @@ class DocxLayerColumnsTest {
private static final double MAIN = PAGE_WIDTH - 2 * MARGIN - SIDEBAR;
/** What a line of the default text in a column is raised into the space above it. */
private static final long RAISE = DocxExports.DEFAULT_LINE_RAISE;
+ /**
+ * A list's marker Lato holds no glyph for, which Word draws in a face of its own: its items stay
+ * exact, and Word sets Lato's high ({@link DocxGrownLines}).
+ */
+ private static final String ARROWHEAD = "➤";
/** A paragraph long enough to fill its column, so the stack is as wide as the page's. */
private static final String LONG = "Led the delivery of a document platform across three teams, "
+ "from the first prototype to the release that replaced the old reporting stack.";
@@ -110,7 +115,7 @@ void aLaterLayerTakesBackTheLoweringOfTheListEndingTheLayerAboveIt() throws Exce
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")
+ .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addList(list -> list.name("Duties").marker(ARROWHEAD)
.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
@@ -130,7 +135,7 @@ void aLaterLayerTakesBackTheLoweringOfTheListEndingTheLayerAboveIt() throws Exce
}
// 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")
+ .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addList(list -> list.name("Duties").marker(ARROWHEAD)
.textStyle(lato24).margin(DocumentInsets.top(20)).items("Heading"))
.addParagraph(p -> p.text("Under").textStyle(lato).margin(DocumentInsets.top(10)))),
LayerAlign.TOP_LEFT)
@@ -197,7 +202,7 @@ void aColumnEndingWithAListKeepsItsLoweringWhereTheRowOrTheGapUnderTheStackTakes
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)
+ .layer(column("Sidebar", 0, MAIN, side -> side.addList(list -> list.marker(ARROWHEAD).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);
@@ -209,7 +214,7 @@ void aColumnEndingWithAListKeepsItsLoweringWhereTheRowOrTheGapUnderTheStackTakes
// The sidebar the taller column, the hang is below the stack: a paragraph under it gives it
// back, and at the end of the document it is taken back.
Consumer tallest = stack -> stack
- .layer(column("Sidebar", 0, MAIN, side -> side.addList(list -> list.textStyle(lato24)
+ .layer(column("Sidebar", 0, MAIN, side -> side.addList(list -> list.marker(ARROWHEAD).textStyle(lato24)
.margin(DocumentInsets.top(10)).items("Heading"))), LayerAlign.TOP_LEFT)
.layer(column("Main", SIDEBAR, 0, main -> main.addParagraph("Side")), LayerAlign.TOP_LEFT);
try (Export export = export(tallest, true)) {
@@ -224,7 +229,7 @@ void aColumnEndingWithAListKeepsItsLoweringWhereTheRowOrTheGapUnderTheStackTakes
}
// 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)
+ .layer(column("Sidebar", 0, MAIN, side -> side.addList(list -> list.marker(ARROWHEAD).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))) {
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 38789f9ee..2d2cd62a3 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
@@ -46,6 +46,13 @@ class DocxListItemLowerTest {
* nothing of the space above the list to be lowered by ({@link DocxParagraphLowerTest}).
*/
private static final DocumentTextStyle LEAD = DocumentTextStyle.builder().fontName(FontName.GOTHIC_A1).size(10).build();
+ /**
+ * The lists' marker: one Lato and Spectral hold no glyph for, which Word draws in a face of its
+ * own, so their items stay exact and Word sets them high or low ({@link DocxGrownLines}). An
+ * item in a face that holds its marker, as tall as its faces' own line, is a least line, which
+ * stands where the page sets it.
+ */
+ private static final String ARROWHEAD = "➤";
@Test
void anItemWhoseMarkerWordDrawsIsLoweredByTheSpaceAboveItAndWhatFollowsGivesItBack() throws Exception {
@@ -53,7 +60,7 @@ void anItemWhoseMarkerWordDrawsIsLoweredByTheSpaceAboveItAndWhatFollowsGivesItBa
// 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(LEAD))
- .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.marker(ARROWHEAD).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);
@@ -69,13 +76,31 @@ void anItemWhoseMarkerWordDrawsIsLoweredByTheSpaceAboveItAndWhatFollowsGivesItBa
assertThat(exported.report().bySubject()).doesNotContainKey("space above");
}
+ @Test
+ void anItemWhoseFaceHoldsItsBulletIsALeastLineAndNotLowered() throws Exception {
+ // The same item under Word's bullet, which Lato holds: a least line, its text on its
+ // foot where the page sets it, so its space above is as written and the paragraph under
+ // the list takes nothing back.
+ Exported exported = export(page -> page
+ .addParagraph(p -> p.text("Lead").textStyle(LEAD))
+ .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");
+
+ assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
+ assertThat(spacing(item).getLineRule()).hasToString("atLeast");
+ assertThat(before(item)).isEqualTo(200);
+ assertThat(position(item)).isZero();
+ assertThat(before(paragraph(exported, "Body"))).isEqualTo(200);
+ }
+
@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(LEAD))
- .addList(list -> list.textStyle(lato(24)).itemSpacing(10).margin(DocumentInsets.top(10))
+ .addList(list -> list.marker(ARROWHEAD).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");
@@ -94,7 +119,7 @@ void itemsWithNoSpaceAboveThemToGiveItBackTakeItBackByTheirOwnLowering() throws
// 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(LEAD))
- .addList(list -> list.textStyle(lato(10)).lineSpacing(3).itemSpacing(3).margin(DocumentInsets.top(10))
+ .addList(list -> list.marker(ARROWHEAD).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");
@@ -114,7 +139,7 @@ void anItemSetHigherThanTheOneAboveItIsLoweredByTheDifference() throws Exception
// much lower already, so it is lowered by the rest.
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
- .addList(list -> list.textStyle(lato(24)).lineSpacing(3).itemSpacing(3).margin(DocumentInsets.top(10))
+ .addList(list -> list.marker(ARROWHEAD).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");
@@ -133,7 +158,7 @@ void anItemThatCannotGiveTheLoweringBackHasTheItemAboveTakeItBack() throws Excep
// 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))
+ Consumer items = list -> list.marker(ARROWHEAD).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(LEAD))
@@ -158,7 +183,7 @@ void anItemLoweredIntoTheSpaceBelowARowEndingTheDocumentIsTakenBack() throws Exc
// 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))
+ .addSection(column -> column.add(new ListBuilder().marker(ARROWHEAD).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 -> {
@@ -179,7 +204,7 @@ void anItemLoweredIntoTheSpaceBelowARowEndingTheDocumentIsTakenBack() throws Exc
Exported halfAPoint = export(page -> {
page.addParagraph(p -> p.text("Lead").textStyle(LEAD));
page.addRow(r -> r
- .addSection(column -> column.add(new ListBuilder().textStyle(lato(24))
+ .addSection(column -> column.add(new ListBuilder().marker(ARROWHEAD).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)))));
});
@@ -194,7 +219,7 @@ void anItemUnderOneLoweredGivesItBackBeforeItRises() throws Exception {
// 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(LEAD))
- .addList(list -> list.textStyle(spectral(10)).lineSpacing(15).itemSpacing(20)
+ .addList(list -> list.marker(ARROWHEAD).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()
@@ -218,7 +243,7 @@ void whatTheSpaceAboveWhatFollowsCannotGiveBackIsNamed() throws Exception {
// paragraph lower too, and the report says so.
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
- .addList(list -> list.name("Duties").textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.name("Duties").marker(ARROWHEAD).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);
@@ -235,7 +260,7 @@ void anItemLoweredAtTheEndOfACellTakesItOutOfTheSpaceTheCellOwesUnderIt() throws
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))
+ .padding(DocumentInsets.of(8)).addParagraph("Title").addList(list -> list.marker(ARROWHEAD).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);
@@ -257,7 +282,7 @@ void anItemLoweredAtTheEndOfACardTakesItOutOfTheCardsPaddingBelow() throws Excep
.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"))));
+ .addList(list -> list.marker(ARROWHEAD).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);
@@ -275,7 +300,7 @@ void anItemOpeningACardIsNotLowered() throws Exception {
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"))));
+ .padding(DocumentInsets.of(8)).addList(list -> list.marker(ARROWHEAD).textStyle(lato(24)).items("Inside"))));
XWPFTableCell panel = exported.document().getTables().get(0).getRow(0).getCell(0);
XWPFParagraph inside = panel.getParagraphs().get(0);
@@ -291,7 +316,7 @@ 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(LEAD))
- .addList(list -> list.textStyle(lato(24)).itemSpacing(10).margin(DocumentInsets.top(10))
+ .addList(list -> list.marker(ARROWHEAD).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);
@@ -306,7 +331,7 @@ void theLastItemOfAListEndingATablesComposedCellIsTakenBack() throws Exception {
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()))))) {
+ .marker(ARROWHEAD).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);
@@ -323,7 +348,7 @@ void anItemLoweredAboveAParagraphTheLayoutOpensAPageWithIsTakenBack() throws Exc
session.pageFlow(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
.spacer(1, 480)
- .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.marker(ARROWHEAD).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);
@@ -344,7 +369,7 @@ void anItemLoweredIntoTheSpaceAboveAParagraphStaysLoweredWhereALaterBlockOpensAP
session.pageFlow(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
.spacer(1, 440)
- .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.marker(ARROWHEAD).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);
@@ -363,7 +388,7 @@ void anItemLoweredIntoTheSpaceAboveAParagraphStaysLoweredWhereALaterBlockOpensAP
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))
+ Consumer lowered = column -> column.add(new ListBuilder().marker(ARROWHEAD).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)));
@@ -385,7 +410,7 @@ void anItemLoweredAtTheEndOfARowsCellIsTakenBackWhereTheBlockUnderTheRowOpensAPa
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")));
+ .addList(list -> list.marker(ARROWHEAD).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);
}
@@ -395,9 +420,9 @@ 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))
+ .addSection(column -> column.add(new ListBuilder().marker(ARROWHEAD).textStyle(lato(24))
.margin(DocumentInsets.top(10)).items("Heading").build()))
- .addSection(column -> column.add(new ListBuilder().textStyle(lato(24))
+ .addSection(column -> column.add(new ListBuilder().marker(ARROWHEAD).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);
@@ -406,9 +431,9 @@ void theItemsOfListsSideBySideInARowAreEachTakenBack() throws Exception {
@Test
void anItemLoweredAboveAListTheLayoutOpensAPageWithIsTakenBack() throws Exception {
- assertThat(flowItemAbove(page -> page.addList(list -> list.name("Next").textStyle(lato(10))
+ assertThat(flowItemAbove(page -> page.addList(list -> list.name("Next").marker(ARROWHEAD).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))
+ assertThat(flowItemAbove(page -> page.addList(list -> list.name("Next").marker(ARROWHEAD).textStyle(lato(10))
.margin(DocumentInsets.top(50)).items("Next item")))).isEqualTo(200);
}
@@ -418,8 +443,8 @@ void aBlockOnALaterPageTakesNothingOfTheHangTheListAboveLeft() throws Exception
// whole, in the line holding its top edge, and nothing of a hang taken back is named.
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD)).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")));
+ .addList(list -> list.marker(ARROWHEAD).textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.marker(ARROWHEAD).textStyle(lato(10)).margin(DocumentInsets.top(50)).items("Next item")));
XWPFParagraph next = paragraph(exported, "Next item");
assertThat(Math.round(line(paragraphBefore(exported.document(), next)) * 20)).as("its 50pt, whole")
@@ -449,7 +474,7 @@ void anItemLoweredAboveABandKeepsWhatTheBandsSpaceAboveTakesAndTheRestIsTakenBac
private static long aboveABand(double gap) throws Exception {
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
- .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"))
+ .addList(list -> list.marker(ARROWHEAD).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()))
@@ -463,7 +488,7 @@ private static long flowItemAbove(Consumer next) throws Excepti
try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
session.pageFlow(page -> {
page.addParagraph(p -> p.text("Lead").textStyle(LEAD)).spacer(1, 460)
- .addList(list -> list.textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"));
+ .addList(list -> list.marker(ARROWHEAD).textStyle(lato(24)).margin(DocumentInsets.top(10)).items("Heading"));
next.accept(page);
});
try (XWPFDocument document = new XWPFDocument(
@@ -518,7 +543,7 @@ private static long latoLowering() {
void anItemLoweredAboveATableTakesItOutOfTheSpaceAboveTheTable() throws Exception {
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
- .addList(list -> list.textStyle(lato(24)).margin(new DocumentInsets(10, 0, 10, 0)).items("Heading"))
+ .addList(list -> list.marker(ARROWHEAD).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");
@@ -534,7 +559,7 @@ void anItemWordSetsHighByLessThanHalfAPointIsNotLowered() throws Exception {
// nothing, the space above does not either.
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
- .addList(list -> list.textStyle(lato(10)).margin(DocumentInsets.top(10)).items("Item")));
+ .addList(list -> list.marker(ARROWHEAD).textStyle(lato(10)).margin(DocumentInsets.top(10)).items("Item")));
XWPFParagraph item = paragraph(exported, "Item");
double high = -(0.8 * line(item) - LATO_ASCENT * 10);
@@ -548,7 +573,7 @@ void anItemTheLayoutSetsOnANewPageIsNotLoweredAndTheItemsAboveItAreTakenBack() t
// 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)
+ .addList(list -> list.name("Duties").marker(ARROWHEAD).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()
@@ -576,7 +601,7 @@ void theItemsOfAListTheLayoutSetsOverTwoPagesInACellKeepTheSpaceAboveThem() thro
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)
+ .addList(list -> list.name("Duties").marker(ARROWHEAD).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"))
@@ -598,7 +623,7 @@ void anItemInAStackOfOneLayerIsLoweredAsInTheFlow() throws Exception {
Exported exported = export(page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
.addLayerStack(stack -> stack.name("Entry")
- .layer(new ListBuilder().textStyle(lato(24)).margin(DocumentInsets.top(10))
+ .layer(new ListBuilder().marker(ARROWHEAD).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");
@@ -616,7 +641,7 @@ void theFirstItemOfAStackOfOneLayerTheLayoutMovesToANewPageIsNotLowered() throws
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)
+ .layer(new ListBuilder().name("Duties").marker(ARROWHEAD).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()
@@ -648,7 +673,7 @@ void anItemInAStackOverADrawingKeepsTheSpaceAboveIt() throws Exception {
.addParagraph(p -> p.text("Above").textStyle(LEAD))
.addLayerStack(stack -> stack.name("Stack")
.back(new ShapeBuilder().name("Backdrop").size(200, 80).build())
- .layer(new ListBuilder().textStyle(lato(24)).margin(DocumentInsets.top(10))
+ .layer(new ListBuilder().marker(ARROWHEAD).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");
From 2bbe3d317c724eaf9893f36d719fbe8fe06c3f1e Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 09:11:23 +0000
Subject: [PATCH 2/3] docs(docx): say why some least list and picture lines
stand lower
- The CHANGELOG said OrangeOps', SerifHeadline's and TealPulse's least
lines stood lower because the space above did not take their gap, and
within the quarter point a least line is held to. The export reckons
them within 0.06pt of the page, and Word and LibreOffice stand them
about a quarter point lower, the lowest 0.42pt; as exact lines the
reckoning was as far off the other way. The entries now say so.
- DocxGrownLines.levelOf's Javadoc names every level it does not read.
---
CHANGELOG.md | 19 +++++++++++--------
.../backend/semantic/docx/DocxGrownLines.java | 5 ++++-
2 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32ef0b802..61e705152 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,10 +30,13 @@ follow semantic versioning; release dates are ISO 8601.
nearer and 23 further in LibreOffice 24.2, of 678. The step from a line to the next differs
from the page's by more than 0.2pt for 80 of 456 steps in Word, from 95, and 88 of 482 in
LibreOffice, from 107.
- - Where an item's line is taller than its text, the gap between its lines written into it, and
- the space above it does not take the difference, its text stands lower than as an exact line,
- inside the quarter point a least line is held to: `OrangeOps`' duties and `SerifHeadline`'s
- items about 0.25pt lower in both editors, the lowest 0.42pt in Word.
+ - Some least items stand lower than they stood as exact lines: `OrangeOps`' duties and
+ `SerifHeadline`'s items, 8pt Lato on lines of 9.7 to 10.15pt in a table's cell, about 0.25pt
+ lower in both editors, the lowest 0.42pt in Word. The export reckons their least line within
+ 0.06pt of the page, and Word and LibreOffice stand it about a quarter point lower; as exact
+ lines the export reckoned them as much high, and they stood nearer the page,
+ from 0.35pt high to 0.18pt low. The quarter-point check on a least line reads the export's
+ reckoning, so it does not hold these lines; each stands within half a point.
- **A DOCX export's lines holding a picture within their text grow with it.**
- A line holding a picture set in it, an icon or a drawn bullet beside its words, was written at
@@ -56,10 +59,10 @@ follow semantic versioning; release dates are ISO 8601.
880; in Word 9 nearer and 55 further, of 882. The step from a line to the next differs from the
page's by more than 0.2pt for 34 of 489 steps in LibreOffice, from 36, and 29 of 472 in Word,
from 31.
- - Where such a line is taller than its text, the gap between lines written into it, and the
- space above it does not take the difference, its text stands lower than as an exact line:
- `TealPulse`'s duties 0.24pt lower in Word, inside the quarter point a least line is held to
- there, and its skills 0.3pt lower in LibreOffice. `ConsultingInvoice`'s line items, whose
+ - `TealPulse`'s duties stand 0.24pt lower in Word than as exact lines, and its skills 0.3pt
+ lower in LibreOffice, within half a point: the export reckons the duties' least line within
+ 0.06pt of the page, and Word stands it lower, as it does the Word list items
+ above. `ConsultingInvoice`'s line items, whose
description now seats its row's lift differently, stand one step of Word's 0.12pt line grid
lower there and where they stood in LibreOffice.
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
index 34e58ba7a..deb77046f 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
@@ -324,7 +324,10 @@ private Reach withItsMarker(CTNumPr item, Run mark, Reach reach) {
return over == null ? null : reachOf(over.over(mark), reach, level.getLvlText().getVal());
}
- /** The level of the numbering part a list's item names, or null where none, or one overridden. */
+ /**
+ * The level of the numbering part a list's item names, or null where it names none the part
+ * holds, or its list's levels are overridden or a numbering style's.
+ */
private CTLvl levelOf(CTNumPr item) {
XWPFNumbering part = numbering.get();
if (part == null || !item.isSetNumId() || item.getNumId().getVal() == null) {
From d002b5664343640a6330daeb30f6b2eca211fc63 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 09:18:30 +0000
Subject: [PATCH 3/3] fix(docx): ask a marker's glyphs of the Normal style only
where it is stated
DocxGrownLines asked a list marker's letters of the Normal style's face
without checking that the document states one. A run naming no face is
read only when it does, so the call could not be reached without it;
the check now says so where the face is asked.
---
.../compose/document/backend/semantic/docx/DocxGrownLines.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
index deb77046f..9183444e4 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
@@ -391,7 +391,7 @@ private Reach reachOf(Run run, Reach so, String letters) {
return null;
}
if (letters != null && !(run.family() != null ? faces.holds(run.family(), slot, letters)
- : faces.holds(defaults.fontName(), slot, letters))) {
+ : defaults != null && faces.holds(defaults.fontName(), slot, letters))) {
return null;
}
return new Reach(Math.max(so.above(), line.ascent() * size), Math.max(so.below(), line.descent() * size),