From e249e2233361150399ad7193a6e14ddf2eb449b4 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 24 Oct 2026 23:54:43 +0100
Subject: [PATCH 1/2] fix(docx): grow lines 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 the text holds: the Normal style two points up cut
letters off at the top. A paragraph's or a list item's line at least
as tall as its runs' own line, by the faces' own tables (DocxNaturalLine,
read for each shipped face by DocxFontTable), is now written as a least
height, where its runs share one depth, hold no raise, script or picture
in the line, the paragraph is seated on its baseline and Word then
stands its text within a quarter point of the page's.
- Word and LibreOffice stand a least line's baseline its runs' descent
above its foot, the spare above the letters (measured, to 0.15pt):
DocxGrownLines.lineOf gives that seat to the baseline seating and to
the anchors of shapes beside the text. A least line shortened for a
pulled paragraph below is written exact first, its text where it stood.
- The report names, once for the document, how many paragraphs of text
are still exact (line height), headers and footers included.
---
CHANGELOG.md | 32 ++
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 10 +-
.../semantic/docx/DocxDrawingAnchors.java | 40 ++-
.../semantic/docx/DocxFontEmbedding.java | 28 +-
.../backend/semantic/docx/DocxFontTable.java | 87 ++++-
.../backend/semantic/docx/DocxGrownLines.java | 318 ++++++++++++++++++
.../semantic/docx/DocxNaturalLine.java | 65 ++++
.../semantic/docx/DocxSemanticBackend.java | 213 ++++++++++--
.../docx/DocxAnchorWithoutABookmarkTest.java | 2 +-
.../semantic/docx/DocxBandRaiseTest.java | 15 +-
.../semantic/docx/DocxBaselineSeatTest.java | 134 ++++++--
.../docx/DocxComposedCellGeometryTest.java | 12 +-
.../semantic/docx/DocxComposedListTest.java | 8 +-
.../semantic/docx/DocxDrawingAnchorsTest.java | 21 ++
.../semantic/docx/DocxExportReportTest.java | 48 ++-
.../backend/semantic/docx/DocxExports.java | 22 ++
.../semantic/docx/DocxFirstLineSeatTest.java | 9 +-
.../docx/DocxGrownLinesExportTest.java | 168 +++++++++
.../semantic/docx/DocxGrownLinesTest.java | 233 +++++++++++++
.../docx/DocxInlineBackgroundTest.java | 2 +-
.../semantic/docx/DocxLayerColumnsTest.java | 31 +-
.../docx/DocxLineGapAboveTheRowTest.java | 10 +-
.../semantic/docx/DocxListItemSeatTest.java | 79 +++--
.../semantic/docx/DocxNaturalLineTest.java | 90 +++++
.../semantic/docx/DocxParagraphLowerTest.java | 25 +-
.../semantic/docx/DocxSpaceAboveTest.java | 34 +-
.../docx/DocxStackOfOneLayerSeatTest.java | 31 +-
.../semantic/docx/DocxZonePictureTest.java | 20 +-
29 files changed, 1574 insertions(+), 215 deletions(-)
create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
create mode 100644 render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b596c9476..5f71f6d47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,38 @@ 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, none raised, 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 none of the raises an exact
+ line's text was seated with. A line Word would still set a quarter point or more off the
+ page's baseline stays exact, where its position seats it.
+ - 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;
+ - 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..241b27a01 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 — 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 |
| 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..102eeedcb 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, none raised, 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; 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, 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 |
| 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 offers = new ArrayList<>();
// Identifiers unique in the document, shared with the other drawings the export writes.
private final LongSupplier ids;
+ // How tall Word stands a written paragraph's line, and its baseline in it.
+ private final Function lines;
// The places given each fragment's shapes in the section so far, by the fragment's place in
// the page's paint order.
private final Map placesTaken = new HashMap<>();
@@ -97,7 +101,17 @@ final class DocxDrawingAnchors {
static final int PLACES_PER_FRAGMENT = 8;
DocxDrawingAnchors(LongSupplier ids) {
+ this(ids, DocxGrownLines.NONE::lineOf);
+ }
+
+ /**
+ * @param ids identifiers unique in the document
+ * @param lines how tall Word stands a written paragraph's line, and its baseline in it
+ * ({@link DocxGrownLines#lineOf})
+ */
+ DocxDrawingAnchors(LongSupplier ids, Function lines) {
this.ids = ids;
+ this.lines = lines;
}
/** Forgets everything, for a new export. */
@@ -410,7 +424,7 @@ private Place placeBeside(DocxDrawings.Shape shape) {
// the paragraph's first line does: Word and LibreOffice place it there, out of a cell's
// top too, and draw it whole. One rising further stands by other text: LibreOffice set an
// 11pt rule 45pt above its paragraph's top at the head of the next page.
- return nearest == null ? null : nearest.place(shape);
+ return nearest == null ? null : nearest.place(shape, lines);
}
/**
@@ -474,10 +488,14 @@ record Seat(XWPFParagraph paragraph, double left, double right, double top, doub
*
* 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.
+ * the runs' position, and a least line's its runs' descent above its foot
+ * ({@link DocxGrownLines#lineOf}), which holds no 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.
+ *
+ * @param lines how tall Word stands a paragraph's line, and its baseline in it
*/
- double paragraphTop() {
+ double paragraphTop(Function lines) {
CTPPr properties = paragraph.getCTP().getPPr();
double before = 0;
double lineTop = top;
@@ -498,11 +516,11 @@ record Seat(XWPFParagraph paragraph, double left, double right, double top, doub
}
before = twips.doubleValue() / 20;
}
- if (spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT) {
- if (!(spacing.getLine() instanceof Number twips)) {
- return Double.NaN;
- }
- lineTop = baseline - (DocxTextBands.BASELINE_SHARE * twips.doubleValue() / 20 - raised());
+ DocxGrownLines.Line line = lines.apply(paragraph.getCTP());
+ if (line != null) {
+ lineTop = baseline - (line.baseline() - raised());
+ } else if (spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT) {
+ return Double.NaN;
}
}
}
@@ -552,8 +570,8 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex
* written short of the page's, or in a repeated header row, which Word repeats on every
* page with what is anchored in it.
*/
- Place place(DocxDrawings.Shape shape) {
- double top = paragraphTop();
+ Place place(DocxDrawings.Shape shape, Function lines) {
+ double top = paragraphTop(lines);
if (!(paragraph.getBody() instanceof XWPFTableCell cell)) {
return risesNoFurther(shape, top, false) ? new Place(paragraph, Double.POSITIVE_INFINITY, top) : null;
}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontEmbedding.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontEmbedding.java
index af1b3f43d..f3044e008 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontEmbedding.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontEmbedding.java
@@ -76,6 +76,22 @@ static Permission permissionOf(byte[] font) {
* @return the raw {@code fsType}, or -1 when the font carries no readable {@code OS/2}
*/
private static int readFsType(byte[] font) {
+ // version(2) avgCharWidth(2) weightClass(2) widthClass(2) then fsType.
+ int offset = tableOffset(font, "OS/2", 10);
+ return offset < 0 ? -1 : Short.toUnsignedInt(ByteBuffer.wrap(font).order(ByteOrder.BIG_ENDIAN)
+ .getShort(offset + 8));
+ }
+
+ /**
+ * Finds a table in the font's table directory.
+ *
+ * @param font the whole font file
+ * @param tag the table's four-letter tag
+ * @param length how many of its bytes the reader needs
+ * @return where the table starts, or -1 when the font has no such table, or the table or the
+ * file ends before that many of its bytes
+ */
+ static int tableOffset(byte[] font, String tag, int length) {
if (font == null || font.length < 12) {
return -1;
}
@@ -86,16 +102,14 @@ private static int readFsType(byte[] font) {
if (entry + 16 > font.length) {
return -1;
}
- String tag = new String(font, entry, 4, java.nio.charset.StandardCharsets.US_ASCII);
- if (!"OS/2".equals(tag)) {
+ if (!tag.equals(new String(font, entry, 4, java.nio.charset.StandardCharsets.US_ASCII))) {
continue;
}
int offset = buffer.getInt(entry + 8);
- // version(2) avgCharWidth(2) weightClass(2) widthClass(2) then fsType.
- if (offset < 0 || offset + 10 > font.length) {
- return -1;
- }
- return Short.toUnsignedInt(buffer.getShort(offset + 8));
+ // The directory states each table's length: a table shorter than the reader needs would
+ // be read into the next one's bytes.
+ int tableLength = buffer.getInt(entry + 12);
+ return offset < 0 || tableLength < length || offset + length > font.length ? -1 : offset;
}
return -1;
}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java
index 60cbb6157..0d202e90e 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxFontTable.java
@@ -90,17 +90,20 @@ private DocxFontTable() {
* @param bands the style each header and footer band of text sets its text in
* @param custom families the session registered, which win over the bundled ones
* @param report where a face that may not travel is recorded
+ * @return the line an editor sets each shipped face in
* @throws IOException if a part cannot be written
*/
- static void write(XWPFDocument document,
- DocumentGraph graph,
- List zones,
- List bands,
- Collection custom,
- DocxExportReport.Builder report) throws IOException {
- List embedded = resolve(graph, zones, bands, custom, report);
+ static ShippedLines write(XWPFDocument document,
+ DocumentGraph graph,
+ List zones,
+ List bands,
+ Collection custom,
+ DocxExportReport.Builder report) throws IOException {
+ Map families = familiesByName(custom);
+ List embedded = resolve(graph, zones, bands, families, report);
+ ShippedLines lines = new ShippedLines(families, embedded);
if (embedded.isEmpty()) {
- return;
+ return lines;
}
OPCPackage pkg = document.getPackage();
@@ -124,6 +127,55 @@ static void write(XWPFDocument document,
try (OutputStream out = table.getOutputStream()) {
out.write(toXml(embedded).getBytes(StandardCharsets.UTF_8));
}
+ return lines;
+ }
+
+ /**
+ * The line an editor sets each face the document ships in where nothing fixes the line's
+ * height ({@link DocxNaturalLine}). A face it does not ship — a standard PDF name, one that
+ * may not travel — is set in whatever the reader's machine substitutes, so it has none.
+ */
+ static final class ShippedLines {
+ /** No face shipped: an export written without a font table. */
+ static final ShippedLines NONE = new ShippedLines(Map.of(), List.of());
+
+ private final Map families;
+ private final Map lines = new java.util.HashMap<>();
+
+ private ShippedLines(Map families, List embedded) {
+ this.families = families;
+ for (Embedded family : embedded) {
+ for (Face face : family.faces()) {
+ if (face.line() != null) {
+ lines.put(family.wordFamily() + '\n' + face.element(), face.line());
+ }
+ }
+ }
+ }
+
+ /**
+ * The line a face is set in.
+ *
+ * @param fontName the face a style names
+ * @param slot which of its family's faces: {@code embedRegular}, {@code embedBold},
+ * {@code embedItalic} or {@code embedBoldItalic}
+ * @return its line, or null where the document does not ship that face
+ */
+ DocxNaturalLine of(FontName fontName, String slot) {
+ FontFamilyDefinition family = fontName == null ? null : families.get(FontLibrary.resolveFamily(fontName));
+ return family == null ? null : of(family.wordFamily(), slot);
+ }
+
+ /**
+ * The line a face is set in, by the family a run names in Word.
+ *
+ * @param wordFamily the family's name as the font table writes it
+ * @param slot which of its faces, as {@link #of(FontName, String)}
+ * @return its line, or null where the document does not ship that face
+ */
+ DocxNaturalLine of(String wordFamily, String slot) {
+ return lines.get(wordFamily + '\n' + slot);
+ }
}
/**
@@ -152,17 +204,22 @@ static Map familiesByName(Collection faces) {
}
- /** One face of a family: which slot it fills, its bytes, and how the part refers to it. */
+ /**
+ * One face of a family: which slot it fills, its bytes, how the part refers to it, and the
+ * line an editor sets it in.
+ */
private static final class Face {
private final String element;
private final byte[] bytes;
private final String fontKey;
+ private final DocxNaturalLine line;
private String relationshipId;
- Face(String element, byte[] bytes, String fontKey) {
+ Face(String element, byte[] bytes, String fontKey, DocxNaturalLine line) {
this.element = element;
this.bytes = bytes;
this.fontKey = fontKey;
+ this.line = line;
}
String element() {
@@ -173,6 +230,10 @@ byte[] bytes() {
return bytes;
}
+ DocxNaturalLine line() {
+ return line;
+ }
+
void relationshipId(String id) {
this.relationshipId = id;
}
@@ -188,7 +249,7 @@ void relationshipId(String id) {
private static List resolve(DocumentGraph graph,
List zones,
List bands,
- Collection custom,
+ Map families,
DocxExportReport.Builder report) {
Map> used = new LinkedHashMap<>();
for (DocumentNode root : graph.roots()) {
@@ -202,8 +263,6 @@ private static List resolve(DocumentGraph graph,
return List.of();
}
- Map families = familiesByName(custom);
-
List embedded = new ArrayList<>();
for (Map.Entry> entry : used.entrySet()) {
FontFamilyDefinition family = families.get(FontLibrary.resolveFamily(entry.getKey()));
@@ -325,7 +384,7 @@ private static void addFace(List faces,
}
}
DocxFontEmbedding.Obfuscated obfuscated = DocxFontEmbedding.obfuscate(bytes);
- faces.add(new Face(slot.element(), obfuscated.bytes(), obfuscated.fontKey()));
+ faces.add(new Face(slot.element(), obfuscated.bytes(), obfuscated.fontKey(), DocxNaturalLine.of(bytes)));
}
/**
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
new file mode 100644
index 000000000..5c0f27b46
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
@@ -0,0 +1,318 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.style.DocumentTextStyle;
+import org.apache.poi.ooxml.util.POIXMLUnits;
+import org.apache.xmlbeans.XmlCursor;
+import org.apache.xmlbeans.XmlObject;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTOnOff;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
+
+import java.util.Set;
+
+/**
+ * How tall a paragraph's lines stand in an editor, and where its text stands in them, from what is
+ * written: an exact line, or a least one its text may grow.
+ *
+ * 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, 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 Set PARAGRAPH_PARTS = Set.of("pPr", "r", "hyperlink", "bookmarkStart", "bookmarkEnd");
+ /** What a run may hold and still be read: text, and what sits in a line of it. */
+ private static final Set RUN_PARTS = Set.of("rPr", "t", "tab", "br", "fldChar", "instrText");
+ /**
+ * How far short of its runs' own line, by the faces' tables, a least line may be written and
+ * be taken as tall as written, in points: the arithmetic's own error, and no more. A line
+ * rounded down to the twip, a hundredth or two short of its own, grows in the editor to its
+ * own on every line, and the paragraph under it stands that much lower for each: Word and
+ * LibreOffice set a least line no shorter than its runs' own, where an exact one keeps the
+ * rounding the export carries (9pt PT Serif lines written at 11.9pt, a fortieth short of their
+ * own, moved {@code ClassicSerif}'s lines up to 0.45pt in LibreOffice).
+ */
+ static final double EPSILON = 0.001;
+ /** WordprocessingML's namespace, which a run's properties' attributes are in. */
+ private static final String W_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ /** Half points in a point, as Word writes sizes. */
+ private static final double HALF_POINTS_PER_POINT = 2;
+ private static final double POINT_TO_TWIP = 20;
+
+ private final DocxFontTable.ShippedLines faces;
+ private final DocumentTextStyle defaults;
+
+ /**
+ * @param faces the line each face the document ships is set in
+ * @param defaults the style the document's Normal style states, which a run that names no face
+ * or size of its own is set in; null where it states none
+ */
+ DocxGrownLines(DocxFontTable.ShippedLines faces, DocumentTextStyle defaults) {
+ this.faces = faces;
+ this.defaults = defaults;
+ }
+
+ /**
+ * How far a paragraph's runs and mark reach above and below their baseline, set in their own
+ * line, in points.
+ *
+ * @param paragraph the written paragraph
+ * @return the reach, or null where one of its runs is not read: a picture, a raise, a face the
+ * document does not ship, a run Word sets right to left, or a marker Word draws itself
+ */
+ Reach reachOf(CTP paragraph) {
+ CTPPr properties = paragraph.getPPr();
+ if (properties != null && properties.isSetNumPr()) {
+ // Word draws a list's marker in the numbering's own face.
+ return null;
+ }
+ // The mark is set in what it states, or else in the Normal style, as a run is: Word counts
+ // it into the paragraph's last line.
+ Reach reach = reachOf(properties != null && properties.isSetRPr() ? properties.getRPr() : null, Reach.NONE);
+ if (reach == null || !holdsOnly(paragraph, PARAGRAPH_PARTS)) {
+ return null;
+ }
+ boolean anyRun = false;
+ try (XmlCursor cursor = paragraph.newCursor()) {
+ cursor.selectPath("declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "
+ + "./w:r | ./w:hyperlink/w:r");
+ while (cursor.toNextSelection()) {
+ XmlObject run = cursor.getObject();
+ if (!(run instanceof org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR text)) {
+ return null;
+ }
+ if (holdsOnlyAnchors(text)) {
+ // A shape anchored to the paragraph takes no room in its line, and its run's
+ // face sets none of it (measured: a 14pt Normal run holding one left a 7.5pt
+ // least line as tall, in Word and in LibreOffice).
+ continue;
+ }
+ if (!holdsOnly(text, RUN_PARTS)) {
+ return null;
+ }
+ reach = reachOf(text.isSetRPr() ? text.getRPr() : null, reach);
+ if (reach == null) {
+ return null;
+ }
+ anyRun = true;
+ }
+ }
+ return anyRun ? reach : null;
+ }
+
+ /**
+ * Whether a paragraph's line, at the height written, is at least as tall as its runs' own
+ * line, so that written at a least height it stays that height, with every line's text the
+ * same height off the line's foot.
+ *
+ * Word 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, Set parts) {
+ try (XmlCursor cursor = element.newCursor()) {
+ for (boolean more = cursor.toFirstChild(); more; more = cursor.toNextSibling()) {
+ if (!parts.contains(cursor.getName().getLocalPart())) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ /**
+ * How far a line's text reaches above and below its baseline, in points.
+ *
+ * @param above how far above, its faces' line gap included
+ * @param below how far below: its deepest run's descent
+ * @param shallowest its shallowest run's descent
+ */
+ record Reach(double above, double below, double shallowest) {
+ static final Reach NONE = new Reach(0, 0, Double.POSITIVE_INFINITY);
+
+ /** The line's height. */
+ double height() {
+ return above + below;
+ }
+ }
+
+ /**
+ * How tall an editor stands a paragraph's line, and its baseline, from the line's top, in
+ * points.
+ *
+ * @param height the line's height
+ * @param baseline how far below the line's top the baseline stands
+ */
+ record Line(double height, double baseline) {
+ }
+
+ /**
+ * What a run's properties say of its face: the family, size and slant they name, each null or
+ * 0 where they leave it to the style. Null where they say something this does not read.
+ */
+ private record Run(String family, int halfPoints, Boolean bold, Boolean italic) {
+ private static final Run UNSTATED = new Run(null, 0, null, null);
+
+ static Run of(XmlObject properties) {
+ if (properties == null) {
+ return UNSTATED;
+ }
+ String family = null;
+ int halfPoints = 0;
+ Boolean bold = null;
+ Boolean italic = null;
+ try (XmlCursor cursor = properties.newCursor()) {
+ for (boolean more = cursor.toFirstChild(); more; more = cursor.toNextSibling()) {
+ String name = cursor.getName().getLocalPart();
+ XmlObject value = cursor.getObject();
+ switch (name) {
+ case "rFonts" -> family = attribute(value, "ascii");
+ case "sz" -> {
+ halfPoints = number(attribute(value, "val"));
+ if (halfPoints <= 0) {
+ return null;
+ }
+ }
+ case "b" -> bold = value instanceof CTOnOff on ? POIXMLUnits.parseOnOff(on) : Boolean.TRUE;
+ case "i" -> italic = value instanceof CTOnOff on ? POIXMLUnits.parseOnOff(on) : Boolean.TRUE;
+ // A raise, a script, a style or a right-to-left run sets its letters elsewhere.
+ case "position", "vertAlign", "rStyle", "rtl", "cs" -> {
+ if (!"position".equals(name) || number(attribute(value, "val")) != 0) {
+ return null;
+ }
+ }
+ default -> {
+ }
+ }
+ }
+ }
+ return new Run(family, halfPoints, bold, italic);
+ }
+
+ private static String attribute(XmlObject element, String name) {
+ try (XmlCursor cursor = element.newCursor()) {
+ return cursor.getAttributeText(new javax.xml.namespace.QName(W_NAMESPACE, name));
+ }
+ }
+
+ private static int number(String text) {
+ try {
+ return text == null ? 0 : Integer.parseInt(text.trim());
+ } catch (NumberFormatException notANumber) {
+ return -1;
+ }
+ }
+ }
+}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java
new file mode 100644
index 000000000..b82484e45
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java
@@ -0,0 +1,65 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * The line an editor sets a face's text in where nothing fixes its height, read from the face's
+ * own tables, in ems.
+ *
+ * Word 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.
+ *
+ * @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
+ */
+ 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 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 ascender = buffer.getShort(os2 + 68);
+ int descender = buffer.getShort(os2 + 70);
+ int lineGap = Math.max(0, buffer.getShort(os2 + 72));
+ return new DocxNaturalLine((ascender + lineGap) / unitsPerEm, -descender / unitsPerEm);
+ }
+ int winAscent = Short.toUnsignedInt(buffer.getShort(os2 + 74));
+ int winDescent = Short.toUnsignedInt(buffer.getShort(os2 + 76));
+ int ascender = buffer.getShort(hhea + 4);
+ int descender = buffer.getShort(hhea + 6);
+ 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..4d107ac40 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 SemanticBackend {
private static final double POINT_TO_TWIP = 20.0;
/** The least difference between Word's baseline and the page's that is moved: one half point. */
private static final double LEAST_BASELINE_SHIFT_POINTS = 0.5;
+ /**
+ * The least difference between Word's baseline and the page's that keeps a line exact
+ * ({@link #letTheLinesGrow}): what a position, counted in half points, leaves at most.
+ */
+ private static final double LEAST_GROWN_LINE_SHIFT_POINTS = 0.25;
/**
* The least raise a line is moved by in the space written above it, in points: Word sets
* lines on a grid of its own, 0.12pt apart (measured), and a twip less of space moves a line
@@ -424,8 +429,12 @@ public final class DocxSemanticBackend implements SemanticBackend {
// Identifiers for the shapes this export draws itself, kept clear of the ones POI numbers
// its pictures with: a drawing's id has to be unique in the document.
private long nextDrawingId;
+ // How tall Word stands each written paragraph's line, and where its text stands in it, by the
+ // faces the export ships (DocxGrownLines).
+ private DocxGrownLines grownLines = DocxGrownLines.NONE;
// Where the shapes this export draws are anchored — see DocxDrawingAnchors.
- private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++);
+ private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++,
+ paragraph -> grownLines.lineOf(paragraph));
// The page the node being written starts on, as the layout placed it, counted within the
// section being written.
private int currentPage;
@@ -1231,6 +1240,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
measuredFamilies = List.copyOf(fonts);
seatFonts = null;
styleMetrics = null;
+ grownLines = DocxGrownLines.NONE;
documentDefaultStyle = dominantTextStyle(whole);
currentCell = null;
aloneInItsRow = null;
@@ -1260,8 +1270,8 @@ private byte[] write(List sections, Path outputFile) throws Exc
applyPageGeometry(document, context.canvas());
if (index == 0) {
writeStylesPart(document);
- DocxFontTable.write(document, whole, zoneContentsIn(sections), bandStylesIn(sections),
- fonts, report);
+ grownLines = new DocxGrownLines(DocxFontTable.write(document, whole, zoneContentsIn(sections),
+ bandStylesIn(sections), fonts, report), documentDefaultStyle);
applyMetadata(document, metadataOf(sections));
reportUnwrittenOutputOptions(sections);
}
@@ -1318,6 +1328,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
hideTheClosingMark(document);
hideTheCellClosingMarks(document.getTables());
letTheOneRowTablesKeptWithTheNextBreak(document.getTables());
+ nameTheExactLines(document);
DocxTextControls.dropFrom(document);
if (textFillsWritten) {
DocxTranslucency.settle(document);
@@ -1600,6 +1611,66 @@ private static boolean distinguishesEvenPages(List sections) {
return false;
}
+ /**
+ * Names, once for the document, the paragraphs of text whose lines are written at an exact
+ * height: Word keeps such a line's height when its text is made larger, and cuts off at its top
+ * the letters that grow taller than it. A line is written at a least height, which grows, only
+ * where the page's line is at least its faces' own and Word then stands its text where the page
+ * does ({@link #letTheLinesGrow}): the rest are set tighter than their faces' own line, in a
+ * face the document does not ship, or written where no line grows — a table's text cell, a
+ * page zone, a text box.
+ */
+ private void nameTheExactLines(XWPFDocument document) {
+ int exact = exactLinesOfTextIn(document.getDocument());
+ // A header or footer made in this export is among the document's relations: POI lists in
+ // getHeaderList() and getFooterList() only the parts it read.
+ for (org.apache.poi.ooxml.POIXMLDocumentPart part : document.getRelations()) {
+ if (part instanceof org.apache.poi.xwpf.usermodel.XWPFHeaderFooter headerOrFooter) {
+ exact += exactLinesOfTextIn(headerOrFooter._getHdrFtr());
+ }
+ }
+ if (exact > 0) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, "line height", null,
+ (exact == 1 ? "the lines of one paragraph are" : "the lines of " + exact + " paragraphs are")
+ + " written at an exact height, which Word keeps when their text is made larger: letters"
+ + " grown taller than their line are cut off at its top. Only a line at least as tall as"
+ + " its faces' own, set in faces the document ships, is written at a least height, which"
+ + " grows with its text");
+ }
+ }
+
+ /** How many paragraphs under {@code part} hold text of their own and are written at an exact height. */
+ private static int exactLinesOfTextIn(org.apache.xmlbeans.XmlObject part) {
+ int exact = 0;
+ try (org.apache.xmlbeans.XmlCursor cursor = part.newCursor()) {
+ cursor.selectPath("declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "
+ + ".//w:p");
+ while (cursor.toNextSelection()) {
+ if (cursor.getObject() instanceof org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP paragraph
+ && paragraph.isSetPPr() && paragraph.getPPr().isSetSpacing()
+ && paragraph.getPPr().getSpacing().getLineRule() == STLineSpacingRule.EXACT
+ && holdsTextOfItsOwn(paragraph)) {
+ exact++;
+ }
+ }
+ }
+ return exact;
+ }
+
+ /** Whether a paragraph's own runs, its links' among them, hold any text. */
+ private static boolean holdsTextOfItsOwn(org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP paragraph) {
+ try (org.apache.xmlbeans.XmlCursor cursor = paragraph.newCursor()) {
+ cursor.selectPath("declare namespace w='http://schemas.openxmlformats.org/wordprocessingml/2006/main' "
+ + "./w:r/w:t | ./w:hyperlink/w:r/w:t");
+ while (cursor.toNextSelection()) {
+ if (!cursor.getTextValue().isBlank()) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
/**
* Hides the mark of the empty paragraph a document ending in a table must end with.
*
@@ -6209,6 +6280,7 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut,
takeBack(itemsLowered, grownBy(itemsLowered));
}
giveBack(itemsLowered, giveBackFromTheSpaceAbove(para, grownBy(itemsLowered)));
+ letTheLinesGrow(para, laidOut.lines(), listLineGap);
double shift = shiftToThePagesBaseline(para, laidOut.lines(), listLineGap, 0, false, true, staysOnItsPage);
if (!wordDrawsTheMarker) {
// Its marker is its text, and its position moves the two together.
@@ -9417,7 +9489,8 @@ private void nameAPullNotGivenBack(DocumentNode node, double pull) {
* it. The line above is written shorter by what its letters do not reach, and its text,
* which Word would raise with the baseline of a shorter exact line, lowered as much.
*
- * Only an exact line of text alone in its paragraph, not one of a stack, with no space
+ *
Only an exact line of text alone in its paragraph — one grown to a least height is made
+ * exact again first ({@link #holdTheLineExact}) — 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
@@ -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.Optional laid = layout.firstLine(above);
// The letters' reach off their own baseline, before any seat: the position written on
@@ -9456,13 +9535,14 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
return 0;
}
// The room under the letters where Word draws them: its baseline stands a fifth of the
- // line above the foot, raised by the runs' written position; less the margin, and the
+ // line above the foot, raised by the runs' written position — for a least line, the raise
+ // that keeps its text where it stood once it is made exact; less the margin, and the
// quarter point the lowering below can round away.
List runs = runsIn(para);
- long seated = 0;
+ long seated = grown ? raiseHoldingItExact(para) : 0;
if (!runs.isEmpty() && runs.get(0).isSetRPr() && runs.get(0).getRPr().sizeOfPositionArray() > 0
&& runs.get(0).getRPr().getPositionArray(0).getVal() instanceof Number number) {
- seated = number.longValue();
+ seated += number.longValue();
}
double wordFoot = (1 - DocxTextBands.BASELINE_SHARE) * line / POINT_TO_TWIP + seated / HALF_POINTS_PER_POINT;
double room = wordFoot - ink[1] - DocxStackedLines.INK_MARGIN - 0.5 / HALF_POINTS_PER_POINT;
@@ -9470,6 +9550,9 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
if (taken <= 0 || taken >= line) {
return 0;
}
+ if (grown) {
+ holdTheLineExact(para);
+ }
spacing.setLine(BigInteger.valueOf(line - taken));
// Word stands an exact line's baseline four fifths of the way down it: shorter by the
// taken twips, its text would rise by four fifths of them.
@@ -12872,8 +12955,11 @@ private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefor
return;
}
boolean ownsItsLine = ownLine && runsBefore == 0;
- raiseRunsFrom(para, runsBefore, Math.round((seatShift(node)
- + shiftToThePagesBaseline(para, node, lineTopAbove, heldExact, ownsItsLine)) * HALF_POINTS_PER_POINT));
+ double seat = seatShift(node);
+ // A text seated off its baseline is moved by its position, which a least line does not keep.
+ raiseRunsFrom(para, runsBefore, Math.round((seat
+ + shiftToThePagesBaseline(para, node, lineTopAbove, heldExact, ownsItsLine, seat == 0))
+ * HALF_POINTS_PER_POINT));
}
/**
@@ -12954,8 +13040,9 @@ private double seatShift(ParagraphNode node) {
* Read 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 +13059,91 @@ 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,
+ List lines,
+ double lineGap) {
+ CTPPr properties = para.getCTP().getPPr();
+ CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null;
+ if (spacing == null || !spacing.isSetLineRule() || spacing.getLineRule() != STLineSpacingRule.EXACT
+ || !grownLines.mayGrow(para.getCTP())) {
+ return;
+ }
+ spacing.setLineRule(STLineSpacingRule.AT_LEAST);
+ double shift = belowThePagesBaseline(para, lines, lineGap, 0);
+ if (!(Math.abs(shift) < LEAST_GROWN_LINE_SHIFT_POINTS)) {
+ spacing.setLineRule(STLineSpacingRule.EXACT);
+ }
+ }
+
+ /**
+ * Makes a line {@link #letTheLinesGrow} wrote at a least height exact again, its text where it
+ * stood: an exact line stands it four fifths of the way down, so its runs are raised by what
+ * that differs from the least line's seat. For a step that shortens the line, which a least
+ * line's text, standing on its foot, would rise with.
+ */
+ private void holdTheLineExact(XWPFParagraph para) {
+ long raise = raiseHoldingItExact(para);
+ para.getCTP().getPPr().getSpacing().setLineRule(STLineSpacingRule.EXACT);
+ raiseRunsFrom(para, 0, raise);
+ }
+
+ /**
+ * The raise, in half points, that keeps a least line's text where it stands once the line is
+ * written exact at the same height ({@link #holdTheLineExact}): positive where the exact line's
+ * baseline, four fifths of the way down, is the lower.
+ */
+ private long raiseHoldingItExact(XWPFParagraph para) {
+ DocxGrownLines.Line grown = grownLines.lineOf(para.getCTP());
+ if (grown == null) {
+ return 0;
+ }
+ double written = writtenTwips(para.getCTP().getPPr().getSpacing().getLine()) / POINT_TO_TWIP;
+ return Math.round((written * DocxTextBands.BASELINE_SHARE - grown.baseline()) * HALF_POINTS_PER_POINT);
+ }
+
+ /**
+ * How far Word's baseline in 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, boolean)}).
*
* @param lines the lines the layout set the text on
* @param lineGap the gap the layout puts between them, beyond their own height
@@ -13026,10 +13179,11 @@ private double shiftToThePagesBaseline(XWPFParagraph para,
}
/**
- * 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; NaN where the paragraph is not written at an exact
- * height or laid out no line of text (see
- * {@link #shiftToThePagesBaseline(XWPFParagraph, ParagraphNode, double, boolean, boolean)}).
+ * How far Word's baseline in lines of text the layout set stands below the page's, in points,
+ * positive when Word's is lower; NaN where the paragraph is written at neither an exact nor a
+ * least height Word's seat is read from ({@link DocxGrownLines#lineOf}), or laid out no line of
+ * text (see
+ * {@link #shiftToThePagesBaseline(XWPFParagraph, ParagraphNode, double, boolean, boolean, boolean)}).
*
* @param lines the lines the layout set the text on
* @param lineGap the gap the layout puts between them, beyond their own height
@@ -13038,13 +13192,9 @@ private double shiftToThePagesBaseline(XWPFParagraph para,
private double belowThePagesBaseline(XWPFParagraph para,
List lines,
double lineGap, double lineTopAbove) {
- CTPPr properties = para.getCTP().getPPr();
- if (properties == null || !properties.isSetSpacing()) {
- return Double.NaN;
- }
- CTSpacing spacing = properties.getSpacing();
- Long written = spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT
- ? writtenTwips(spacing.getLine()) : null;
+ // Read as Word stands it: an exact line's baseline four fifths of the way down, a least
+ // line's its runs' descent above the foot (DocxGrownLines). Any other line Word measures.
+ DocxGrownLines.Line written = grownLines.lineOf(para.getCTP());
if (written == null) {
return Double.NaN;
}
@@ -13053,12 +13203,11 @@ private double belowThePagesBaseline(XWPFParagraph para,
span -> span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan)) {
// Lines whose gaps Word shares out step closer than the page's; the middle one is
// matched, so the first and last are off by as little as the steps allow.
- double wordLine = written / POINT_TO_TWIP;
double middle = Math.max(0, lines.size() - 1) / 2.0;
double pageStep = line.lineHeight() + lineGap;
double pages = lineTopAbove + lineTopsTakenIn.getOrDefault(para.getCTP(), 0.0)
+ middle * pageStep + line.lineHeight() - line.baselineOffsetFromBottom();
- return middle * wordLine + wordLine * DocxTextBands.BASELINE_SHARE - pages;
+ return middle * written.height() + written.baseline() - pages;
}
}
return Double.NaN;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
index e3403d94f..0e7667b50 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAnchorWithoutABookmarkTest.java
@@ -193,7 +193,7 @@ void aPageReferenceAfterTheBlockKeepsItsNoteInItsPlace() throws Exception {
.contains("its anchor has no bookmark in the Word file");
assertThat(notesOn(exported.report(), "SectionNode")).singleElement()
.isEqualTo("writes no paragraph, so " + LOST);
- assertThat(exported.report().notes()).extracting(DocxExportReport.Note::subject)
+ assertThat(DocxExports.besidesTheExactLines(exported.report())).extracting(DocxExportReport.Note::subject)
.as("each where its node was written, the block's own after them")
.containsExactly("ShapeNode", "PageReferenceNode", "SectionNode");
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java
index 9ceb96f65..24086c435 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandRaiseTest.java
@@ -37,9 +37,14 @@
*/
class DocxBandRaiseTest {
- /** Spectral's ascent, in ems: 1059 of 1000 units. */
- private static final double SPECTRAL_ASCENT = 1.059;
- private static final DocumentTextStyle TITLE = DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(30).build();
+ /** Poppins' ascent, in ems: 1050 of 1000 units. */
+ private static final double POPPINS_ASCENT = 1.05;
+ /**
+ * A title Word sets 2pt low in its exact line: Poppins' own line holds a line gap the page's
+ * does not, so the line is tighter than it and stays exact.
+ */
+ private static final DocumentTextStyle TITLE = DocumentTextStyle.builder().fontName(FontName.POPPINS).size(30)
+ .build();
@Test
void aLaterLayerResumesBelowTheRaisedLineAboveIt() throws Exception {
@@ -235,10 +240,10 @@ private static com.demcha.compose.document.node.DocumentNode title(String name,
return new ParagraphBuilder().name(name).text(name).textStyle(TITLE).margin(DocumentInsets.top(above)).build();
}
- /** How far Word sets a Spectral 30pt line's baseline below the page's, in points. */
+ /** How far Word sets a Poppins 30pt line's baseline below the page's, in points. */
private static double raiseOf(XWPFParagraph paragraph) {
BigInteger line = (BigInteger) paragraph.getCTP().getPPr().getSpacing().getLine();
- return 0.8 * line.doubleValue() / 20 - SPECTRAL_ASCENT * 30;
+ return 0.8 * line.doubleValue() / 20 - POPPINS_ASCENT * 30;
}
/** The position the paragraph's first run is raised by, in half points. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
index 2796363e6..74e68f11c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBaselineSeatTest.java
@@ -11,6 +11,7 @@
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.junit.jupiter.api.Test;
@@ -26,7 +27,10 @@
*
* Word 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))))) {
List cell = document.getTables().get(0).getRow(0).getCell(0).getParagraphs();
XWPFParagraph left = cell.get(cell.size() - 1);
- double raise = 0.8 * line(left) - SPECTRAL_ASCENT * 30;
+ double raise = 0.8 * line(left) - POPPINS_ASCENT * 30;
assertThat(left.getText()).as("the premise: the column's last paragraph").isEqualTo("Left");
@@ -441,6 +499,10 @@ private static double line(XWPFParagraph paragraph) {
return DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine()) / 20.0;
}
+ private static STLineSpacingRule.Enum rule(XWPFParagraph paragraph) {
+ return paragraph.getCTP().getPPr().getSpacing().getLineRule();
+ }
+
private static int position(XWPFParagraph paragraph) {
return positionOf(paragraph.getRuns().get(0).getCTR());
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellGeometryTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellGeometryTest.java
index 697f692e3..a6df26d85 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellGeometryTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellGeometryTest.java
@@ -63,7 +63,7 @@ void aRowSetAtEitherEndOfItsCellKeepsTheColumnsThePageGivesIt() throws Exception
assertThat(export.rowGrid()).containsExactly(
twips(amount - CELL_TEXT_LEFT), twips(CELL_TEXT_RIGHT - amount));
- assertThat(export.report().notes()).isEmpty();
+ assertThat(DocxExports.besidesTheExactLines(export.report())).isEmpty();
}
@Test
@@ -180,7 +180,7 @@ void anEmptyParagraphInARowOfACellStandsInAColumnOfItsOwn() throws Exception {
.addParagraph("Discount").addParagraph("").build());
assertThat(export.rowGrid()).hasSize(2);
- assertThat(export.report().notes()).isEmpty();
+ assertThat(DocxExports.besidesTheExactLines(export.report())).isEmpty();
}
@Test
@@ -225,7 +225,7 @@ void aRowWhoseColumnsAreStatedInACellIsWrittenAsBefore() throws Exception {
Export export = inACell(new RowBuilder().weights(1, 2).addParagraph("One").addParagraph("Two").build());
assertThat(export.rowGrid()).hasSize(2);
- assertThat(export.report().notes()).isEmpty();
+ assertThat(DocxExports.besidesTheExactLines(export.report())).isEmpty();
}
@Test
@@ -235,7 +235,7 @@ void aParagraphRightAlignedInACellIsHeldInToWhereThePageSetsIt() throws Exceptio
assertThat(DocxTwips.of(export.paragraph("Total due").getCTP().getPPr().getInd().getLeft()))
.isEqualTo(twips(start - CELL_TEXT_LEFT));
- assertThat(export.report().notes()).isEmpty();
+ assertThat(DocxExports.besidesTheExactLines(export.report())).isEmpty();
}
@Test
@@ -246,7 +246,7 @@ void aPictureCentredInACellIsHeldInToWhereThePageCentresIt() throws Exception {
assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(126 * 20L);
assertThat(DocxTwips.of(indent.getRight())).isEqualTo(126 * 20L);
- assertThat(export.report().notes()).isEmpty();
+ assertThat(DocxExports.besidesTheExactLines(export.report())).isEmpty();
}
@Test
@@ -370,7 +370,7 @@ void aLeftAlignmentInACellIsWrittenFromTheLeftAndSaysNothing() throws Exception
XWPFParagraph paragraph = export.paragraph("Total due");
assertThat(paragraph.getCTP().getPPr() == null || !paragraph.getCTP().getPPr().isSetInd()
|| DocxTwips.of(paragraph.getCTP().getPPr().getInd().getLeft()) == 0).isTrue();
- assertThat(export.report().notes()).isEmpty();
+ assertThat(DocxExports.besidesTheExactLines(export.report())).isEmpty();
}
private static long twips(double points) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java
index ffc042f4e..e950f84d0 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java
@@ -136,8 +136,9 @@ void aMarkdownParagraphLevelWithAListKeepsItsOwnLine() throws Exception {
new ParagraphBuilder().text("**Languages**").textStyle(size(9)).build(),
new ListBuilder().name("Items").noMarker().hangingIndent(true).textStyle(size(9))
.items("Python").build())) {
- assertThat(spacing(inACell(document, "Languages")).getLineRule()).hasToString("exact");
- assertThat(spacing(inACell(document, "Python")).getLineRule()).hasToString("exact");
+ // Written at the height the page measured, Lato's own, as a least height.
+ assertThat(spacing(inACell(document, "Languages")).getLineRule()).hasToString("atLeast");
+ assertThat(spacing(inACell(document, "Python")).getLineRule()).hasToString("atLeast");
}
}
@@ -178,7 +179,8 @@ void aMarkdownParagraphBeforeAListOfItsTextKeepsItsLine() throws Exception {
List amounts = cellParagraphs(document, "0.00");
assertThat(amounts).hasSize(2);
- assertThat(spacing(amounts.get(0)).getLineRule()).as("the paragraph's").hasToString("exact");
+ assertThat(spacing(amounts.get(0)).getLineRule()).as("the paragraph's, Lato's own")
+ .hasToString("atLeast");
assertThat(line(amounts.get(1))).as("the list's, at 16pt").isEqualTo(2 * line(amounts.get(0)));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
index 2bb38c321..077b9a99f 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
@@ -187,6 +187,27 @@ void anExactLinesTopIsWhereWordStandsItsBaselineLessTheSpaceAbove() {
assertThat(offsetDown(drawingsIn(paragraph).get(0))).isCloseTo(150 - 147, within(0.01));
}
+ @Test
+ void aLeastLinesTopIsItsBaselineLessWhatItsRunsReachAboveItLessTheSpaceAbove() {
+ // A 15pt least line, 12pt above it: Word keeps the spare above its text and stands the
+ // baseline its runs' descent, 2pt here, above the foot. The page draws the baseline at 172,
+ // so Word's line starts at 172 - 13 = 159, its paragraph at 147.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph paragraph = text(document, "Languages");
+ spacing(paragraph, 240, 300);
+ paragraph.getCTP().getPPr().getSpacing().setLineRule(STLineSpacingRule.AT_LEAST);
+ AtomicLong ids = new AtomicLong(1);
+ DocxDrawingAnchors anchors = new DocxDrawingAnchors(ids::getAndIncrement,
+ ctp -> ctp == paragraph.getCTP() ? new DocxGrownLines.Line(15, 13) : null);
+ anchors.paragraphOn(0, paragraph, true);
+ anchors.seat(0, paragraph, paragraph, 40, 300, 163.5, 172);
+
+ queue(anchors, List.of(dot(30, 150)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(offsetDown(drawingsIn(paragraph).get(0))).isCloseTo(150 - 147, within(0.01));
+ }
+
@Test
void aShapeInACellIsPlacedFromTheCellsTextColumn() {
XWPFDocument document = new XWPFDocument();
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java
index 815ffa068..440199496 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExportReportTest.java
@@ -35,12 +35,33 @@ class DocxExportReportTest {
@Test
void aDocumentThatLosesNothingReportsNothing() throws Exception {
- assertThat(reportOf(page -> page.addParagraph(p -> p.text("Ordinary text")))
+ // Set in a face the document ships, whose own line its line is: written at a least height,
+ // it grows with its text, and there is nothing to say.
+ assertThat(reportOf(page -> page.addParagraph(p -> p.text("Ordinary text").textStyle(
+ com.demcha.compose.document.style.DocumentTextStyle.builder()
+ .fontName(com.demcha.compose.font.FontName.LATO).size(10).build())))
.isEmpty())
.as("an export with nothing to say says nothing")
.isTrue();
}
+ @Test
+ void linesOfTextWrittenAtAnExactHeightAreNamedOnceForTheDocument() throws Exception {
+ // Helvetica is a name, not a file the document ships: its lines are exact, which Word keeps
+ // when the text is made larger. The spacer's empty line holds no text to grow.
+ DocxExportReport report = reportOf(page -> page
+ .addParagraph(p -> p.text("First"))
+ .spacer(1, 12)
+ .addParagraph(p -> p.text("Second")));
+
+ assertThat(report.notes()).singleElement().satisfies(note -> {
+ assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED);
+ assertThat(note.subject()).isEqualTo(DocxExports.EXACT_LINES);
+ assertThat(note.path()).as("about the whole document").isNull();
+ assertThat(note.detail()).startsWith("the lines of 2 paragraphs are written at an exact height");
+ });
+ }
+
@Test
void aDroppedNodeIsNamedWithItsPath() throws Exception {
DocxExportReport report = reportOf(page -> page
@@ -48,7 +69,7 @@ void aDroppedNodeIsNamedWithItsPath() throws Exception {
.addPath(path -> path.name("Rail").size(20, 20).moveTo(0, 0).lineTo(1, 0).lineTo(0.5, 1)
.closePath().fill(GRADIENT)));
- List notes = report.notes();
+ List notes = DocxExports.besidesTheExactLines(report);
assertThat(notes).hasSize(1);
assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED);
assertThat(notes.get(0).path())
@@ -86,10 +107,11 @@ void whatSurvivesInAnotherFormIsApproximatedRatherThanDropped() throws Exception
.padding(DocumentInsets.of(10))
.addParagraph(p -> p.text("Inside the card"))));
- assertThat(report.count(DocxExportReport.Severity.APPROXIMATED)).isEqualTo(1);
+ assertThat(DocxExports.besidesTheExactLines(report)).extracting(DocxExportReport.Note::severity)
+ .containsExactly(DocxExportReport.Severity.APPROXIMATED);
assertThat(report.count(DocxExportReport.Severity.DROPPED)).isZero();
assertThat(report.bySubject()).containsKey("corner radius");
- assertThat(report.notes().get(0).path()).contains("Card");
+ assertThat(DocxExports.besidesTheExactLines(report).get(0).path()).contains("Card");
}
@Test
@@ -176,6 +198,24 @@ void aNoteIsReplacedOnlyFromWhereItsSectionStartsAndEveryTimeItIsSaid() {
.containsExactly("drawn", "gone", "gone", "never named");
}
+ @Test
+ void aFootersLinesAreCountedAmongTheExactOnes() throws Exception {
+ // A band's line is written exact, in a footer part POI does not list for a document it
+ // made: the body's Lato line grows, and the note counts the footer's alone.
+ AtomicReference captured = new AtomicReference<>();
+ try (DocumentSession session = session(page -> page.addParagraph(p -> p.text("Body").textStyle(
+ com.demcha.compose.document.style.DocumentTextStyle.builder()
+ .fontName(com.demcha.compose.font.FontName.LATO).size(10).build())))) {
+ session.footer(com.demcha.compose.document.output.DocumentHeaderFooter.builder()
+ .zone(com.demcha.compose.document.output.DocumentHeaderFooterZone.FOOTER)
+ .height(10).fontSize(9).leftText("Foot").build());
+ session.export(new DocxSemanticBackend(captured::set));
+ }
+
+ assertThat(captured.get().bySubject().get(DocxExports.EXACT_LINES)).singleElement()
+ .satisfies(note -> assertThat(note.detail()).startsWith("the lines of one paragraph are"));
+ }
+
private static DocxExportReport reportOf(Consumer content) throws Exception {
AtomicReference captured = new AtomicReference<>();
try (DocumentSession session = session(content)) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
index 8d415420b..f29847448 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxExports.java
@@ -44,9 +44,22 @@ final class DocxExports {
/** {@link #DEFAULT_LINE_RAISE} for the default face at 7pt, the size tests set small text in. */
static final long SMALL_LINE_RAISE = 3;
+ /** The subject of the note naming the lines of text written at an exact height. */
+ static final String EXACT_LINES = "line height";
+
private DocxExports() {
}
+ /**
+ * A report's notes besides the one naming the lines of text written at an exact height
+ * ({@code DocxSemanticBackend#nameTheExactLines}). The default face, Helvetica, is a name the
+ * document does not ship, so a test's lines of default text are exact and the report says so;
+ * a test of what else the export says reads past it.
+ */
+ static List besidesTheExactLines(DocxExportReport report) {
+ return report.notes().stream().filter(note -> !EXACT_LINES.equals(note.subject())).toList();
+ }
+
/** Exports through a session, so the backend is handed the compiled layout. */
static XWPFDocument withLayout(double pageWidth, double pageHeight, double margin,
Consumer content) throws Exception {
@@ -95,6 +108,15 @@ static DocxExportReport reportWithoutLayout(double pageWidth, double pageHeight,
return report.get();
}
+ /** The document graph a session hands its backends, for a writer that reads the graph alone. */
+ static DocumentGraph graphOf(Consumer content) throws Exception {
+ Captured captured = new Captured();
+ try (DocumentSession session = session(400, 600, 20, content)) {
+ session.export(captured);
+ }
+ return captured.graph;
+ }
+
/** Exports with neither a layout nor a canvas, as a caller holding only the graph does. */
static XWPFDocument withoutCanvas(Consumer content) throws Exception {
Captured captured = new Captured();
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
index 1afb1d907..4224b5c37 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFirstLineSeatTest.java
@@ -79,9 +79,10 @@ void aPanelsTopBorderKeepsWhatItTakesOfThePadding() throws Exception {
@Test
void aLineHalfAPointLowAndMoreKeepsItsPosition() throws Exception {
- // Spectral's 30pt line stands 4.9pt low in Word: the position moves it, as before.
+ // Poppins' 30pt exact line stands 2pt low in Word: the position moves it, as before.
Exported exported = export(page -> page.addSection("Card", card -> card.fillColor(SURFACE)
- .padding(DocumentInsets.of(8)).addParagraph(p -> p.text("Title").textStyle(spectral(30)))));
+ .padding(DocumentInsets.of(8)).addParagraph(p -> p.text("Title").textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.POPPINS).size(30).build()))));
XWPFTableCell panel = onlyTable(exported.document()).getRow(0).getCell(0);
assertThat(topMargin(panel)).as("its padding, all of it").isEqualTo(8 * 20);
@@ -386,10 +387,6 @@ private static DocumentTableCell composed(com.demcha.compose.document.node.Docum
.stroke(DocumentStroke.of(DocumentColor.BLACK, 0)).build());
}
- private static DocumentTextStyle spectral(double size) {
- return DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(size).build();
- }
-
private static DocumentTextStyle lato(double size) {
return DocumentTextStyle.builder().fontName(FontName.LATO).size(size).build();
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
new file mode 100644
index 000000000..425a5761d
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
@@ -0,0 +1,168 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.node.TextVerticalAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A paragraph's lines are written at a least height where they are at least as tall as their
+ * faces' own line, so Word grows them when the text is made larger; and exact where they are
+ * tighter, or where a least line would not stand the text where the page does.
+ *
+ * The 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 aComposedCellsParagraphIsWrittenAtALeastHeightToo() 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' Windows descent is two tenths of its size, its line's descent on the page
+ // three: a least line would stand its text a fifth of a point low at 20pt, its position
+ // moving nothing, so the line stays exact and 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 List positions(XWPFParagraph paragraph) {
+ List raised = new ArrayList<>();
+ for (var run : paragraph.getCTP().getRList()) {
+ raised.add(run.isSetRPr() && run.getRPr().sizeOfPositionArray() > 0
+ ? ((Number) run.getRPr().getPositionArray(0).getVal()).longValue() : 0L);
+ }
+ return raised;
+ }
+
+ /** The paragraph, in the body or a table's cell, whose text starts so. */
+ private static XWPFParagraph paragraph(XWPFDocument document, String start) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ if (paragraph.getText().startsWith(start)) {
+ return paragraph;
+ }
+ }
+ for (XWPFTable table : document.getTables()) {
+ for (var row : table.getRows()) {
+ for (var cell : row.getTableCells()) {
+ for (XWPFParagraph paragraph : cell.getParagraphs()) {
+ if (paragraph.getText().startsWith(start)) {
+ return paragraph;
+ }
+ }
+ }
+ }
+ }
+ throw new AssertionError("no paragraph reads " + start);
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
new file mode 100644
index 000000000..7bfe39e54
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
@@ -0,0 +1,233 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule;
+
+import java.math.BigInteger;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * How tall an editor stands a written paragraph's line, and where its text stands in it.
+ *
+ * The 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();
+ }
+
+ @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")));
List pages = session.layoutGraph().fragments().stream()
.filter(fragment -> fragment.path().contains("Duties"))
@@ -213,7 +218,7 @@ void anItemTheLayoutSetsOnANewPageKeepsTheSpaceAboveIt() throws Exception {
XWPFParagraph one = paragraph(document, "One");
XWPFParagraph two = paragraph(document, "Two");
XWPFParagraph three = paragraph(document, "Three");
- double raise = 0.8 * line(one) - SPECTRAL_ASCENT * 30;
+ double raise = 0.8 * line(one) - POPPINS_ASCENT * 30;
assertThat(pages).as("the premise: the second item opens the next page").containsExactly(0, 1, 1);
assertThat(before(one)).as("the spacer's height past its hairline and the list's 10pt, less the raise")
@@ -231,8 +236,8 @@ void anItemTheLayoutSetsOnANewPageKeepsTheSpaceAboveIt() throws Exception {
void theFirstItemOfAListTheLayoutStartsOnANewPageKeepsTheSpaceAboveIt() throws Exception {
try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
session.pageFlow(page -> page.spacer(1, 520)
- .addList(list -> list.name("Duties").noMarker().textStyle(spectral(30)).margin(DocumentInsets.top(10))
- .items("One"))
+ .addList(list -> list.name("Duties").noMarker().textStyle(poppins(30))
+ .margin(DocumentInsets.top(10)).items("One"))
.addParagraph(p -> p.text("Body").textStyle(lato(10))));
List pages = session.layoutGraph().fragments().stream()
.filter(fragment -> fragment.path().contains("Duties"))
@@ -240,7 +245,7 @@ void theFirstItemOfAListTheLayoutStartsOnANewPageKeepsTheSpaceAboveIt() throws E
try (XWPFDocument document = new XWPFDocument(
new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
XWPFParagraph one = paragraph(document, "One");
- double raise = 0.8 * line(one) - SPECTRAL_ASCENT * 30;
+ double raise = 0.8 * line(one) - POPPINS_ASCENT * 30;
assertThat(pages).as("the premise: the list opens the next page").containsExactly(1);
assertThat(position(one)).as("raised by its position").isCloseTo(Math.round(raise * 2), within(1L));
@@ -297,7 +302,7 @@ void theItemsOfAListTheLayoutSetsOverTwoPagesInACellKeepTheSpaceAboveThem() thro
try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) {
session.pageFlow(page -> page.spacer(1, 460)
.addSection("Card", card -> card.fillColor(DocumentColor.rgb(238, 243, 249))
- .addList(list -> list.name("Duties").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"))));
List pages = session.layoutGraph().fragments().stream()
.filter(fragment -> fragment.path().contains("Duties"))
@@ -307,7 +312,7 @@ void theItemsOfAListTheLayoutSetsOverTwoPagesInACellKeepTheSpaceAboveThem() thro
XWPFParagraph one = inACell(document, "One");
XWPFParagraph two = inACell(document, "Two");
XWPFParagraph three = inACell(document, "Three");
- double raise = 0.8 * line(three) - SPECTRAL_ASCENT * 30;
+ double raise = 0.8 * line(three) - POPPINS_ASCENT * 30;
assertThat(pages).as("the premise: the second item opens the next page").containsExactly(0, 1, 1);
assertThat(document.getTables()).as("the premise: one cell over both pages").hasSize(1);
@@ -323,12 +328,12 @@ void theItemsOfAListTheLayoutSetsOverTwoPagesInACellKeepTheSpaceAboveThem() thro
void anItemOfRunsIsSeatedAsAPlainOneIs() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addParagraph(p -> p.text("Lead").textStyle(LEAD))
- .addList(list -> list.textStyle(spectral(30)).marker("•").hangingIndent(true).markerGap(6)
+ .addList(list -> list.textStyle(poppins(30)).marker("•").hangingIndent(true).markerGap(6)
.margin(DocumentInsets.top(20))
- .addItem(text -> text.style("Rich ", spectral(30)).style("item", spectral(30)))))) {
+ .addItem(text -> text.style("Rich ", poppins(30)).style("item", poppins(30)))))) {
XWPFParagraph item = document.getParagraphs().stream()
.filter(paragraph -> paragraph.getText().contains("item")).findFirst().orElseThrow();
- 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: its marker written as text").isFalse();
assertThat(item.getCTP().getRList()).as("the premise: tabbed to its text's column")
@@ -338,8 +343,12 @@ void anItemOfRunsIsSeatedAsAPlainOneIs() throws Exception {
}
}
- private static DocumentTextStyle spectral(double size) {
- return DocumentTextStyle.builder().fontName(FontName.SPECTRAL).size(size).build();
+ private static DocumentTextStyle poppins(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.POPPINS).size(size).build();
+ }
+
+ private static DocumentTextStyle ubuntu(double size) {
+ return DocumentTextStyle.builder().fontName(FontName.UBUNTU).size(size).build();
}
private static DocumentTextStyle lato(double size) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java
new file mode 100644
index 000000000..3083c173b
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java
@@ -0,0 +1,90 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.font.FontName;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The line an editor sets a face in, read from the face, against the line Word set it in.
+ *
+ * The 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 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..6815963b6 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;
@@ -30,6 +31,8 @@
class DocxSpaceAboveTest {
private static final DocumentTextStyle BODY = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
+ /** Lato's descent, in ems: 426 of 2000 units, where a least line stands its text off the foot. */
+ private static final double LATO_DESCENT = 0.213;
/**
* The header above a pulled block: Gothic A1, which Word seats where the page does, so it takes
* nothing of the space under it to be lowered by ({@link DocxParagraphLowerTest}).
@@ -103,15 +106,21 @@ void aLinePulledUpIntoTheLineAboveInACellTakesItsFoot() throws Exception {
// 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.
+ // 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; shortened, it is written exact, its text first
+ // moved to where the least line stood it.
DocumentTextStyle wordmark = DocumentTextStyle.builder().fontName(FontName.LATO).size(21).build();
XWPFParagraph[] plain = lockup(wordmark, 0);
XWPFParagraph[] pulled = lockup(wordmark, -3.5);
double shorter = (DocxTwips.of(lineOf(plain[0])) - DocxTwips.of(lineOf(pulled[0]))) / 20.0;
+ double line = DocxTwips.of(lineOf(plain[0])) / 20.0;
+ long held = Math.round((LATO_DESCENT * 21 - 0.2 * line) * 2);
+ 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.5, within(0.06));
assertThat(positionOf(pulled[0]) - positionOf(plain[0])).as("its text lowered as its baseline would rise")
- .isEqualTo(-6);
+ .isEqualTo(held - 6);
assertThat(DocxTwips.of(lineOf(pulled[1]))).as("the pulled line whole, the pull taken above it")
.isEqualTo(DocxTwips.of(lineOf(plain[1])));
}
@@ -175,13 +184,28 @@ void aPullDeeperThanTheLettersAboveLeaveRoomForRisesInsideItsOwnLine() throws Ex
}
/** A wordmark over a 10pt subtitle pulled up by {@code pull}, in a row's cell. */
+ @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();
+ }
+
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 +218,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
From b6043c585115f8a024e9e2ca914bc5ff99f24b68 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 25 Oct 2026 00:52:57 +0100
Subject: [PATCH 2/2] fix(docx): grow no line in a face whose hhea is not its
line's metrics
- DocxNaturalLine gives no line for a face whose hhea ascender and
descender are not the typographic or Windows metrics its line is set
on. The page measures descent by hhea, so a least line in such a face
stood its text off the page's baseline: Alegreya Sans and IBM Plex
Serif, bundled, now stay exact.
- A least line made exact above a pulled paragraph moves its text by
where the least line stood it and the pull's lowering together,
rounded to the half point once (raiseHoldingItExact, in points).
- Tests: the quarter-point threshold on both sides, no line for the two
faces, the shipped bold face's reach, the single rounding; the
CHANGELOG and recipe name right-to-left runs and scripts as exact.
---
CHANGELOG.md | 13 +++---
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../backend/semantic/docx/DocxGrownLines.java | 3 +-
.../semantic/docx/DocxNaturalLine.java | 25 ++++++++---
.../semantic/docx/DocxSemanticBackend.java | 45 ++++++++-----------
.../docx/DocxGrownLinesExportTest.java | 30 +++++++++++--
.../semantic/docx/DocxGrownLinesTest.java | 2 +
.../semantic/docx/DocxNaturalLineTest.java | 10 +++++
.../semantic/docx/DocxSpaceAboveTest.java | 23 +++++-----
10 files changed, 96 insertions(+), 59 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f71f6d47..68536a549 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,19 +16,20 @@ follow semantic versioning; release dates are ISO 8601.
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, none raised, no picture in the line, and the paragraph
- is seated on its baseline, not a Word list's item.
+ 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 none of the raises an exact
- line's text was seated with. A line Word would still set a quarter point or more off the
- page's baseline stays exact, where its position seats it.
+ 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;
+ 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).
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 241b27a01..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`) — 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 — 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 102eeedcb..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: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, none raised, 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; 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, 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 |
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
index 5c0f27b46..60c94357f 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLines.java
@@ -22,7 +22,8 @@
* 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, mixed faces and sizes in one line, to 0.1pt).
+ * (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
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java
index b82484e45..a3a8b41b2 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLine.java
@@ -14,6 +14,11 @@
* 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
@@ -32,7 +37,7 @@ record DocxNaturalLine(double ascent, double descent) {
*
* @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
+ * {@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);
@@ -46,18 +51,24 @@ static DocxNaturalLine of(byte[] font) {
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 ascender = buffer.getShort(os2 + 68);
- int descender = buffer.getShort(os2 + 70);
- int lineGap = Math.max(0, buffer.getShort(os2 + 72));
- return new DocxNaturalLine((ascender + lineGap) / unitsPerEm, -descender / unitsPerEm);
+ 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));
- int ascender = buffer.getShort(hhea + 4);
- int descender = buffer.getShort(hhea + 6);
+ 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 4d107ac40..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
@@ -9490,9 +9490,9 @@ private void nameAPullNotGivenBack(DocumentNode node, double pull) {
* which Word would raise with the baseline of a shorter exact line, lowered as much.
*
* Only an exact line of text alone in its paragraph — one grown to a least height is made
- * exact again first ({@link #holdTheLineExact}) — 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
+ * 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.
*
@@ -9539,24 +9539,27 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) {
// that keeps its text where it stood once it is made exact; less the margin, and the
// quarter point the lowering below can round away.
List runs = runsIn(para);
- long seated = grown ? raiseHoldingItExact(para) : 0;
+ double held = grown ? raiseHoldingItExact(para) : 0;
+ double seated = held;
if (!runs.isEmpty() && runs.get(0).isSetRPr() && runs.get(0).getRPr().sizeOfPositionArray() > 0
&& runs.get(0).getRPr().getPositionArray(0).getVal() instanceof Number number) {
- seated += number.longValue();
+ seated += number.longValue() / HALF_POINTS_PER_POINT;
}
- double wordFoot = (1 - DocxTextBands.BASELINE_SHARE) * line / POINT_TO_TWIP + seated / HALF_POINTS_PER_POINT;
+ double wordFoot = (1 - DocxTextBands.BASELINE_SHARE) * line / POINT_TO_TWIP + seated;
double room = wordFoot - ink[1] - DocxStackedLines.INK_MARGIN - 0.5 / HALF_POINTS_PER_POINT;
long taken = Math.round(Math.min(points, room) * POINT_TO_TWIP);
if (taken <= 0 || taken >= line) {
return 0;
}
if (grown) {
- holdTheLineExact(para);
+ spacing.setLineRule(STLineSpacingRule.EXACT);
}
spacing.setLine(BigInteger.valueOf(line - taken));
// Word stands an exact line's baseline four fifths of the way down it: shorter by the
- // taken twips, its text would rise by four fifths of them.
- long lowered = Math.round(taken / POINT_TO_TWIP * DocxTextBands.BASELINE_SHARE * HALF_POINTS_PER_POINT);
+ // taken twips, its text would rise by four fifths of them. A least line made exact first
+ // moves its text by what that differs from where the least line stood it, rounded with it
+ // to the half point once.
+ long lowered = Math.round((taken / POINT_TO_TWIP * DocxTextBands.BASELINE_SHARE - held) * HALF_POINTS_PER_POINT);
for (org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR run : runsIn(para)) {
org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr runProperties =
run.isSetRPr() ? run.getRPr() : run.addNewRPr();
@@ -13112,29 +13115,19 @@ private void letTheLinesGrow(XWPFParagraph para,
}
/**
- * Makes a line {@link #letTheLinesGrow} wrote at a least height exact again, its text where it
- * stood: an exact line stands it four fifths of the way down, so its runs are raised by what
- * that differs from the least line's seat. For a step that shortens the line, which a least
- * line's text, standing on its foot, would rise with.
- */
- private void holdTheLineExact(XWPFParagraph para) {
- long raise = raiseHoldingItExact(para);
- para.getCTP().getPPr().getSpacing().setLineRule(STLineSpacingRule.EXACT);
- raiseRunsFrom(para, 0, raise);
- }
-
- /**
- * The raise, in half points, that keeps a least line's text where it stands once the line is
- * written exact at the same height ({@link #holdTheLineExact}): positive where the exact line's
- * baseline, four fifths of the way down, is the lower.
+ * How far a line {@link #letTheLinesGrow} wrote at a least height must have its text raised to
+ * stay where it stands once the line is written exact at the same height, in points, positive
+ * where the exact line's baseline, four fifths of the way down, is the lower. For a step that
+ * shortens the line ({@link #takeFromTheLineAbove}), which a least line's text, standing on its
+ * foot, would rise with.
*/
- private long raiseHoldingItExact(XWPFParagraph para) {
+ private double raiseHoldingItExact(XWPFParagraph para) {
DocxGrownLines.Line grown = grownLines.lineOf(para.getCTP());
if (grown == null) {
return 0;
}
double written = writtenTwips(para.getCTP().getPPr().getSpacing().getLine()) / POINT_TO_TWIP;
- return Math.round((written * DocxTextBands.BASELINE_SHARE - grown.baseline()) * HALF_POINTS_PER_POINT);
+ return written * DocxTextBands.BASELINE_SHARE - grown.baseline();
}
/**
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
index 425a5761d..6b79466cd 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesExportTest.java
@@ -52,7 +52,28 @@ void linesWithAGapTakenFromTheSpaceAboveAreWrittenAtALeastHeightAndNotMoved() th
}
@Test
- void aComposedCellsParagraphIsWrittenAtALeastHeightToo() throws Exception {
+ 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)))
@@ -87,9 +108,10 @@ void runsOfDifferentDepthsAreWrittenExact() throws Exception {
@Test
void aFaceTheEditorsSetOnAnotherDescentIsWrittenExact() throws Exception {
- // Alegreya Sans' Windows descent is two tenths of its size, its line's descent on the page
- // three: a least line would stand its text a fifth of a point low at 20pt, its position
- // moving nothing, so the line stays exact and its position seats it.
+ // 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);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
index 7bfe39e54..c0d110a4d 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGrownLinesTest.java
@@ -75,6 +75,8 @@ void aBoldRunIsReadInTheBoldFace() throws Exception {
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
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java
index 3083c173b..d3ca03663 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNaturalLineTest.java
@@ -44,6 +44,16 @@ void aFaceThatDoesNotIsSetOnItsWindowsMetricsWithWhatItsLineGapAsksBeyondThem()
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);
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 6815963b6..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
@@ -31,8 +31,6 @@
class DocxSpaceAboveTest {
private static final DocumentTextStyle BODY = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
- /** Lato's descent, in ems: 426 of 2000 units, where a least line stands its text off the foot. */
- private static final double LATO_DESCENT = 0.213;
/**
* The header above a pulled block: Gothic A1, which Word seats where the page does, so it takes
* nothing of the space under it to be lowered by ({@link DocxParagraphLowerTest}).
@@ -104,23 +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. Alone, the wordmark's line is its face's own and written at a least
- // height, its text on the line's foot; shortened, it is written exact, its text first
- // moved to where the least line stood 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;
- double line = DocxTwips.of(lineOf(plain[0])) / 20.0;
- long held = Math.round((LATO_DESCENT * 21 - 0.2 * line) * 2);
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.5, within(0.06));
+ 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(held - 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])));
}
@@ -183,7 +180,6 @@ void aPullDeeperThanTheLettersAboveLeaveRoomForRisesInsideItsOwnLine() throws Ex
assertThat(DocxTwips.of(lineOf(pulled[1]))).as("and the pulled line the rest").isLessThan(DocxTwips.of(lineOf(plain[1])));
}
- /** A wordmark over a 10pt subtitle pulled up by {@code pull}, in a row's cell. */
@Test
void aLineAboveWhoseLettersLeaveNoRoomUnderThemKeepsItsLeastHeight() throws Exception {
// Its descenders reach the foot of its line: nothing is taken, and the line is not made
@@ -195,6 +191,7 @@ void aLineAboveWhoseLettersLeaveNoRoomUnderThemKeepsItsLeastHeight() throws Exce
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);
}