diff --git a/CHANGELOG.md b/CHANGELOG.md
index b596c9476..68536a549 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,39 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export's lines grow with their text where they are as tall as their faces' own.**
+ - Every paragraph's lines were written at an exact height, which Word keeps whatever its text
+ holds: a reader making the body larger in Word — the `Normal` style two points up — got letters
+ taller than their lines, cut off at the top on screen.
+ - A paragraph's or a list item's line that is at least as tall as its runs' own line is now
+ written as a least height (`w:lineRule="atLeast"`). The runs' own line is their faces' line by
+ 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, and the paragraph is seated on its baseline, not a Word list's item.
+ - 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
+ is still moved into the space above it where Word would set it a little off. A line Word
+ would set a quarter point or more off the page's baseline stays exact.
+ - The rest stays exact:
+ - a line 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; one rounded to the twip below its own, which a least
+ line would grow back on every line;
+ - a line in a face the document does not ship, the default Helvetica's among them, which the
+ reader's machine substitutes, or in one whose `hhea` extents, which the page measures by,
+ are not the metrics its line is set on;
+ - a table's text cells, page zones, text boxes, stacked lines and line pairs.
+ - The report names how many paragraphs of text are still exact, once for the document
+ (`line height`, no path).
+ - The corpus writes 1164 of its 3856 paragraphs of text at a least height. In Word, two points
+ more on `Normal` leaves 1138 of the 1832 paragraphs it grows on lines shorter than 1.1 times
+ their letters, from 1617.
+ - Lines move as their seats change. In Word no line stands half a point further from the page
+ than its baseline holds. The step from a line to the next differs from the page's by 0.120pt
+ on average, from 0.130, and by more than 0.2pt for 238 of 2766 steps, from 335. In
+ LibreOffice 24.2 it differs by 0.173pt on average, from 0.187.
+
- **A DOCX export's page-margin notes name every page they are about.**
- A note about several pages named the first of them only, and the later ones, which Word sets
with the margins in force too, went unnamed: `ObsidianInvoice` with its line items run onto
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 81f708708..52a9c33b8 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -66,7 +66,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; a top edge below zero comes out of the space owed above the paragraph, the foot of the one line above it and the top of its own line, and what none gives is not written and is reported (`space above`), the paragraph and what follows that much lower in the flow, and in a table cell up to that much, its row as much taller where the cell sets the row's height — not yet on the first block of a band's layer or of a row of columns' later layer, on a block opening a page in the flow (a section's first, one moved to a new page, one after a page break), or on a side of an overlay's line pair, which takes its pull out of the space above alone; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own, and a badge's initials written as its shape's text stand off the shape's middle by as much, in its insets: in Word, and in LibreOffice where the line and twice the seat fit the preset's text rectangle (elsewhere LibreOffice, which reads an inset below zero as none, moves them only by half the inset it reads, short of the seat); Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — lines written at the height the page measured, as a least height Word grows with the text where they are at least as tall as their faces' own line and Word then stands the text where the page does (`DocxGrownLines`: a least line's baseline its runs' descent above its foot), exact elsewhere, which the report names once for the document (`line height`); each run keeps its own style, falling back to the paragraph's when it has none; a character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; a top edge below zero comes out of the space owed above the paragraph, the foot of the one line above it and the top of its own line, and what none gives is not written and is reported (`space above`), the paragraph and what follows that much lower in the flow, and in a table cell up to that much, its row as much taller where the cell sets the row's height — not yet on the first block of a band's layer or of a row of columns' later layer, on a block opening a page in the flow (a section's first, one moved to a new page, one after a page break), or on a side of an overlay's line pair, which takes its pull out of the space above alone; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own, and a badge's initials written as its shape's text stand off the shape's middle by as much, in its insets: in Word, and in LibreOffice where the line and twice the seat fit the preset's text rectangle (elsewhere LibreOffice, which reads an inset below zero as none, moves them only by half the inset it reads, short of the seat); Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — an exact Poppins line's; never a least line's, which holds no position — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items, a drawn marker or a marker holding a percent sign before a digit, which Word reads in a level's text as a level's number, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell that its table's lines are not matched to, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius, the padding above and below the letters and a padding below zero on any side are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; a picture on a later line of another height is set by that measure too, and where the paragraph's lines are not read, nothing moves a picture beside text off the baseline: a paragraph's note names either, not a list item's; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 22b34e92a..45401af97 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:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet 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, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them — one that cannot, and stands less high itself, has it taken back as far — and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or 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 lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
+| Line height | `w:spacing w: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 or a list item'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, 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 table's text cell's, 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 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 |
@@ -439,6 +439,14 @@ restate it stay silent, so changing `Normal` in Word changes the body the way a
expects. A run whose font, size or colour differs keeps saying so, so headings, chips and
accents are unaffected.
+Whether a line grows with its text depends on how its height is written (see "Measured
+geometry"). A line at least as tall as its faces' own is written as a least height: `Normal`
+two points larger, its lines grow with it. A line the page sets tighter than its faces' own —
+Poppins', Gothic A1's — or in a face the document does not ship, as the default Helvetica, is
+exact, and Word keeps its height: letters grown taller than the line are cut off at its top, on
+screen (Word's PDF export draws them whole). The report says how many paragraphs are written so,
+once for the document (`line height`).
+
The part also states the paragraph defaults — no space after a paragraph, single lines —
because Word otherwise takes them from its own new-document template, 8pt after every
paragraph and lines 1.08 tall, and the page has neither. A paragraph's space around it is
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
index 85e797f60..285b00188 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
@@ -4,6 +4,7 @@
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
@@ -19,6 +20,7 @@
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
+import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
@@ -80,6 +82,8 @@ final class DocxDrawingAnchors {
private final List Word's line is not the page's: the export stands Word's baseline where the page's
* is, and Word stands an exact line's baseline four fifths of the way down it, raised by
- * the runs' position. A 15pt exact line round 8.6pt text starts 4.6pt above the page's
- * line, and a shape placed from the page's line stood that much high.
An exact line is as tall as written, whatever its text, and Word and LibreOffice stand its + * baseline four fifths of the way down it ({@link DocxTextBands#BASELINE_SHARE}). A least line is + * as tall as written or as its runs' own line, whichever is taller, and keeps the spare above its + * text: the baseline stands the line's deepest run's descent above the line's foot. A run's own + * line is its face's ({@link DocxNaturalLine}), and a line of several runs reaches as high as the + * tallest and as deep as the deepest of them, the paragraph's mark among them in its last line + * (measured in Word and in LibreOffice 24.2: each face of the templates alone to 0.15pt, mixed + * faces and sizes in one line to 0.1pt).
+ * + *A text's position does not move it in a least line: Word, raising every run of a line, keeps + * the text on the line's foot until it passes the line's top, and LibreOffice moves it and grows + * the line. So only a paragraph whose runs hold no position, no picture set in the line and no raise + * of their own is read — a shape anchored to it takes no room in the line, and is passed over; the + * rest, and one set in a face the document does not ship, have no reach.
+ * + * @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); + + /** What a paragraph may hold besides its runs and still be read. */ + private static final SetWord stands each least line's baseline its own deepest run's descent above the foot, not + * the paragraph's: a line of 8pt bold Lato over a line of 7.5pt Lato stood its second line + * 0.1pt further down than its first, and its grid made it a step more. Only a paragraph whose + * runs and mark share one descent, to {@link #EPSILON}, has one place for its text in every line.
+ * + * @param paragraph the written paragraph + * @return whether its runs are read ({@link #reachOf}), reach no taller than its line, to + * {@link #EPSILON}, and share one descent + */ + boolean mayGrow(CTP paragraph) { + CTPPr properties = paragraph.getPPr(); + CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null; + if (spacing == null || !(spacing.getLine() instanceof Number twips)) { + return false; + } + Reach reach = reachOf(paragraph); + return reach != null && twips.doubleValue() / POINT_TO_TWIP + EPSILON >= reach.height() + && reach.below() - reach.shallowest() <= EPSILON; + } + + /** + * How tall an editor stands a paragraph's line and where in it the baseline: an exact line as + * written, a least line as written or as its runs' own line where that is taller. + * + * @param paragraph the written paragraph + * @return the line, or null where its height is the editor's own, or a least line's runs are + * not read ({@link #reachOf}) + */ + Line lineOf(CTP paragraph) { + CTPPr properties = paragraph.getPPr(); + CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null; + if (spacing == null || !spacing.isSetLineRule() || !(spacing.getLine() instanceof Number twips)) { + return null; + } + double written = twips.doubleValue() / POINT_TO_TWIP; + if (spacing.getLineRule() == STLineSpacingRule.EXACT) { + return new Line(written, written * DocxTextBands.BASELINE_SHARE); + } + if (spacing.getLineRule() != STLineSpacingRule.AT_LEAST) { + return null; + } + Reach reach = reachOf(paragraph); + if (reach == null) { + return null; + } + double height = Math.max(written, reach.height()); + return new Line(height, height - reach.below()); + } + + private Reach reachOf(XmlObject runProperties, Reach so) { + Run run = Run.of(runProperties); + if (run == null) { + return null; + } + // The Normal style states a face and a size, and never a weight or a slant. + boolean bold = Boolean.TRUE.equals(run.bold()); + boolean italic = Boolean.TRUE.equals(run.italic()); + String slot = bold ? italic ? "embedBoldItalic" : "embedBold" : italic ? "embedItalic" : "embedRegular"; + DocxNaturalLine line = run.family() != null ? faces.of(run.family(), slot) + : defaults != null ? faces.of(defaults.fontName(), slot) : null; + double size = run.halfPoints() > 0 ? run.halfPoints() / HALF_POINTS_PER_POINT + : defaults != null ? Math.round(defaults.size() * HALF_POINTS_PER_POINT) / HALF_POINTS_PER_POINT + : Double.NaN; + if (line == null || !(size > 0)) { + 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)); + } + + /** + * Whether a run holds drawings anchored to its paragraph and nothing else of its own: no text, + * no picture set in the line. + */ + private static boolean holdsOnlyAnchors(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run) { + if (run.sizeOfDrawingArray() == 0) { + return false; + } + try (XmlCursor cursor = run.newCursor()) { + for (boolean more = cursor.toFirstChild(); more; more = cursor.toNextSibling()) { + String part = cursor.getName().getLocalPart(); + if ("drawing".equals(part)) { + try (XmlCursor drawing = cursor.getObject().newCursor()) { + for (boolean shape = drawing.toFirstChild(); shape; shape = drawing.toNextSibling()) { + if (!"anchor".equals(drawing.getName().getLocalPart())) { + return false; + } + } + } + } else if (!"rPr".equals(part)) { + return false; + } + } + } + return true; + } + + /** Whether every child element of {@code element} is one of {@code parts}. */ + private static boolean holdsOnly(XmlObject element, SetWord and LibreOffice agree on it (measured on every face the templates use, 10 and 20pt, + * Word on Windows and LibreOffice 24.2 on Linux, to 0.15pt): a face whose {@code OS/2} table sets + * {@code USE_TYPO_METRICS} is set on its typographic ascender and descender with its typographic + * line gap above them, and any other on its Windows ascent and descent with what {@code hhea}'s + * line gap asks beyond them above. A line given a least height taller than this keeps the spare + * above its text: the baseline stands this line's {@link #descent} above the line's foot.
+ * + *Only a face whose {@code hhea} ascender and descender are the metrics so chosen has a line + * here. The page measures a face's descent by {@code hhea}, so only then does a least line stand + * its text where the page does; and an editor that reads {@code hhea} for the face sets it the + * same. Every face the templates use is one.
+ * + * @param ascent how far the line reaches above the baseline, its line gap included, in ems + * @param descent how far it reaches below, in ems + * @author Artem Demchyshyn + */ +record DocxNaturalLine(double ascent, double descent) { + + /** {@code fsSelection} bit 7: the typographic metrics are the face's line. */ + private static final int USE_TYPO_METRICS = 0x0080; + /** The {@code OS/2} version that defines {@code USE_TYPO_METRICS}. */ + private static final int TYPO_METRICS_VERSION = 4; + /** The {@code OS/2} table's length up to and including {@code usWinDescent}. */ + private static final int OS2_THROUGH_WIN_DESCENT = 78; + + /** + * The face's line in an editor. + * + * @param font the whole font file + * @return its line, or null where the font carries no readable {@code head}, {@code hhea} or + * {@code OS/2} table, or its {@code hhea} extents are not the metrics its line is set on + */ + static DocxNaturalLine of(byte[] font) { + int head = DocxFontEmbedding.tableOffset(font, "head", 20); + int hhea = DocxFontEmbedding.tableOffset(font, "hhea", 10); + int os2 = DocxFontEmbedding.tableOffset(font, "OS/2", OS2_THROUGH_WIN_DESCENT); + if (head < 0 || hhea < 0 || os2 < 0) { + return null; + } + ByteBuffer buffer = ByteBuffer.wrap(font).order(ByteOrder.BIG_ENDIAN); + double unitsPerEm = Short.toUnsignedInt(buffer.getShort(head + 18)); + if (!(unitsPerEm > 0)) { + return null; + } + int ascender = buffer.getShort(hhea + 4); + int descender = buffer.getShort(hhea + 6); + int version = Short.toUnsignedInt(buffer.getShort(os2)); + int selection = Short.toUnsignedInt(buffer.getShort(os2 + 62)); + if (version >= TYPO_METRICS_VERSION && (selection & USE_TYPO_METRICS) != 0) { + int typoAscender = buffer.getShort(os2 + 68); + int typoDescender = buffer.getShort(os2 + 70); + int typoGap = Math.max(0, buffer.getShort(os2 + 72)); + if (typoAscender != ascender || typoDescender != descender) { + return null; + } + return new DocxNaturalLine((typoAscender + typoGap) / unitsPerEm, -typoDescender / unitsPerEm); + } + int winAscent = Short.toUnsignedInt(buffer.getShort(os2 + 74)); + int winDescent = Short.toUnsignedInt(buffer.getShort(os2 + 76)); + if (winAscent != ascender || winDescent != -descender) { + return null; + } + int lineGap = buffer.getShort(hhea + 8); + int beyond = Math.max(0, lineGap - ((winAscent + winDescent) - (ascender - descender))); + return new DocxNaturalLine((winAscent + beyond) / unitsPerEm, winDescent / unitsPerEm); + } +} 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 ae71d26ef..008fc5723 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 @@ -189,6 +189,11 @@ public final class DocxSemanticBackend implements SemanticBackendOnly an exact line of text alone in its paragraph, not one of a stack, with no space - * written below it and no run shaded or underlined: a shaded run fills the line in Word, and - * an underline is drawn below the letters. Where both are laid out, the line must end on the + *
Only an exact line of text alone in its paragraph — one grown to a least height is made + * exact first, its text kept where it stood ({@link #raiseHoldingItExact}) — not one of a stack, + * with no space written below it and no run shaded or underlined: a shaded run fills the line in + * Word, and an underline is drawn below the letters. Where both are laid out, the line must end on the * page the pulled paragraph starts on: the last line of a page shortened would move where * Word breaks it.
* @@ -9446,7 +9519,13 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) { } CTPPr properties = para.getCTP().getPPr(); CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null; - Long line = spacing != null && spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT + // A line grown to a least height (letTheLinesGrow) is shortened as an exact one: its text + // stands on its foot, and would rise with it. + boolean grown = spacing != null && spacing.isSetLineRule() + && spacing.getLineRule() == STLineSpacingRule.AT_LEAST + && grownLines.lineOf(para.getCTP()) != null; + Long line = spacing != null && spacing.isSetLineRule() + && (spacing.getLineRule() == STLineSpacingRule.EXACT || grown) ? writtenTwips(spacing.getLine()) : null; java.util.OptionalRead off the first line holding text and the height the paragraph was written at, the * gap between lines included, at the paragraph's middle line: where the space above could not * give up a whole gap, Word's lines step a little closer than the page's, and the error is - * shared by the first and last. 0 for a paragraph not written at an exact height: Word - * then seats it by its own measure of the face.
+ * shared by the first and last. A least line ({@link #letTheLinesGrow}) stands its baseline + * its runs' descent above its foot. 0 for a paragraph written at neither: Word then seats it + * by its own measure of the face. * *A line of its own Word sets low is raised first by moving it up into the space written * above it, where that space can take it ({@link #raiseIntoTheSpaceAbove}); under half a point, @@ -12972,25 +13062,81 @@ private double seatShift(ParagraphNode node) { * however little * @param ownsItsLine whether the Word paragraph holds this paragraph's text alone, and so * its line can move for it + * @param mayGrow whether its lines may be written at a least height ({@link #letTheLinesGrow}) */ private double shiftToThePagesBaseline(XWPFParagraph para, ParagraphNode node, double lineTopAbove, - boolean heldExact, boolean ownsItsLine) { + boolean heldExact, boolean ownsItsLine, boolean mayGrow) { // A line starting elsewhere than the page's — a stack's, a line pair's — was cut to fit // its letters where the page sets them, and is seated however little; so is one cut to // its pictures' reach, whose ink stands at its edges. boolean cutToFit = lineTopAbove != 0 || stackedLineHeights.containsKey(node) || heldExact; boolean staysOnItsPage = staysOnItsPage(node); + if (mayGrow && ownsItsLine && !cutToFit) { + letTheLinesGrow(para, layout.lines(node), layout.lineGap(node)); + } return shiftToThePagesBaseline(para, layout.lines(node), layout.lineGap(node), lineTopAbove, cutToFit, ownsItsLine, staysOnItsPage); } /** - * How far Word's baseline in exact lines of text the layout set stands below the page's, in - * points, positive when Word's is lower, after what the space above took of it: the position - * left to move, 0 where the whole difference is under half a point, which the space above, a - * cell's padding or the space above a row of columns takes instead for a line set low, and the - * space under it for one set high ({@link #offerToLower}) (see - * {@link #shiftToThePagesBaseline(XWPFParagraph, ParagraphNode, double, boolean, boolean)}). + * Writes a paragraph's lines at a least height rather than an exact one where they are at + * least as tall as their runs' own line, and Word then stands its text where the page does. + * + *
An exact line keeps its height whatever its text: a reader making the text larger in + * Word — the Normal style two points up — gets letters taller than their lines, cut off at the + * top. A least line grows with them. Where the page's line is as tall as the runs' own + * ({@link DocxGrownLines#reachOf}), a least line written at the page's height is that height, + * and its text stands its runs' descent above the line's foot, which is where the page sets it + * when the page's own line is its faces' ascent and descent with what lies between the lines + * above it: a line's gap is taken from the space above the paragraph ({@link #applyLineGap}).
+ * + *A least line's text is not moved by its position (see {@link DocxGrownLines}), so a line + * Word would set off the page's baseline by {@link #LEAST_GROWN_LINE_SHIFT_POINTS} or more — + * its gaps shared out over its lines where nothing above gave them, a face whose descent + * differs from the page's — stays exact, where its position seats it.
+ * + * @param lines the lines the layout set the text on + * @param lineGap the gap the layout puts between them, beyond their own height + */ + private void letTheLinesGrow(XWPFParagraph para, + ListWord stands the baseline of an exact line four fifths of the way down it, whatever the * face; the page sets it the face's ascent below the line's top. {@code NorthlineProposal}'s - * 46pt Spectral title, whose ascent is under seven tenths of its line, stood 7pt low in Word.
+ * 46pt Spectral title, whose ascent is under seven tenths of its line, stood 7pt low in Word. + * A line as tall as its face's own is now written at a least height, which stands its text where + * the page does; the lines seated here are tighter than their faces' own — Poppins', whose own + * line holds a line gap the page's does not, and Ubuntu's — and stay exact. */ class DocxBaselineSeatTest { @@ -34,10 +38,10 @@ class DocxBaselineSeatTest { private static final double SPECTRAL_ASCENT = 1.059; /** Spectral's own line, in ems: its ascent and 463 units of descent. */ private static final double SPECTRAL_LINE = 1.522; - /** Lato's ascent, in ems: 1974 of 2000 units. */ - private static final double LATO_ASCENT = 0.987; /** Poppins' ascent, in ems: 1050 of 1000 units. */ private static final double POPPINS_ASCENT = 1.05; + /** Ubuntu's ascent, in ems: 932 of 1000 units. */ + private static final double UBUNTU_ASCENT = 0.932; /** Gothic A1's ascent, in ems: 798 of 1000 units, its own line one em. */ private static final double GOTHIC_A1_ASCENT = 0.798; /** @@ -51,24 +55,54 @@ private static DocumentTextStyle spectral(double size) { return DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(size).build(); } + /** + * Poppins: its own line holds a line gap a tenth of its size above its letters, which the page's + * does not, so its lines are tighter than its own and written exact, where Word sets it low. + */ + private static DocumentTextStyle poppins(double size) { + return DocumentTextStyle.builder().fontName(FontName.POPPINS).size(size).build(); + } + @Test - void aDeepFaceIsRaisedFromWordsBaselineToThePages() throws Exception { + void aFaceWordSetsLowInAnExactLineIsRaisedFromWordsBaselineToThePages() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page - .addParagraph(p -> p.text("Title").textStyle(spectral(30))))) { + .addParagraph(p -> p.text("Title").textStyle(poppins(60))))) { XWPFParagraph title = paragraph(document, "Title"); - double wordBelowThePage = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double wordBelowThePage = 0.8 * line(title) - POPPINS_ASCENT * 60; + assertThat(rule(title)).as("the premise: an exact line").isEqualTo(STLineSpacingRule.EXACT); assertThat(wordBelowThePage).as("the premise: Word's baseline is the lower").isGreaterThan(4); assertThat(position(title)).isCloseTo((int) Math.round(wordBelowThePage * 2), within(1)); } } + @Test + void aDeepFaceAsTallAsItsOwnLineStandsItsTextWhereThePageDoes() throws Exception { + // Spectral's line is its own: written at a least height, Word keeps the spare above the + // letters and stands the baseline the descent above the foot, where the page does, so + // nothing moves it. Exact, Word stood its 25pt title 4pt low. At 25pt the line is a whole + // number of twips; at 30pt it rounds a hundredth short of its own, which a least line would + // grow back, and stays exact. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Title").textStyle(spectral(25))) + .addParagraph(p -> p.text("Rounded").textStyle(spectral(30))))) { + XWPFParagraph title = paragraph(document, "Title"); + + assertThat(rule(title)).isEqualTo(STLineSpacingRule.AT_LEAST); + assertThat(line(title)).as("the page's line").isEqualTo(SPECTRAL_LINE * 25); + assertThat(position(title)).isZero(); + assertThat(rule(paragraph(document, "Rounded"))).isEqualTo(STLineSpacingRule.EXACT); + } + } + @Test void aFaceWhoseBaselineWordAlreadyNearlyMatchesIsLeftAlone() throws Exception { - // Lato's ascent is 0.82 of its line: a quarter point from Word's four fifths at 10pt. + // Gothic A1's ascent is four fifths of its line, a fiftieth of a point from Word's seat at + // 10pt; its own line holds a line gap the page's does not, so the line is exact. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Body").textStyle(DocumentTextStyle.builder() - .fontName(FontName.LATO).size(10).build())))) { + .fontName(FontName.GOTHIC_A1).size(10).build())))) { + assertThat(rule(paragraph(document, "Body"))).isEqualTo(STLineSpacingRule.EXACT); assertThat(position(paragraph(document, "Body"))).isZero(); } } @@ -129,35 +163,56 @@ void theHalfOfALinePairSetLowerIsSeatedFromTheLinesTop() throws Exception { @Test void linesThatTookTheirGapFromTheSpaceAboveAreSeatedFromTheHigherTop() throws Exception { - // Two lines 10pt apart under 20pt of space: Word's lines take their gaps from above and - // start 10pt above the page's first line, and are raised into the 10pt left above them. + // Two lines a point apart under 20pt of space: Word's lines take their gaps from above and + // start a point above the page's first line, and are raised into the 19pt left above them. + // Poppins' own line holds a line gap of a tenth of its size above its letters, which the + // page's line does not: the lines are tighter than that, and exact. + try (XWPFDocument document = DocxExports.withLayout(240, 600, 20, page -> page + .addParagraph(p -> p.text("First line of it and second line").textStyle(DocumentTextStyle.builder() + .fontName(FontName.POPPINS).size(20).build()) + .lineSpacing(1).margin(new DocumentInsets(20, 0, 0, 0))))) { + XWPFParagraph text = paragraph(document, "First line of it and second line"); + double line = line(text); + double raise = 0.8 * line - 1 - POPPINS_ASCENT * 20; + + assertThat(line).as("the premise: two lines, each its gap taller").isGreaterThan(28.5); + assertThat(text.getCTP().getPPr().getSpacing().getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + assertThat(raise).as("the premise: Word's baseline is the lower, by less than the space left") + .isBetween(0.5, 19.0); + assertThat(before(text)).isCloseTo(Math.round((19 - raise) * 20), within(1L)); + assertThat(position(text)).isZero(); + } + } + + @Test + void linesAtLeastAsTallAsTheirFacesOwnStandTheirTextOnTheirFootWhereThePageDoes() throws Exception { + // Two lines 10pt apart under 20pt of space, Spectral's own line and the gap tall: written at a + // least height, which keeps the gap above the letters, as the page has it above the second + // line; the first's comes off the space above, and nothing is raised. try (XWPFDocument document = DocxExports.withLayout(240, 600, 20, page -> page .addParagraph(p -> p.text("First line of it and second line of it").textStyle(spectral(20)) .lineSpacing(10).margin(new DocumentInsets(20, 0, 0, 0))))) { XWPFParagraph text = paragraph(document, "First line of it and second line of it"); - double line = line(text); - double raise = 0.8 * line - 10 - SPECTRAL_ASCENT * 20; - assertThat(line).as("the premise: two lines, each its gap taller").isGreaterThan(40); - assertThat(raise).as("the premise: Word's baseline is the lower, by less than the space left") - .isBetween(0.5, 10.0); - assertThat(before(text)).isCloseTo(Math.round((10 - raise) * 20), within(1L)); + assertThat(line(text)).as("the premise: two lines, each its gap taller").isGreaterThan(40); + assertThat(text.getCTP().getPPr().getSpacing().getLineRule()).isEqualTo(STLineSpacingRule.AT_LEAST); + assertThat(before(text)).as("the gap off the 20pt above").isEqualTo(200); assertThat(position(text)).isZero(); } } @Test void aLineWordSetsLowIsRaisedIntoTheSpaceAboveItAndOwesItBelow() throws Exception { - // 20pt above a 30pt Spectral title: Word's baseline stands nearly 5pt below the page's. - // The line moves up that much of the space, to the twip, where its position would move - // it in half points, and LibreOffice that half again; Lato under it, which Word sets high - // and nothing under it lowers, stands where the page does all the same. + // 20pt above a 30pt Poppins title: Word's baseline stands 2pt below the page's. The line + // moves up that much of the space, to the twip, where its position would move it in half + // points, and LibreOffice that half again; Lato under it, its line its own and written at a + // least height, stands where the page does all the same. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) - .addParagraph(p -> p.text("Title").textStyle(spectral(30)).margin(DocumentInsets.top(20))) + .addParagraph(p -> p.text("Title").textStyle(poppins(30)).margin(DocumentInsets.top(20))) .addParagraph(p -> p.text("Body").textStyle(lato(10))))) { XWPFParagraph title = paragraph(document, "Title"); - double raise = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(title) - POPPINS_ASCENT * 30; assertThat(raise).as("the premise: Word's baseline is the lower, by less than the space") .isBetween(1.0, 20.0); @@ -173,10 +228,10 @@ void aLineWordSetsLowIsRaisedIntoTheSpaceAboveItAndOwesItBelow() throws Exceptio void whatTheSpaceAboveCannotGiveIsLeftToThePosition() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) - .addParagraph(p -> p.text("Title").textStyle(spectral(30)).margin(DocumentInsets.top(2))) + .addParagraph(p -> p.text("Title").textStyle(poppins(60)).margin(DocumentInsets.top(2))) .addParagraph(p -> p.text("Body").textStyle(lato(10))))) { XWPFParagraph title = paragraph(document, "Title"); - double raise = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(title) - POPPINS_ASCENT * 60; assertThat(before(title)).as("all of the 2pt").isZero(); assertThat(position(title)).isCloseTo((int) Math.round((raise - 2) * 2), within(1)); @@ -207,7 +262,8 @@ void aParagraphTheLayoutBreaksOverAPageKeepsTheSpaceAboveIt() throws Exception { String text = "A title long enough to run onto a second line and a third one"; try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) { session.pageFlow(page -> page.spacer(1, 500) - .addParagraph(p -> p.name("Broken").text(text).textStyle(spectral(30)).margin(DocumentInsets.top(10)))); + .addParagraph(p -> p.name("Broken").text(text).textStyle(poppins(30)) + .margin(DocumentInsets.top(10)))); var placed = session.layoutGraph().nodes().stream() .filter(node -> "Broken".equals(node.semanticName())).findFirst().orElseThrow(); try (XWPFDocument document = new XWPFDocument( @@ -241,15 +297,17 @@ void aRaiseUnderATenthOfAPointLeavesTheSpaceAboveAsWritten() throws Exception { @Test void aLineWordSetsHighKeepsTheSpaceAboveIt() throws Exception { - // Lato's ascent is 0.82 of its line: at 24pt Word's baseline stands 0.65pt above the - // page's. Half a point and more is its position's, as for a line set that much low: it - // is lowered by its position, the space as written. Under half a point the space below - // it gives it ({@link DocxParagraphLowerTest}). + // Ubuntu's ascent is 0.83 of its line, and its own line taller than the page's: at 24pt + // Word's baseline stands 0.84pt above the page's in the exact line. Half a point and more + // is its position's, as for a line set that much low: it is lowered by its position, the + // space as written. Under half a point the space below it gives it + // ({@link DocxParagraphLowerTest}). try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) - .addParagraph(p -> p.text("Heading").textStyle(lato(24)).margin(DocumentInsets.top(10))))) { + .addParagraph(p -> p.text("Heading").textStyle(DocumentTextStyle.builder().fontName(FontName.UBUNTU) + .size(24).build()).margin(DocumentInsets.top(10))))) { XWPFParagraph heading = paragraph(document, "Heading"); - double raise = 0.8 * line(heading) - LATO_ASCENT * 24; + double raise = 0.8 * line(heading) - UBUNTU_ASCENT * 24; assertThat(raise).as("the premise: Word's baseline is the higher, by more than half a point") .isLessThan(-0.5); @@ -294,14 +352,14 @@ void aLineInABandIsRaisedIntoTheSpaceAboveItAndTheBandOwesItBelow() throws Excep .addParagraph(p -> p.text("Above").textStyle(lato(10))) .addLayerStack(stack -> stack.name("Stack") .back(new com.demcha.compose.document.dsl.ShapeBuilder().name("Backdrop").size(200, 80).build()) - .layer(new ParagraphBuilder().name("Title").text("Title").textStyle(spectral(30)) + .layer(new ParagraphBuilder().name("Title").text("Title").textStyle(poppins(30)) .margin(DocumentInsets.top(20)).build(), LayerAlign.TOP_LEFT)) - .addParagraph(p -> p.name("Below").text("Below").textStyle(spectral(30)))); + .addParagraph(p -> p.name("Below").text("Below").textStyle(poppins(30)))); try (session; XWPFDocument document = new XWPFDocument(new java.io.ByteArrayInputStream( session.export(new DocxSemanticBackend())))) { XWPFParagraph title = paragraph(document, "Title"); XWPFParagraph below = paragraph(document, "Below"); - double raise = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(title) - POPPINS_ASCENT * 30; var placedTitle = session.layoutGraph().nodes().stream() .filter(node -> "Title".equals(node.semanticName())).findFirst().orElseThrow(); var placedBelow = session.layoutGraph().nodes().stream() @@ -324,12 +382,12 @@ void theLastLineOfACellOwesItsRaiseInsideTheCell() throws Exception { try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) .addRow(row -> row - .addParagraph(p -> p.text("Left").textStyle(spectral(30)).margin(DocumentInsets.top(20))) + .addParagraph(p -> p.text("Left").textStyle(poppins(30)).margin(DocumentInsets.top(20))) .addParagraph(p -> p.text("Right").textStyle(lato(10)))) .addParagraph(p -> p.text("Below").textStyle(lato(10))))) { ListThe page's line is its face's ascent and descent; a face whose own line is no taller — Lato, + * Spectral — is written at a least height, one whose own line holds a line gap the page's does + * not — Poppins — or the editors' descent another depth — Alegreya Sans — exact (measured in Word + * and in LibreOffice 24.2: a least line keeps the spare above its text and stands its baseline the + * runs' descent above its foot).
+ * + * @author Artem Demchyshyn + */ +class DocxGrownLinesExportTest { + + private static final String WRAPPED = "A paragraph long enough to wrap onto a second line and onto a third one " + + "in a column a hundred and sixty points wide"; + + @Test + void linesWithAGapTakenFromTheSpaceAboveAreWrittenAtALeastHeightAndNotMoved() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(200, 600, 20, page -> page + .addParagraph(p -> p.text("Lead").textStyle(face(FontName.GOTHIC_A1, 10))) + .addParagraph(p -> p.text(WRAPPED).textStyle(face(FontName.LATO, 10)).lineSpacing(3) + .margin(DocumentInsets.top(10))))) { + XWPFParagraph text = paragraph(document, WRAPPED); + + assertThat(spacing(text).getLineRule()).isEqualTo(STLineSpacingRule.AT_LEAST); + assertThat(DocxTwips.of(spacing(text).getLine())).as("Lato's 12pt and the 3pt gap").isEqualTo(300); + assertThat(DocxTwips.of(spacing(text).getBefore())).as("the gap off the 10pt above").isEqualTo(140); + assertThat(positions(text)).as("the text stands on the line's foot where the page sets it") + .containsOnly(0L); + } + } + + @Test + void aLineGrowsOnlyWhileWordWouldSetItUnderAQuarterPointOffThePagesBaseline() throws Exception { + // Two lines right under the block above, which gives none of their gap: it is shared out + // over both, and a least line's text, on its foot, stands a quarter of the gap low at the + // middle of the two. A 0.8pt gap leaves it 0.2pt off and the lines grow; at 1.2pt it would + // stand 0.3pt off, and they stay exact. + for (double gap : new double[]{0.8, 1.2}) { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Lead").textStyle(face(FontName.GOTHIC_A1, 10))) + .addParagraph(p -> p.text("First line\nSecond line").textStyle(face(FontName.LATO, 10)) + .lineSpacing(gap)))) { + XWPFParagraph text = paragraph(document, "First line"); + + assertThat(DocxTwips.of(spacing(text).getLine())).as("the premise: Lato's 12pt and half the gap") + .isEqualTo(Math.round((12 + gap / 2) * 20)); + assertThat(spacing(text).getLineRule()).as("a %spt gap", gap) + .isEqualTo(gap < 1 ? STLineSpacingRule.AT_LEAST : STLineSpacingRule.EXACT); + } + } + } + + @Test + void aRowOfColumnsParagraphIsWrittenAtALeastHeightToo() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addRow(row -> row + .addParagraph(p -> p.text("Left").textStyle(face(FontName.LATO, 10))) + .addParagraph(p -> p.text("Right").textStyle(face(FontName.LATO, 10)))))) { + assertThat(spacing(paragraph(document, "Left")).getLineRule()).isEqualTo(STLineSpacingRule.AT_LEAST); + assertThat(spacing(paragraph(document, "Right")).getLineRule()).isEqualTo(STLineSpacingRule.AT_LEAST); + } + } + + @Test + void linesTighterThanTheirFacesOwnAreWrittenExact() throws Exception { + // Poppins' own line holds a line gap of a tenth of its size above its letters; the page's + // line and half a point's gap are short of it, and a least line would grow to it. + try (XWPFDocument document = DocxExports.withLayout(200, 600, 20, page -> page + .addParagraph(p -> p.text(WRAPPED).textStyle(face(FontName.POPPINS, 10)).lineSpacing(0.5)))) { + assertThat(spacing(paragraph(document, WRAPPED)).getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + } + } + + @Test + void runsOfDifferentDepthsAreWrittenExact() throws Exception { + // Word stands each least line's text its own deepest run's descent above its foot: an 8pt + // run's line and a 7.5pt one's would stand their text at two depths. + DocumentTextStyle bold = DocumentTextStyle.builder().fontName(FontName.LATO).size(8) + .decoration(DocumentTextDecoration.BOLD).build(); + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.inlineText("LayoutLint ", bold).inlineText("flags fragile patterns", + face(FontName.LATO, 7.5)).lineSpacing(1)))) { + assertThat(spacing(paragraph(document, "LayoutLint")).getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + } + } + + @Test + void aFaceTheEditorsSetOnAnotherDescentIsWrittenExact() throws Exception { + // Alegreya Sans' typographic descent, which the editors set its line on, is two tenths of + // its size, and its hhea descent, which the page measures, three: a least line would stand + // its text 2pt low at 20pt, its position moving nothing, so the face has no line to grow to + // and the line stays exact, where its position seats it. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Alegreya").textStyle(face(FontName.ALEGREYA_SANS, 20))))) { + assertThat(spacing(paragraph(document, "Alegreya")).getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + } + } + + @Test + void aParagraphSeatedOffItsBaselineIsWrittenExact() throws Exception { + // Its position seats it by its cap band, which a least line does not keep. + try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page + .addParagraph(p -> p.text("Seated").textStyle(face(FontName.LATO, 10)) + .verticalAlign(TextVerticalAlign.TOP)))) { + XWPFParagraph seated = paragraph(document, "Seated"); + + assertThat(positions(seated)).as("the premise: seated by its position").doesNotContain(0L); + assertThat(spacing(seated).getLineRule()).isEqualTo(STLineSpacingRule.EXACT); + } + } + + @Test + void aListsItemGrowsWhereItsMarkerIsItsTextAndNotWhereWordDrawsIt() throws Exception { + // An item of runs, under a hanging marker, has its marker written as its first run. + 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)))) + .addList(list -> list.textStyle(face(FontName.LATO, 10)).items("Drawn")))) { + XWPFParagraph written = paragraph(document, "•"); + XWPFParagraph drawn = paragraph(document, "Drawn"); + + 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); + } + } + + private static DocumentTextStyle face(FontName name, double size) { + return DocumentTextStyle.builder().fontName(name).size(size).build(); + } + + private static CTSpacing spacing(XWPFParagraph paragraph) { + return paragraph.getCTP().getPPr().getSpacing(); + } + + /** The positions the paragraph's runs are raised by, in half points, 0 for none. */ + private static ListThe expected reaches are the faces' own tables ({@link DocxNaturalLine}), which Word and + * LibreOffice were measured to set a line in: a 10pt Lato line reaches 9.87pt up and 2.13pt down, + * a line of 10pt Poppins and 10pt Spectral reaches as high as the Poppins, its line gap above it, + * and as deep as the Spectral.
+ * + * @author Artem Demchyshyn + */ +class DocxGrownLinesTest { + + private static final DocumentTextStyle LATO = face(FontName.LATO, 10); + private static final double LATO_UP = 0.987; + private static final double LATO_DOWN = 0.213; + + @Test + void aParagraphReachesAsHighAndAsDeepAsItsTallestAndDeepestRun() throws Exception { + DocxGrownLines lines = shipping(LATO, face(FontName.POPPINS, 10), face(FontName.SPECTRAL, 10)); + XWPFParagraph paragraph = paragraph(); + run(paragraph, "Poppins", 10); + run(paragraph, "Spectral", 10); + + DocxGrownLines.Reach reach = lines.reachOf(paragraph.getCTP()); + + assertThat(reach.above()).as("Poppins' ascent and its line gap").isCloseTo(11.5, within(0.01)); + assertThat(reach.below()).as("Spectral's descent").isCloseTo(4.63, within(0.01)); + assertThat(reach.shallowest()).as("the mark's, in Normal's Lato").isCloseTo(LATO_DOWN * 10, within(0.01)); + } + + @Test + void aRunOrAMarkStatingNoFaceOrSizeIsReadInTheNormalStyle() throws Exception { + DocxGrownLines lines = shipping(LATO, LATO); + XWPFParagraph plain = paragraph(); + plain.createRun().setText("Plain"); + XWPFParagraph marked = paragraph(); + run(marked, "Lato", 10); + CTPPr properties = marked.getCTP().isSetPPr() ? marked.getCTP().getPPr() : marked.getCTP().addNewPPr(); + properties.addNewRPr().addNewSz().setVal(BigInteger.valueOf(24)); + + assertThat(lines.reachOf(plain.getCTP()).height()).as("Lato at 10pt, Normal's") + .isCloseTo(12.0, within(0.01)); + assertThat(lines.reachOf(marked.getCTP()).below()).as("the 12pt mark, in Normal's face") + .isCloseTo(LATO_DOWN * 12, within(0.01)); + } + + @Test + void aBoldRunIsReadInTheBoldFace() throws Exception { + DocxGrownLines lines = shipping(LATO, DocumentTextStyle.builder().fontName(FontName.LATO).size(10) + .decoration(DocumentTextDecoration.BOLD).build()); + XWPFParagraph bold = paragraph(); + run(bold, "Lato", 10).setBold(true); + XWPFParagraph italic = paragraph(); + run(italic, "Lato", 10).setItalic(true); + + assertThat(lines.reachOf(bold.getCTP())).as("the bold face is shipped").isNotNull(); + assertThat(lines.reachOf(italic.getCTP())).as("the italic face is not").isNull(); + assertThat(shipping(LATO).reachOf(bold.getCTP())).as("in a document shipping the regular face alone") + .isNull(); + } + + @Test + void aLinksRunAndAFieldsAreReadAndARunBoldOffIsUpright() throws Exception { + DocxGrownLines lines = shipping(LATO, LATO); + XWPFParagraph linked = paragraph(); + var link = linked.getCTP().addNewHyperlink().addNewR(); + link.addNewRPr().addNewSz().setVal(BigInteger.valueOf(24)); + link.addNewT().setStringValue("Linked"); + XWPFParagraph field = paragraph(); + run(field, "Lato", 10).getCTR().addNewFldChar() + .setFldCharType(org.openxmlformats.schemas.wordprocessingml.x2006.main.STFldCharType.BEGIN); + run(field, "Lato", 10).getCTR().addNewInstrText().setStringValue(" PAGEREF terms \\h "); + XWPFParagraph upright = paragraph(); + var bold = run(upright, "Lato", 10).getCTR().getRPr().addNewB(); + try (var cursor = bold.newCursor()) { + cursor.setAttributeText(new javax.xml.namespace.QName( + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "val"), "0"); + } + + assertThat(lines.reachOf(linked.getCTP()).below()).as("the link's 12pt run") + .isCloseTo(LATO_DOWN * 12, within(0.01)); + assertThat(lines.reachOf(field.getCTP())).as("a field's runs").isNotNull(); + assertThat(lines.reachOf(upright.getCTP())).as("bold off, in the regular face, the only one shipped") + .isNotNull(); + } + + @Test + void aRunHoldingOnlyAShapeAnchoredToTheParagraphIsPassedOver() throws Exception { + // The run states nothing, so it is the 14pt Normal style's: measured, it leaves a 7.5pt line + // of least height as tall as it was, in Word and in LibreOffice. + DocxGrownLines lines = shipping(face(FontName.LATO, 14), face(FontName.LATO, 7.5)); + XWPFParagraph paragraph = paragraph(); + paragraph.createRun().getCTR().addNewDrawing().addNewAnchor(); + run(paragraph, "Lato", 7.5); + CTPPr properties = paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr() : paragraph.getCTP().addNewPPr(); + properties.addNewRPr().addNewSz().setVal(BigInteger.valueOf(15)); + XWPFParagraph inline = paragraph(); + inline.createRun().getCTR().addNewDrawing().addNewInline(); + run(inline, "Lato", 7.5); + + assertThat(lines.reachOf(paragraph.getCTP()).below()).isCloseTo(LATO_DOWN * 7.5, within(0.01)); + assertThat(lines.reachOf(inline.getCTP())).as("a picture set in the line").isNull(); + } + + @Test + void aParagraphHoldingWhatALineIsNotReadByHasNoReach() throws Exception { + DocxGrownLines lines = shipping(LATO, LATO); + + XWPFParagraph picture = paragraph(); + run(picture, "Lato", 10).getCTR().addNewDrawing(); + XWPFParagraph raised = paragraph(); + run(raised, "Lato", 10).getCTR().getRPr().addNewPosition().setVal(BigInteger.valueOf(4)); + XWPFParagraph script = paragraph(); + run(script, "Lato", 10).getCTR().getRPr().addNewVertAlign() + .setVal(org.openxmlformats.schemas.officeDocument.x2006.sharedTypes.STVerticalAlignRun.SUPERSCRIPT); + XWPFParagraph rightToLeft = paragraph(); + run(rightToLeft, "Lato", 10).getCTR().getRPr().addNewRtl(); + XWPFParagraph substituted = paragraph(); + run(substituted, "Helvetica", 10); + XWPFParagraph listed = paragraph(); + run(listed, "Lato", 10); + listed.getCTP().addNewPPr().addNewNumPr(); + + assertThat(lines.reachOf(picture.getCTP())).as("a picture").isNull(); + assertThat(lines.reachOf(raised.getCTP())).as("a raise, which a least line does not keep").isNull(); + 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(DocxGrownLines.NONE.reachOf(paragraph().getCTP())).as("nothing shipped").isNull(); + } + + @Test + void aLineMayGrowWhereItIsAtLeastItsRunsOwnAndTheyShareOneDepth() throws Exception { + DocxGrownLines lines = shipping(LATO, LATO, face(FontName.LATO, 8)); + XWPFParagraph own = lato(240, 10); + XWPFParagraph taller = lato(300, 10); + XWPFParagraph shorter = lato(240 - 1, 10); + XWPFParagraph mixed = lato(300, 10); + run(mixed, "Lato", 8); + + assertThat(lines.mayGrow(own.getCTP())).as("its runs' own line").isTrue(); + assertThat(lines.mayGrow(taller.getCTP())).isTrue(); + assertThat(lines.mayGrow(shorter.getCTP())).as("a twip short").isFalse(); + assertThat(lines.mayGrow(mixed.getCTP())).as("10 and 8pt runs, each line its own depth").isFalse(); + } + + @Test + void aLeastLineStandsItsBaselineItsRunsDescentAboveItsFootAndAnExactOneFourFifthsDown() throws Exception { + DocxGrownLines lines = shipping(LATO, LATO); + XWPFParagraph exact = lato(280, 10); + XWPFParagraph least = lato(280, 10); + rule(least, STLineSpacingRule.AT_LEAST); + XWPFParagraph grown = lato(200, 10); + rule(grown, STLineSpacingRule.AT_LEAST); + XWPFParagraph auto = lato(240, 10); + rule(auto, STLineSpacingRule.AUTO); + + assertThat(lines.lineOf(exact.getCTP())).isEqualTo(new DocxGrownLines.Line(14, 14 * 0.8)); + assertThat(lines.lineOf(least.getCTP()).height()).isEqualTo(14); + assertThat(lines.lineOf(least.getCTP()).baseline()).isCloseTo(14 - LATO_DOWN * 10, within(0.01)); + assertThat(lines.lineOf(grown.getCTP()).height()).as("grown to its runs' own").isCloseTo(12, within(0.01)); + assertThat(lines.lineOf(grown.getCTP()).baseline()).isCloseTo(LATO_UP * 10, within(0.01)); + assertThat(lines.lineOf(auto.getCTP())).as("the editor's own measure").isNull(); + } + + /** + * The lines of a document that ships the faces of {@code faces} and of its Normal style + * {@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 { + var graph = DocxExports.graphOf(page -> { + page.addParagraph(p -> p.text("Normal").textStyle(normal)); + for (DocumentTextStyle face : faces) { + page.addParagraph(p -> p.text("Face").textStyle(face)); + } + }); + try (XWPFDocument document = new XWPFDocument()) { + return new DocxGrownLines(DocxFontTable.write(document, graph, List.of(), List.of(), null, + new DocxExportReport.Builder()), normal); + } + } + + private static DocumentTextStyle face(FontName name, double size) { + return DocumentTextStyle.builder().fontName(name).size(size).build(); + } + + private static XWPFParagraph paragraph() { + return new XWPFDocument().createParagraph(); + } + + private static XWPFRun run(XWPFParagraph paragraph, String family, double size) { + XWPFRun run = paragraph.createRun(); + run.setText(family); + run.setFontFamily(family); + run.setFontSize(size); + return run; + } + + /** A paragraph of one 10pt Lato run, its line {@code twips} tall, exact. */ + private static XWPFParagraph lato(long twips, double size) { + XWPFParagraph paragraph = paragraph(); + run(paragraph, "Lato", size); + CTP ctp = paragraph.getCTP(); + CTPPr properties = ctp.isSetPPr() ? ctp.getPPr() : ctp.addNewPPr(); + CTSpacing spacing = properties.addNewSpacing(); + spacing.setLine(BigInteger.valueOf(twips)); + spacing.setLineRule(STLineSpacingRule.EXACT); + return paragraph; + } + + private static void rule(XWPFParagraph paragraph, STLineSpacingRule.Enum rule) { + paragraph.getCTP().getPPr().getSpacing().setLineRule(rule); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java index bb8a0e57c..35e30a220 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java @@ -510,7 +510,7 @@ void aSquareChipWithNoPaddingLosesNothingAndSaysNothing() throws Exception { .addParagraph(p -> p.inlineHighlight("overdue", DocumentTextStyle.DEFAULT, BADGE, 0, DocumentInsets.zero()))); - assertThat(report.isEmpty()).isTrue(); + assertThat(DocxExports.besidesTheExactLines(report)).isEmpty(); } @Test 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 3684e06ba..b0de24bc9 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 @@ -68,11 +68,11 @@ void sideBySideLayersAreTheCellsOfOneRow() throws Exception { @Test void aLaterLayerStandsBelowTheLineAboveItAsFarAsThatLineWasRaised() throws Exception { - // The first layer's line, Spectral under 20pt of space, is moved up into that space and - // owes as much below it. The later layer measures the gap to itself from the page, where - // that line stood lower, so it takes the raise back with the gap. + // The first layer's line, Poppins under 20pt of space, exact and set low in Word, is moved + // up into that space and owes as much below it. The later layer measures the gap to itself + // from the page, where that line stood lower, so it takes the raise back with the gap. com.demcha.compose.document.style.DocumentTextStyle spectral = com.demcha.compose.document.style - .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.SPECTRAL).size(30).build(); + .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.POPPINS).size(30).build(); com.demcha.compose.document.style.DocumentTextStyle lato = com.demcha.compose.document.style .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.LATO).size(10).build(); try (Export export = export(stack -> stack @@ -87,7 +87,7 @@ void aLaterLayerStandsBelowTheLineAboveItAsFarAsThatLineWasRaised() throws Excep double line = org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT .equals(title.getCTP().getPPr().getSpacing().getLineRule()) ? DocxTwips.of(title.getCTP().getPPr().getSpacing().getLine()) / 20.0 : Double.NaN; - double raise = 0.8 * line - 1.059 * 30; + double raise = 0.8 * line - 1.05 * 30; PlacedNode placedTitle = export.placed("Title"); PlacedNode role = export.placed("Role"); double gap = placedTitle.placementY() - (role.placementY() + role.placementHeight()); @@ -147,14 +147,16 @@ void aLaterLayerTakesBackTheLoweringOfTheListEndingTheLayerAboveIt() throws Exce @Test void aLaterLayerGivesNothingToTheLineEndingTheLayerAboveIt() throws Exception { - // The first layer ends with a paragraph's line Word sets high. The later layer measures the - // gap to itself from the page: the line is not lowered by it, and the later layer stands - // where it did. + // The first layer ends with a paragraph's line Word sets high, exact Ubuntu. The later layer + // measures the gap to itself from the page: the line is not lowered by it, and the later + // layer stands where it did. com.demcha.compose.document.style.DocumentTextStyle lato = com.demcha.compose.document.style .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.LATO).size(10).build(); + com.demcha.compose.document.style.DocumentTextStyle high = com.demcha.compose.document.style + .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.UBUNTU).size(10).build(); try (Export export = export(stack -> stack .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addParagraph(p -> p.name("Title").text("Heading") - .textStyle(lato).margin(DocumentInsets.top(20)))), LayerAlign.TOP_LEFT) + .textStyle(high).margin(DocumentInsets.top(20)))), LayerAlign.TOP_LEFT) .layer(column("Sidebar", 0, MAIN, side -> side.addParagraph("Contact")), LayerAlign.TOP_LEFT) .layer(column("MainLayer", SIDEBAR, 0, main -> main .addSpacer(spacer -> spacer.name("TitlePlace").width(100).height(80)) @@ -173,7 +175,7 @@ void aLaterLayerGivesNothingToTheLineEndingTheLayerAboveIt() throws Exception { // A paragraph under it in its own layer gives it. try (Export export = export(stack -> stack .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addParagraph(p -> p.name("Title").text("Heading") - .textStyle(lato).margin(DocumentInsets.top(20))) + .textStyle(high).margin(DocumentInsets.top(20))) .addParagraph(p -> p.text("Under").textStyle(lato).margin(DocumentInsets.top(10)))), LayerAlign.TOP_LEFT) .layer(column("Sidebar", 0, MAIN, side -> side.addParagraph("Contact")), LayerAlign.TOP_LEFT) @@ -235,9 +237,10 @@ void aColumnEndingWithAListKeepsItsLoweringWhereTheRowOrTheGapUnderTheStackTakes @Test void aLaterLayerThatWritesNothingLeavesTheRaiseOwedInTheCell() throws Exception { // The later layer only holds the title's place: written of it is nothing, and the raised - // title still owes below it what it was raised by, at the cell's end. + // title, exact Poppins Word sets low, still owes below it what it was raised by, at the + // cell's end. com.demcha.compose.document.style.DocumentTextStyle spectral = com.demcha.compose.document.style - .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.SPECTRAL).size(30).build(); + .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.POPPINS).size(30).build(); try (Export export = export(stack -> stack .layer(column("TitleLayer", SIDEBAR, 0, top -> top.addParagraph(p -> p.name("Title").text("Title") .textStyle(spectral).margin(DocumentInsets.top(20)))), LayerAlign.TOP_LEFT) @@ -260,9 +263,9 @@ void aLaterLayerThatWritesNothingLeavesTheRaiseOwedInTheCell() throws Exception void aLaterLayerUnderAPanelTakesNoRaiseThePanelAlreadyOwedInside() throws Exception { // The first layer ends in a painted card whose line is raised: the card's cell owes the // raise below that line, inside the card, and the later layer's gap from the card's foot - // is the page's alone. + // is the page's alone. The line is exact Poppins, which Word sets low. com.demcha.compose.document.style.DocumentTextStyle spectral = com.demcha.compose.document.style - .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.SPECTRAL).size(30).build(); + .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.POPPINS).size(30).build(); com.demcha.compose.document.style.DocumentTextStyle lato = com.demcha.compose.document.style .DocumentTextStyle.builder().fontName(com.demcha.compose.font.FontName.LATO).size(10).build(); try (Export export = export(stack -> stack diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLineGapAboveTheRowTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLineGapAboveTheRowTest.java index ccab784d1..271f93bca 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLineGapAboveTheRowTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLineGapAboveTheRowTest.java @@ -115,7 +115,10 @@ void aRowOpeningItsPageIsNotLifted() throws Exception { @Test void aGapTooSmallForItsLinesPositionToTakeBackIsSharedOut() throws Exception { // Lifted by 0.6pt, the first of two lines would stand 0.36pt high, less than its position - // moves it, and a cell's last paragraph has no space under it to lower it into. + // moves it, and a cell's last paragraph has no space under it to lower it into. Its lines, + // as tall as Lato's own and the gap's half, are written at a least height, whose text + // stands on the line's foot: the middle of the two stands a quarter of the gap low, which + // the row is lifted by. double gap = 0.6; try (Export export = export(page -> aRow(page, ABOVE, WRAPPING, gap)); Export alone = export(page -> aRow(page, ABOVE, "Phase", gap))) { @@ -123,7 +126,10 @@ void aGapTooSmallForItsLinesPositionToTakeBackIsSharedOut() throws Exception { assertThat(lineTwips(export.paragraph("Research"))).as("the line and half the gap, as before") .isCloseTo(Math.round((line + gap / 2) * 20), within(1L)); - assertThat(after(export.paragraph("Above"))).as("nothing lifted").isEqualTo(after(alone.paragraph("Above"))); + assertThat(export.paragraph("Research").getCTP().getPPr().getSpacing().getLineRule()) + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.AT_LEAST); + assertThat(after(export.paragraph("Above"))).as("lifted by the middle line's quarter gap") + .isEqualTo(after(alone.paragraph("Above")) - Math.round(gap / 4 * 20)); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java index 82417ba3e..773cb1a85 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java @@ -36,12 +36,17 @@ */ class DocxListItemSeatTest { - /** Spectral's ascent, in ems: 1059 of 1000 units. */ - private static final double SPECTRAL_ASCENT = 1.059; - /** Spectral's own line, in ems: its ascent and 463 units of descent. */ - private static final double SPECTRAL_LINE = 1.522; + /** Poppins' ascent, in ems: 1050 of 1000 units. */ + private static final double POPPINS_ASCENT = 1.05; + /** + * The page's line for Poppins, in ems: its ascent and 350 units of descent. Its own line holds + * a line gap of 100 units above that, so its lines are tighter than their own, and exact. + */ + private static final double POPPINS_LINE = 1.4; /** Lato's ascent, in ems: 1974 of 2000 units. */ private static final double LATO_ASCENT = 0.987; + /** Ubuntu's ascent, in ems: 932 of 1000 units, its own line taller than the page's. */ + private static final double UBUNTU_ASCENT = 0.932; /** * The paragraph above a list: Gothic A1, which Word seats where the page does, so it takes * nothing of the space above the list to be lowered by ({@link DocxParagraphLowerTest}). @@ -51,17 +56,17 @@ class DocxListItemSeatTest { @Test void anItemWordSetsLowRisesIntoTheSpaceAboveItAndTheNextIntoWhatItOwes() throws Exception { - // Spectral's 30pt line stands nearly 5pt low in Word. The first item moves up into the + // Poppins' 30pt exact line stands 2pt low in Word. The first item moves up into the // list's 20pt above it; the second into the gap and what the first owes, the gap left as // it was; the paragraph under the list stands where it did, the last item's raise above it. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) - .addList(list -> list.textStyle(spectral(30)).itemSpacing(10).margin(DocumentInsets.top(20)) + .addList(list -> list.textStyle(poppins(30)).itemSpacing(10).margin(DocumentInsets.top(20)) .items("One", "Two")) .addParagraph(p -> p.text("Body").textStyle(lato(10))))) { XWPFParagraph one = paragraph(document, "One"); XWPFParagraph two = paragraph(document, "Two"); - double raise = 0.8 * line(one) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(one) - POPPINS_ASCENT * 30; assertThat(raise).as("the premise: Word's baseline is the lower, by less than the gap").isBetween(1.0, 10.0); assertThat(before(one)).isCloseTo(Math.round((20 - raise) * 20), within(1L)); @@ -75,16 +80,16 @@ void anItemWordSetsLowRisesIntoTheSpaceAboveItAndTheNextIntoWhatItOwes() throws @Test void anItemWhoseMarkerIsWrittenAsTextIsLoweredWithItByItsPosition() throws Exception { - // Lato's 24pt line stands 0.65pt high in Word: moved down, the space above as written, and - // the marker, a run of the item's own, with it. + // Ubuntu's 24pt exact line stands 0.84pt high in Word: moved down, the space above as + // written, and the marker, a run of the item's own, with it. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) - .addList(list -> list.textStyle(lato(24)).marker("•").hangingIndent(true).markerGap(6) - .margin(DocumentInsets.top(10)).addItem(text -> text.style("Heading", lato(24)))) + .addList(list -> list.textStyle(ubuntu(24)).marker("•").hangingIndent(true).markerGap(6) + .margin(DocumentInsets.top(10)).addItem(text -> text.style("Heading", ubuntu(24)))) .addParagraph(p -> p.text("Body").textStyle(lato(10)).margin(DocumentInsets.top(10))))) { XWPFParagraph item = document.getParagraphs().stream() .filter(paragraph -> paragraph.getText().contains("Heading")).findFirst().orElseThrow(); - double raise = 0.8 * line(item) - LATO_ASCENT * 24; + double raise = 0.8 * line(item) - UBUNTU_ASCENT * 24; assertThat(item.getText()).as("the premise: its marker written as its text").startsWith("•"); assertThat(item.getCTP().getPPr().getSpacing().getLineRule()).as("the premise: an exact line") @@ -118,14 +123,14 @@ void anItemWhoseMarkerWordDrawsIsNotMovedByItsPosition() throws Exception { @Test void anItemWhoseMarkerWordDrawsRisesAsFarAsTheSpaceAboveAndNoFurther() throws Exception { - // Spectral's 30pt line stands nearly 5pt low in Word, under 2pt of space: the line and its + // Poppins' 30pt exact line stands 2pt low in Word, under 2pt of space: the line and its // marker rise those 2pt, and what is left stays, rather than the text alone moving on. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addParagraph(p -> p.text("Lead").textStyle(LEAD)) - .addList(list -> list.textStyle(spectral(30)).margin(DocumentInsets.top(2)).items("Title")) + .addList(list -> list.textStyle(poppins(30)).margin(DocumentInsets.top(2)).items("Title")) .addParagraph(p -> p.text("Body").textStyle(lato(10))))) { XWPFParagraph item = paragraph(document, "Title"); - double raise = 0.8 * line(item) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(item) - POPPINS_ASCENT * 30; assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue(); assertThat(raise).as("the premise: lower than the space above holds").isGreaterThan(2.0); @@ -145,10 +150,10 @@ void anItemOpeningABandWithNoSpaceWrittenAboveItIsRaisedByItsPosition() throws E .addParagraph(p -> p.text("Above").textStyle(LEAD)) .addLayerStack(stack -> stack.name("Stack") .back(new ShapeBuilder().name("Backdrop").size(200, 80).build()) - .layer(new ListBuilder().noMarker().textStyle(spectral(30)) + .layer(new ListBuilder().noMarker().textStyle(poppins(30)) .items("Title").build(), LayerAlign.TOP_LEFT)))) { XWPFParagraph item = anywhere(document, "Title"); - double raise = 0.8 * line(item) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(item) - POPPINS_ASCENT * 30; assertThat(raise).as("the premise: Word's baseline is the lower").isGreaterThan(1); assertThat(item.getCTP().getPPr().getSpacing().isSetBefore()).as("the premise: no space above it").isFalse(); @@ -164,13 +169,13 @@ void anItemOfAListInATablesComposedCellRisesIntoTheGapAboveItAsAParagraphThereDo // as much below; the first, under no space of its own, is moved by its position. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t .columns(DocumentTableColumn.fixed(200)) - .rowCells(DocumentTableCell.node(new ListBuilder().noMarker().textStyle(spectral(30)).itemSpacing(10) + .rowCells(DocumentTableCell.node(new ListBuilder().noMarker().textStyle(poppins(30)).itemSpacing(10) .items("One", "Two").build()))))) { XWPFParagraph one = inACell(document, "One"); XWPFParagraph two = inACell(document, "Two"); - double raise = 0.8 * line(two) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(two) - POPPINS_ASCENT * 30; - assertThat(line(one)).as("the premise: the page's line").isCloseTo(SPECTRAL_LINE * 30, within(0.05)); + assertThat(line(one)).as("the premise: the page's line").isCloseTo(POPPINS_LINE * 30, within(0.05)); assertThat(raise).as("the premise: Word's baseline is the lower, by less than the gap").isBetween(1.0, 10.0); assertThat(before(two)).isCloseTo(Math.round((10 - raise) * 20), within(1L)); assertThat(after(two)).as("what it owes, under the cell's last item").isCloseTo(Math.round(raise * 20), within(1L)); @@ -184,16 +189,16 @@ void anItemsLinesAreSeatedAtTheirMiddleOne() throws Exception { // Two lines 10pt apart and no space above to give: Word's lines are each 5pt taller than // the face, stepping 5pt closer than the page's, and the error is shared by the two. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page - .addList(list -> list.noMarker().textStyle(spectral(20)).lineSpacing(10).items("First\nSecond")))) { + .addList(list -> list.noMarker().textStyle(poppins(20)).lineSpacing(10).items("First\nSecond")))) { XWPFParagraph item = document.getParagraphs().stream() .filter(paragraph -> paragraph.getText().contains("First")).findFirst().orElseThrow(); - double face = SPECTRAL_LINE * 20; + double face = POPPINS_LINE * 20; double line = line(item); double middle = 0.5; assertThat(line).as("the premise: the one gap shared by two lines").isCloseTo(face + 5, within(0.05)); assertThat(position(item)).isCloseTo(Math.round( - (middle * line + 0.8 * line - middle * (face + 10) - SPECTRAL_ASCENT * 20) * 2), within(1L)); + (middle * line + 0.8 * line - middle * (face + 10) - POPPINS_ASCENT * 20) * 2), within(1L)); } } @@ -203,7 +208,7 @@ void anItemTheLayoutSetsOnANewPageKeepsTheSpaceAboveIt() throws Exception { // item would stand as low as before and what it owed would stand the third lower. try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) { session.pageFlow(page -> page.spacer(1, 480) - .addList(list -> list.name("Duties").noMarker().textStyle(spectral(30)).itemSpacing(10) + .addList(list -> list.name("Duties").noMarker().textStyle(poppins(30)).itemSpacing(10) .margin(DocumentInsets.top(10)).items("One", "Two", "Three"))); ListThe expected heights and descents are Word's (16.0, Windows), measured from the PDF it made + * of a 20pt paragraph of each face in single spacing: the distance between its baselines, and + * from a baseline to the line's foot. Word counts on a grid of a six-hundredth of an inch, so + * they are held to 0.15pt.
+ * + * @author Artem Demchyshyn + */ +class DocxNaturalLineTest { + + private static final double SIZE = 20; + private static final double WORDS_GRID = 0.15; + + @Test + void aFaceThatUsesItsTypographicMetricsIsSetOnThemWithTheirLineGapAbove() throws IOException { + // Lato, Poppins and Gothic A1 set USE_TYPO_METRICS; Poppins and Gothic A1 have a line gap, + // which Word adds above the text. + assertLine(FontName.LATO, 24.00, 4.30); + assertLine(FontName.POPPINS, 30.00, 6.94); + assertLine(FontName.GOTHIC_A1, 25.02, 4.12); + assertLine(FontName.SPECTRAL, 30.42, 9.16); + } + + @Test + void aFaceThatDoesNotIsSetOnItsWindowsMetricsWithWhatItsLineGapAsksBeyondThem() throws IOException { + // Tinos asks for 87 units of line gap beyond its Windows ascent and descent; Carlito and + // Volkhov for none. + assertLine(FontName.TINOS, 22.98, 4.24); + assertLine(FontName.CARLITO, 24.42, 5.44); + assertLine(FontName.VOLKHOV, 25.80, 6.34); + } + + @Test + void aFaceWhoseHheaExtentsAreNotTheMetricsItsLineIsSetOnHasNoLine() throws IOException { + // Alegreya Sans sets USE_TYPO_METRICS on a typographic descent of 0.2 of its size, and its + // hhea, which the page measures by, descends 0.3: a least line would stand its text a + // tenth of its size off the page's baseline. IBM Plex Serif, on its Windows metrics, descends + // 286 units there and 275 in its hhea. + assertThat(DocxNaturalLine.of(regularOf(FontName.ALEGREYA_SANS))).as("typographic").isNull(); + assertThat(DocxNaturalLine.of(regularOf(FontName.IBM_PLEX_SERIF))).as("Windows").isNull(); + } + + @Test + void aFileWithoutTheTablesHasNoLine() throws IOException { + byte[] lato = regularOf(FontName.LATO); + + assertThat(DocxNaturalLine.of(null)).isNull(); + assertThat(DocxNaturalLine.of(new byte[8])).isNull(); + assertThat(DocxNaturalLine.of(Arrays.copyOf(lato, 400))) + .as("a file cut off inside its table directory's tables") + .isNull(); + assertThat(DocxNaturalLine.of(withTableLength(lato, "OS/2", 70))) + .as("an OS/2 table that ends before its Windows metrics, the next table's bytes after it") + .isNull(); + } + + /** The font with the length its table directory states for one table changed. */ + private static byte[] withTableLength(byte[] font, String tag, int length) { + byte[] changed = font.clone(); + java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(changed); + int tables = Short.toUnsignedInt(buffer.getShort(4)); + for (int index = 0; index < tables; index++) { + int entry = 12 + index * 16; + if (tag.equals(new String(changed, entry, 4, java.nio.charset.StandardCharsets.US_ASCII))) { + buffer.putInt(entry + 12, length); + return changed; + } + } + throw new AssertionError("no " + tag + " table"); + } + + private static void assertLine(FontName face, double height, double descent) throws IOException { + DocxNaturalLine line = DocxNaturalLine.of(regularOf(face)); + + assertThat(line).as(face.toString()).isNotNull(); + assertThat((line.ascent() + line.descent()) * SIZE).as("%s line", face).isCloseTo(height, within(WORDS_GRID)); + assertThat(line.descent() * SIZE).as("%s descent", face).isCloseTo(descent, within(WORDS_GRID)); + } + + private static byte[] regularOf(FontName face) throws IOException { + try (InputStream in = DocxFontTable.familiesByName(null).get(face).fontSourceSet().orElseThrow() + .regular().openStream()) { + return in.readAllBytes(); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphLowerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphLowerTest.java index 9d1c1db3a..75ad50d3c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphLowerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphLowerTest.java @@ -26,17 +26,19 @@ * where it did. * *Word and LibreOffice set an exact line's baseline four fifths of the way down it, the page - * its face's ascent below the line's top: Lato's ascent is 0.82 of its line, and at 10pt Word - * sets it about a quarter point high. Under half a point its position moves nothing, and a raise + * its face's ascent below the line's top: Ubuntu's ascent is 0.83 of its line, and at 10pt Word + * sets it about a third of a point high. Under half a point its position moves nothing, and a raise * only moves a line up. What nothing under the line gives back, it is not lowered by, so nothing - * under it ever stands lower.
+ * under it ever stands lower. Ubuntu's own line, its Windows ascent and descent, is taller than + * the page's, so its lines are written exact. */ class DocxParagraphLowerTest { - /** Lato's ascent, in ems: 1974 of 2000 units. */ - private static final double LATO_ASCENT = 0.987; + /** Ubuntu's ascent, in ems: 932 of 1000 units. */ + private static final double UBUNTU_ASCENT = 0.932; /** A line Word sets high, by under half a point. */ - private static final DocumentTextStyle HIGH = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); + private static final DocumentTextStyle HIGH = DocumentTextStyle.builder().fontName(FontName.UBUNTU).size(10) + .build(); /** A paragraph that wraps onto two lines of the 360pt the page gives it. */ private static final String WRAPPED = "A line long enough to wrap onto a second one in a column three hundred and " + "sixty points wide, set in Gothic A1 at ten points"; @@ -356,13 +358,13 @@ void aLineSetHighOpeningACardIsNotLowered() throws Exception { @Test void aLineSetHighByUnderATenthOfAPointIsNotLowered() throws Exception { // Word sets lines on a grid 0.12pt apart: a twip of space moves a line a step or nothing. - DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.LATO).size(3).build(); + DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.UBUNTU).size(2.5).build(); XWPFDocument document = export(page -> page .addParagraph(p -> p.text("Lead").textStyle(EVEN)) .addParagraph(p -> p.text("Small").textStyle(small)) .addParagraph(p -> p.text("Next").textStyle(EVEN).margin(DocumentInsets.top(10)))); XWPFParagraph line = paragraph(document, "Small"); - double high = LATO_ASCENT * 3 - 0.8 * DocxTwips.of(line.getCTP().getPPr().getSpacing().getLine()) / 20.0; + double high = UBUNTU_ASCENT * 2.5 - 0.8 * DocxTwips.of(line.getCTP().getPPr().getSpacing().getLine()) / 20.0; assertThat(high).as("the premise: set high, by under a tenth of a point").isBetween(0.05, 0.1); assertThat(before(line)).isZero(); @@ -589,10 +591,13 @@ private static com.demcha.compose.document.node.DocumentNode stackedRow() { .build(); } - /** How far Word sets a 10pt Lato line above the page's baseline, in twips, from the line written. */ + /** How far Word sets a 10pt Ubuntu line above the page's baseline, in twips, from the line written. */ private static long lowering(XWPFParagraph paragraph) { + assertThat(paragraph.getCTP().getPPr().getSpacing().getLineRule()) + .as("the premise: an exact line") + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT); double line = DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine()) / 20.0; - long twips = Math.round((LATO_ASCENT * 10 - 0.8 * line) * 20); + long twips = Math.round((UBUNTU_ASCENT * 10 - 0.8 * line) * 20); assertThat(twips).as("the premise: Word sets it high, by under half a point").isBetween(2L, 9L); return twips; } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java index 11ac489ba..b7770659b 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSpaceAboveTest.java @@ -12,6 +12,7 @@ import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; import java.util.List; @@ -101,17 +102,22 @@ void aParagraphPulledUpInsideASectionPulledUpAddsToItsPull() throws Exception { void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception { // CobaltRota's lockup: a subtitle pulled up under its wordmark, in a cell with nothing // above to give the pull. The wordmark's line, its letters standing on the baseline, is - // written 3.5pt shorter, its text lowered by the four fifths of that Word's baseline would - // rise — 6 half points, where three quarters would round to 5 — and the subtitle has no - // space above it. + // written 3.4pt shorter, and the subtitle has no space above it. Alone, the wordmark's line + // is its face's own and written at a least height, its text on the line's foot, where the + // page sets it; shortened, it is written exact, its text moved by the four fifths of the + // pull Word's baseline would rise and by the 0.57pt its exact seat stands above its least + // one: 6.57 half points together, 7. Each rounded apart, 5.44 and 1.13, they would come to + // 6, a quarter point short. DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build(); XWPFParagraph[] plain = lockup(wordmark, 0); - XWPFParagraph[] pulled = lockup(wordmark, -3.5); + XWPFParagraph[] pulled = lockup(wordmark, -3.4); double shorter = (DocxTwips.of(lineOf(plain[0])) - DocxTwips.of(lineOf(pulled[0]))) / 20.0; - assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(3.5, within(0.06)); + assertThat(ruleOf(plain[0])).isEqualTo(STLineSpacingRule.AT_LEAST); + assertThat(ruleOf(pulled[0])).as("made exact to be shortened").isEqualTo(STLineSpacingRule.EXACT); + assertThat(shorter).as("the wordmark's line gives up the pull").isCloseTo(3.4, within(0.01)); assertThat(positionOf(pulled[0]) - positionOf(plain[0])).as("its text lowered as its baseline would rise") - .isEqualTo(-6); + .isEqualTo(-7); assertThat(DocxTwips.of(lineOf(pulled[1]))).as("the pulled line whole, the pull taken above it") .isEqualTo(DocxTwips.of(lineOf(plain[1]))); } @@ -174,14 +180,29 @@ void aPullDeeperThanTheLettersAboveLeaveRoomForRisesInsideItsOwnLine() throws Ex assertThat(DocxTwips.of(lineOf(pulled[1]))).as("and the pulled line the rest").isLessThan(DocxTwips.of(lineOf(plain[1]))); } + @Test + void aLineAboveWhoseLettersLeaveNoRoomUnderThemKeepsItsLeastHeight() throws Exception { + // Its descenders reach the foot of its line: nothing is taken, and the line is not made + // exact for nothing, its text where the least line stands it. + DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build(); + XWPFParagraph[] pulled = lockup("gypsy", wordmark, -3.5); + + assertThat(ruleOf(pulled[0])).isEqualTo(STLineSpacingRule.AT_LEAST); + assertThat(positionOf(pulled[0])).isZero(); + } + /** A wordmark over a 10pt subtitle pulled up by {@code pull}, in a row's cell. */ private static XWPFParagraph[] lockup(DocumentTextStyle wordmark, double pull) throws Exception { + return lockup("HALL", wordmark, pull); + } + + private static XWPFParagraph[] lockup(String mark, DocumentTextStyle wordmark, double pull) throws Exception { DocumentTextStyle subtitle = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page .addRow("Head", row -> row .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(1)) .addSection("Lockup", cell -> cell - .addParagraph(p -> p.text("HALL").textStyle(wordmark)) + .addParagraph(p -> p.text(mark).textStyle(wordmark)) .addParagraph(p -> p.text("QUAYSIDE BAR").textStyle(subtitle) .margin(new DocumentInsets(pull, 0, 0, 0)))) .addParagraph(p -> p.text("MONDAY").textStyle(subtitle))))) { @@ -194,6 +215,10 @@ private static Object lineOf(XWPFParagraph paragraph) { return paragraph.getCTP().getPPr().getSpacing().getLine(); } + private static STLineSpacingRule.Enum ruleOf(XWPFParagraph paragraph) { + return paragraph.getCTP().getPPr().getSpacing().getLineRule(); + } + private static int positionOf(XWPFParagraph paragraph) { var run = paragraph.getRuns().get(0).getCTR(); return run.isSetRPr() && run.getRPr().sizeOfPositionArray() > 0 diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerSeatTest.java index 34ebb85c8..47c7ba2f0 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerSeatTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxStackOfOneLayerSeatTest.java @@ -37,14 +37,19 @@ */ class DocxStackOfOneLayerSeatTest { - /** Spectral's ascent, in ems: 1059 of 1000 units. */ - private static final double SPECTRAL_ASCENT = 1.059; - /** Lato's ascent, in ems: 1974 of 2000 units. */ - private static final double LATO_ASCENT = 0.987; - /** Spectral at 30pt, which Word sets nearly 5pt low. */ - private static final DocumentTextStyle LOW = DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(30).build(); - /** Lato at 10pt, which Word sets high by under half a point. */ - private static final DocumentTextStyle HIGH = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); + /** Poppins' ascent, in ems: 1050 of 1000 units. */ + private static final double POPPINS_ASCENT = 1.05; + /** Ubuntu's ascent, in ems: 932 of 1000 units. */ + private static final double UBUNTU_ASCENT = 0.932; + /** + * Poppins at 30pt, which Word sets 2pt low: its own line holds a line gap the page's does not, + * so the line is tighter than it and exact. + */ + private static final DocumentTextStyle LOW = DocumentTextStyle.builder().fontName(FontName.POPPINS).size(30) + .build(); + /** Ubuntu at 10pt, whose own line is taller than the page's, which Word sets high by under half a point. */ + private static final DocumentTextStyle HIGH = DocumentTextStyle.builder().fontName(FontName.UBUNTU).size(10) + .build(); /** Gothic A1, which Word seats where the page does to a few hundredths: neither raised nor lowered. */ private static final DocumentTextStyle EVEN = DocumentTextStyle.builder().fontName(FontName.GOTHIC_A1).size(10).build(); @@ -57,7 +62,7 @@ void aLineSetLowIsRaisedIntoTheSpaceAboveItAndTheBlockPastTheStackIsGivenWhatItO .build(), LayerAlign.TOP_LEFT)) .addParagraph(p -> p.text("Body").textStyle(EVEN))); XWPFParagraph title = paragraph(document, "Title"); - double raise = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(title) - POPPINS_ASCENT * 30; assertThat(raise).as("the premise: Word's baseline is the lower, by less than the space").isBetween(1.0, 20.0); assertThat(before(title)).isCloseTo(Math.round((20 - raise) * 20), within(1L)); @@ -78,7 +83,7 @@ void anItemSetLowRisesIntoTheSpaceAboveItAndTheNextIntoWhatItOwes() throws Excep .build(), LayerAlign.TOP_LEFT)) .addParagraph(p -> p.text("Body").textStyle(EVEN))); XWPFParagraph one = paragraph(document, "One"); - double raise = 0.8 * line(one) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(one) - POPPINS_ASCENT * 30; assertThat(raise).as("the premise: Word's baseline is the lower, by less than the gap").isBetween(1.0, 10.0); assertThat(before(one)).isCloseTo(Math.round((20 - raise) * 20), within(1L)); @@ -103,7 +108,7 @@ void aLineOpeningItsPageIsNotRaisedIntoTheSpaceAboveIt() throws Exception { } XWPFDocument document = export(content); XWPFParagraph title = paragraph(document, "Title"); - double raise = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(title) - POPPINS_ASCENT * 30; assertThat(position(title)).as("raised by its position").isCloseTo((int) Math.round(raise * 2), within(1)); assertThat(before(paragraph(document, "Body"))).as("nothing owed below it").isZero(); @@ -125,7 +130,7 @@ void aLineSetHighOpeningItsPageIsNotLowered() throws Exception { } XWPFDocument document = export(content); XWPFParagraph high = paragraph(document, "High"); - long lower = Math.round((LATO_ASCENT * 10 - 0.8 * line(high)) * 20); + long lower = Math.round((UBUNTU_ASCENT * 10 - 0.8 * line(high)) * 20); assertThat(lower).as("the premise: Word sets it high, by under half a point").isBetween(2L, 9L); assertThat(before(paragraph(document, "Next"))).as("its 10pt, whole").isEqualTo(200); @@ -212,7 +217,7 @@ void aLineInAStackOfOneLayerInsideABandIsRaisedIntoTheSpaceAboveIt() throws Exce .addParagraph(p -> p.text("Title").textStyle(LOW).margin(DocumentInsets.top(20))) .build(), LayerAlign.TOP_LEFT).build(), LayerAlign.TOP_LEFT)))) { XWPFParagraph title = paragraph(document, "Title"); - double raise = 0.8 * line(title) - SPECTRAL_ASCENT * 30; + double raise = 0.8 * line(title) - POPPINS_ASCENT * 30; assertThat(raise).as("the premise: Word's baseline is the lower").isGreaterThan(1); assertThat(before(title)).as("its 20pt less the raise").isCloseTo(Math.round((20 - raise) * 20), within(1L)); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java index f8e2b0124..7e0d382e8 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java @@ -71,7 +71,7 @@ void aHeadersLogoStandsWhereThePageDrawsIt() throws Exception { assertThat(share(line)).as("the line holds it above its baseline").isGreaterThanOrEqualTo(24 - 0.05); assertThat(line.getRuns().get(0).getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().getDescr()) .as("no file name for a screen reader to read out").isEmpty(); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -104,7 +104,7 @@ void textSetAboveItsLogoGrowsTheLineToHoldIt() throws Exception { assertThat(lineOf(line)).as("30pt above the logo's foot, four fifths of it").isCloseTo(37.5, within(0.1)); assertThat(baseline).as("the logo's foot").isCloseTo(PAGE_HEIGHT - exported.picture().y(), within(0.1)); assertThat(baseline - positionOf(text)).isCloseTo(exported.baseline("Quarterly"), within(0.5)); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -114,7 +114,7 @@ void aFootersLogoStandsWhereThePageDrawsIt() throws Exception { assertThat(fromTheTop(exported.margin().getFooter()) + (lineOf(line) - share(line))) .as("its foot, from the page's foot").isCloseTo(exported.picture().y(), within(0.1)); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -135,7 +135,7 @@ void textBesideALogoIsRaisedToTheBaselineThePageSetsItOn() throws Exception { assertThat(text.text()).isEqualTo("Quarterly"); assertThat(baseline - positionOf(text)).as("raised onto its own baseline, within half a point, " + align) .isCloseTo(exported.baseline("Quarterly"), within(0.5)); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } } @@ -154,7 +154,7 @@ void textRightAfterALogoStartsWhereWordSetsIt() throws Exception { .addParagraph(p -> p.text("Quarterly").textStyle(CHROME)) .build())); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); assertThat(split.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail) .containsExactly("a header written as one line of Word's header; 1 of its 2 parts stands off where " + "the page sets them"); @@ -175,7 +175,7 @@ void aPageNumberBesideAFootersLogoIsRaisedEveryRunOfItsField() throws Exception .allSatisfy(run -> assertThat(positionOf(run)).isEqualTo(raise)); double baseline = PAGE_HEIGHT - fromTheTop(exported.margin().getFooter()) - (lineOf(line) - share(line)); assertThat(baseline - raise).isCloseTo(exported.baseline("1"), within(0.5)); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -223,7 +223,7 @@ void aLinkedLogoIsWrittenInItsLink() throws Exception { XWPFHeaderFooter header = exported.document().getHeaderList().get(0); assertThat(header.getPackagePart().getRelationship(link.getId()).getTargetURI()) .hasToString("https://example.com"); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -317,7 +317,7 @@ void aPartThePageSetsLowerIsLoweredToTheHalfPointTheLineHolds() throws Exception assertThat(positionOf(text)).as("lowered, to the half point towards the baseline").isEqualTo(-4.0); assertThat(baseline - positionOf(text)).isCloseTo(exported.baseline("Quarterly"), within(0.5)); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -409,7 +409,7 @@ void aLogoOfTheSameBytesOnEveryPageIsMeasuredWhereItsHeightIsThePicturesOwn() th assertThat(fromTheTop(exported.margin().getHeader()) + share(line)) .isCloseTo(PAGE_HEIGHT - exported.picture().y(), within(0.1)); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test @@ -423,7 +423,7 @@ void aLogoBeforeAPageNumberAgainstTheRightMarginEndsWhereWordSetsIt() throws Exc .add(page.pageNumber(CHROME)) .build())); - assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue(); + assertThat(DocxExports.besidesTheExactLines(exported.report())).isEmpty(); } @Test