From e7422856354cf70046b0aef0da075ca28c93c8ba Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 11 Oct 2026 20:36:15 +0100
Subject: [PATCH 1/2] fix(docx): seat what a row's margins leave of a text cell
by the height of its lines
- What a row's margins leave of a text cell's call, from a tenth of a
point up to a half, is moved by the height of the cell's lines: each
exact line written shorter and the space after it taller by as much,
or the other way as far as that space holds. The cell's content keeps
its height and each line its top, and Word and LibreOffice stand the
text four fifths of the difference higher or lower. Under half a
point nothing moved such a cell before: CobaltRota's staff names,
beside the day cells its chips are composed in, stood 0.25pt low in
their rows in Word and 0.2pt in LibreOffice.
- Only where every paragraph of the cell is one line, in a face whose
letters are read, and no nearer the letters' tops and feet than three
quarters of a point, where Word cuts an exact line's letters on
screen. From half a point up the position moves it, as before.
- DocxInk reads the reach of a text set in one style, which the
paragraph line's reading now goes through.
- Docs: the CHANGELOG entry and a wording fix in the composed chip
label entry, the recipe's table paragraph and the capability
matrix's table row.
- Fidelity: the Word and LibreOffice on Windows baselines rewritten for
CobaltRota and LumaStudioInvoice, both nearer the page.
- Tests: DocxCellSeatTest covers a cell beside a composed one, several
lines, a line made taller from the space after it, what the margins
leave of a text row, the letters' foot and top, lines broken in one
paragraph, a standard face not embedded and a call under a tenth.
---
CHANGELOG.md | 39 +++-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../libreoffice-windows-lines.tsv | 178 +++++++--------
.../docx-fidelity/libreoffice-windows.tsv | 4 +-
.../docx-fidelity/word-windows-lines.tsv | 178 +++++++--------
.../resources/docx-fidelity/word-windows.tsv | 6 +-
.../backend/semantic/docx/DocxInk.java | 88 +++++---
.../semantic/docx/DocxSemanticBackend.java | 111 +++++++++-
.../semantic/docx/DocxCellSeatTest.java | 202 +++++++++++++++++-
10 files changed, 585 insertions(+), 225 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcc942c9c..7699c23b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,39 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export stands a table text cell's text where the page sets it by the height of its
+ lines, where its row's margins leave less than half a point of what it calls for.** Word and
+ LibreOffice stand an exact line's baseline four fifths of the way down it, and the page sets a
+ cell's line its face's ascent below its top; a row of text cells is moved by its margins by the
+ least its cells call for. A row holding a composed cell is not — its margins would move the
+ composed content too — nor one whose cells call for opposite ways or in a vertical merge, and
+ what the margins left was moved by the text's position from half a point up, and under that
+ not at all. `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood
+ 0.25pt low in their rows in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice
+ 24.2, and its covers label as much. Under half a point, from a tenth, each of the cell's lines
+ is now written shorter and the space after it taller by as much, or the other way as far as
+ that space holds: the cell's content keeps its height and each line its top, and the text
+ moves four fifths of the difference in both editors. Only where every paragraph of the cell is
+ one line, in a face whose letters the export reads — not a standard face it does not embed — and
+ no nearer the letters' tops and feet than three quarters of a point, where Word cuts an exact
+ line's letters on screen. The line alone written shorter keeps a row its height
+ in Word and on Windows, but LibreOffice 24.2 holds a row to its cells' content: the covers row,
+ whose value cells' lines were its tallest content, came out 0.8pt shorter there (measured).
+ From half a point up the position moves it as before: written by the line instead,
+ `ConsultingInvoice`'s amounts stood nearer the page in their rows in LibreOffice, where the
+ position moves text further than it says, and the rows themselves stand 0.3pt low there, which
+ that overshoot made up for.
+
+ In their rows, `CobaltRota`'s staff names and covers label now stand within 0.08pt of the
+ page's in Word, LibreOffice on Windows and LibreOffice 24.2, every row its height. `CobaltRota`'s
+ median moves from 0.17pt to 0.15pt in Word, its p90 from 0.38pt to 0.31pt, and from 0.08pt to
+ 0.06pt in LibreOffice on Windows, its p90 from 0.30pt to 0.22pt; `LumaStudioInvoice`'s from
+ 0.14pt to 0.11pt in Word, its long version's from 0.21pt to 0.17pt in Word and from 0.20pt to
+ 0.12pt in LibreOffice on Windows. No document of the fidelity corpus moves further in Word or in
+ LibreOffice on Windows. LibreOffice 24.2 sets each of `CobaltRota`'s staff rows a twip shorter
+ than the page, holding them to their content, so the rows above the names stand higher there,
+ and the names, seated in their rows, a few hundredths of a point further from the page.
+
- **A DOCX export holds a shape's row less its borders as wide as Word draws them, and a table's
row between two others less the rules it holds.**
- **A shape's borders come off as wide as Word draws them.** A border is written in eighths of a
@@ -64,8 +97,10 @@ follow semantic versioning; release dates are ISO 8601.
in LibreOffice on Windows and 7 further, by up to 0.15pt — the outlined pair's. In LibreOffice
24.2 its p90 moves from 0.90pt to 0.52pt and its median from 0.17pt to 0.22pt, 40 of its lines
nearer and 53 further, by up to 0.3pt: the single-shift labels, now seated in their chips,
- stand about 0.4pt high with them there, as LibreOffice 24.2 sets the chips 0.25pt higher than
- the page and Word 0.15pt lower. No other document of the fidelity corpus moves.
+ stand about 0.4pt high with them there, as the rows above the chips come out 0.25pt shorter in
+ all in LibreOffice 24.2 than on the page, and 0.15pt taller in Word; in its own row each chip
+ stands within a tenth of a point of the page's in all three. No other document of the fidelity
+ corpus moves.
- **A DOCX export counts a table's rules as Word and LibreOffice do, and holds a cell's margins and
a chip composed in it to the twip the page gives them.**
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 9c501fb41..0037a5f4d 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -80,7 +80,7 @@ Payload records live in `core` under
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes; its left margin and padding are not written, and a picture drawn beside its text is fitted to its box with its padding in it — each named in the report) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link, a transform or its left margin and padding as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) |
-| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row), each as wide as Word draws it, in eighths of a point, and counted as both editors count it, two and a half twips an eighth rounded down (a rule between two rows shared in whole twips, the row above it taking half rounded down; a cell's top and bottom margins rounded to the twip together), a row held at the page's height less the most any of its cells takes, its margins and the rules it holds — between two others, the rest of its own above and half that of the cell under it; LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's, and under a row ruled heavier than it a row stands the difference lower there; a row of text cells moved by its top and bottom margins together, from Word's seat four fifths of the way down an exact line onto the page's baselines, by the least its cells' lines call for and no further than the margins hold — not a row holding a composed cell, one the layout did not measure, one in a vertical merge or whose cells call for opposite ways, nor a least call under a tenth of a point — and what the margins leave of a cell's call, half a point and more, moved by its text's position; a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a translucent fill is flattened against the colour the page paints under the cell, and a translucent rule against the cell's fill where it has one, since `w:shd` and `w:tcBorders` are opaque, and the table's report note names it (`translucency`); Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) |
+| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row), each as wide as Word draws it, in eighths of a point, and counted as both editors count it, two and a half twips an eighth rounded down (a rule between two rows shared in whole twips, the row above it taking half rounded down; a cell's top and bottom margins rounded to the twip together), a row held at the page's height less the most any of its cells takes, its margins and the rules it holds — between two others, the rest of its own above and half that of the cell under it; LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's, and under a row ruled heavier than it a row stands the difference lower there; a row of text cells moved by its top and bottom margins together, from Word's seat four fifths of the way down an exact line onto the page's baselines, by the least its cells' lines call for and no further than the margins hold — not a row holding a composed cell, one the layout did not measure, one in a vertical merge or whose cells call for opposite ways, nor a least call under a tenth of a point — and what the margins leave of a cell's call, half a point and more, moved by its text's position, and less, from a tenth of a point, by the height of its lines, each written shorter and the space after it taller, or the other way as far as that space holds, which keeps the cell's content as tall — where every paragraph of the cell is one line, in a face whose letters the export reads, and its letters stay clear of the line's top and foot; a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, less its own margins and padding, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a translucent fill is flattened against the colour the page paints under the cell, and a translucent rule against the cell's fill where it has one, since `w:shd` and `w:tcBorders` are opaque, and the table's report note names it (`translucency`); Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) |
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; the report names a clip that cuts what its layers paint, measured from the layout's fragments by `DocxClipInk` — a clip that cuts nothing (an icon inside its box, a disc's initials, a photo filling its circle) is not named; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a floating picture over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less its borders and margins, which keep inside it, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it, from the shape's top where the line is taller than its outline and Word sets it on the shape's middle, and a label it holds whole written at the line height that stands its text on the page's baseline, as far as its letters and the room allow, by a tenth of a point or more and not in a standard face the export does not embed — its row held and its label cut in whole twips rounded down, as the row the cell is in holds the page's height rounded once, and what its letters keep the label past that room taken out of the margins of the cell it is in, where it is all that cell holds and the cell all its row holds; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing` where the layout puts it, anchored as a rectangle is |
| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape, and the markers as the shapes they are, anchored as a rectangle is: beside an entry's text they move with it when the text above is edited |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 74e552377..c80069bdc 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -69,7 +69,7 @@ creation date is real metadata.
|---|---|
| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, a heading line bold (the first three levels larger), and drops its marks; the export writes the text as the page sets it — read line by line through the page's own parser, one run a piece in the face, family, colour, tracking and size the page sets it in, `**Java**` a bold run reading `Java`, a linked paragraph's pieces in one link, Word's outline listing a heading by the text written — wherever it writes a paragraph, a page zone's and a badge's included (a badge's initials counted as the page sets them, and written in the flow where they stand in two faces or as a heading, as initials in two runs' faces are). The page's laid-out lines decide it: the pieces are written where those lines hold their letters in their faces, families, colours and tracking, at their sizes — an auto-sized paragraph's read at the size the page fits it to, a heading at its multiple of it; a session that reads no markdown has its marks written as they stand, and text the parser changes nothing of, an underscore inside a word, is written as it stands too. A paragraph composed in a table cell takes only lines that set its pieces so. The page sets a markdown heading in a line as tall as the paragraph's own and draws its letters past it; written in that exact line, Word cuts their tops on screen, and the report names a heading written taller than its line. The faces are the page's: where the session reads markdown its parser sets every piece in a face of its own, the paragraph's left aside, so a bold paragraph's `Senior_Engineer` is written regular, as the page sets it. A paragraph whose lines are not read — with no layout, composed in a table cell whose text, as authored or as the page reads it, no line of its table carries, or a page zone's the layout shows none of — or that the page sets in other letters than its text, as Arabic, which the page shapes before it reads the marks, or that the parser reads into nothing — a lone `*`, an empty list item; `***`, a rule; a line set four spaces in, a block of code — which the page sets as nothing, is written as authored, marks and all, and the report names it, saying where the lines are not read that whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at the size the page fits its text to, read off the layout's lines, on every path that writes a paragraph: a run with a style of its own keeps it, as on the page, and a prefix is measured at the fitted size. Where the lines do not tell it — none are read, they are in sizes that do not say which is the paragraph's, or the paragraph is added at more than one place, which the page fits at each apart — the text is written at its style's size, and the report says the fitted size is not measured, on the paragraph or, in a header or footer, on the zone's note. LibreOffice breaks a badge's initials wider than the square inscribed in its disc, at any size, and hides what wraps; an auto-sized badge, which fills its disc, meets this too. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. An item the page reads as markdown is written as the page sets it, as a paragraph is. See "What a list becomes" below for the kinds that stay plain paragraphs |
-| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given |
+| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). Each rule comes off as wide as Word draws it, in eighths of a point: a 0.7pt hairline is drawn 0.75pt wide (measured: taken off at 0.7, `EditorialProposal`'s tables and all under them stood a step of Word's grid lower). A cell's rule is written from two to ninety-six eighths of a point, the range of a line border's width. A rule is counted as both editors count it, two and a half twips an eighth rounded down (measured: one of 12.5 twips takes 12, one of 17.5 takes 17), and a rule between two rows is shared in whole twips, the row above it taking half rounded down and the row under it the rest; a cell's top and bottom margins are rounded to the twip together. Tables of one, two, three and six rows ruled at five, six, seven and nine eighths then take the page's height to within a step of Word's grid and to the twip in LibreOffice (measured). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A row between two others is written less the rest of its own rule above it and half that of the cell under it, one rule where the two are ruled alike, and any row less the most one of its cells takes, its margins and those rules together (measured: held less its own rule alone, a row ruled at 0.5pt over one ruled at 1.5pt stood half a point taller in Word, and all under it half a point lower). LibreOffice makes room at the edge between two rows for the heavier of their rules, where Word makes it for the lower row's: under a row ruled heavier than it, a row and all under it stand the difference lower there (measured: under a 1.5pt row, 0.5pt rows stood 1pt lower in LibreOffice on Windows and LibreOffice 24.2, and where the page sets them in Word). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A row of text cells is moved by its margins to stand its text where the page sets it: the page sets a cell's line its face's ascent below the line's top, Word an exact line's baseline four fifths of the way down it, so every cell's top margin is made smaller and its bottom one as much larger — or the other way — by the least any of its cells calls for, no further than the margin holds, and each cell's text moves with its room, set at the top, the middle or the bottom, while the row keeps its height (measured: a line of 8pt Poppins stood 0.58pt low in Word and 0.55pt in LibreOffice, of Lato 0.24 and 0.25pt high; moved, within 0.04pt and 0.09pt). Not a row holding a composed cell, whose paragraphs their own writers seat where they can, nor a row the layout did not measure, nor one in a vertical merge or whose cells call for opposite ways; a least call under a tenth of a point is left. What the margins leave of a cell's call is moved by its text's position where that is half a point or more, as a paragraph's is: LibreOffice moves raised text further than it is raised, by up to half as much again, which from half a point up is less than what is left. Less, from a tenth of a point, is moved by the height of the cell's lines: each written shorter and the space after it taller by as much, or the other way as far as that space holds, which keeps the cell's content as tall and each line's top where it was, and moves the text four fifths of the difference in both editors — where every paragraph of the cell is one line, in a face whose letters the export reads (not a standard face it does not embed), and its letters stay clear of the line's top and foot, where Word cuts them on screen (measured: `CobaltRota`'s staff names, beside the day cells its chips are composed in, stood 0.25pt low in Word, 0.2pt in LibreOffice on Windows and 0.19pt in LibreOffice 24.2; so, within 0.08pt in all three, every row its height; the line alone written shorter shortened a row whose tallest content it was by as much in LibreOffice 24.2, which holds a row to its cells' content). LibreOffice sets a vertically centred cell's content as if its first paragraph had no space above it, so a cell padded more above than its row's smallest stands its text up to half that difference higher there (measured: 3pt above it stood the line 1.5pt high). A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too |
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
index f5363964d..a8684d00e 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -2294,31 +2294,31 @@ invoice-luma_studio 0:32e8369a9c4f 0.04 0.80 qty
invoice-luma_studio 0:d1062d072acd 0.04 0.78 unitprice
invoice-luma_studio 0:7c512b48ba8a 0.04 0.84 vat
invoice-luma_studio 0:cf38d95c9c6b 0.04 1.54 amount
-invoice-luma_studio 0:7b420577e50f 0.07 -0.10 £1,200.00
-invoice-luma_studio 0:7b420577e50f#1 0.07 -0.25 £1,200.00
-invoice-luma_studio 0:dc934fe30a94 0.07 0.00 20%
-invoice-luma_studio 0:dc934fe30a94#1 0.08 0.00 20%
-invoice-luma_studio 0:dc934fe30a94#2 0.08 0.00 20%
-invoice-luma_studio 0:dc934fe30a94#3 0.09 0.00 20%
-invoice-luma_studio 0:dc934fe30a94#4 0.10 0.00 20%
+invoice-luma_studio 0:7b420577e50f -0.13 -0.10 £1,200.00
+invoice-luma_studio 0:7b420577e50f#1 -0.13 -0.25 £1,200.00
+invoice-luma_studio 0:dc934fe30a94 -0.13 0.00 20%
+invoice-luma_studio 0:dc934fe30a94#1 -0.12 0.00 20%
+invoice-luma_studio 0:dc934fe30a94#2 -0.12 0.00 20%
+invoice-luma_studio 0:dc934fe30a94#3 -0.11 0.00 20%
+invoice-luma_studio 0:dc934fe30a94#4 -0.10 0.00 20%
invoice-luma_studio 0:d8f4e3a46d2d 0.01 0.03 brandstrategyworkshop
invoice-luma_studio 0:be624d692600 -0.56 0.03 discoverysession,research&brand
invoice-luma_studio 0:40ca2aad940f -0.55 0.03 positioning.
-invoice-luma_studio 0:07bcffc86e95 0.08 -0.10 £2,650.00
-invoice-luma_studio 0:07bcffc86e95#1 0.08 -0.25 £2,650.00
+invoice-luma_studio 0:07bcffc86e95 -0.12 -0.10 £2,650.00
+invoice-luma_studio 0:07bcffc86e95#1 -0.12 -0.25 £2,650.00
invoice-luma_studio 0:e302dd04d528 0.02 0.03 visualidentitydesign
invoice-luma_studio 0:40e412f627fb -0.55 0.03 logosuite,colourpalette,typograp
invoice-luma_studio 0:9c17d116fe08 -0.54 0.03 &guidelines.
-invoice-luma_studio 0:a20d66947acc 0.08 -0.10 £3,200.00
-invoice-luma_studio 0:a20d66947acc#1 0.08 -0.25 £3,200.00
+invoice-luma_studio 0:a20d66947acc -0.12 -0.10 £3,200.00
+invoice-luma_studio 0:a20d66947acc#1 -0.12 -0.25 £3,200.00
invoice-luma_studio 0:b25ac9cd66bd 0.03 0.03 websitedesign(upto8pages)
invoice-luma_studio 0:dab758788e6f -0.19 0.03 ux/uidesignfordesktopandmobile.
-invoice-luma_studio 0:f916278cb74c 0.09 -0.05 £850.00
-invoice-luma_studio 0:f916278cb74c#1 0.09 -0.19 £850.00
+invoice-luma_studio 0:f916278cb74c -0.11 -0.05 £850.00
+invoice-luma_studio 0:f916278cb74c#1 -0.11 -0.19 £850.00
invoice-luma_studio 0:4a1359f78476 0.04 0.03 copywriting
invoice-luma_studio 0:69afea703455 -0.18 0.03 websitecopy&keymessaging.
-invoice-luma_studio 0:07b417914317 0.10 -0.05 £600.00
-invoice-luma_studio 0:07b417914317#1 0.10 -0.19 £600.00
+invoice-luma_studio 0:07b417914317 -0.10 -0.05 £600.00
+invoice-luma_studio 0:07b417914317#1 -0.10 -0.19 £600.00
invoice-luma_studio 0:340455fbac25 0.05 0.03 projectmanagement
invoice-luma_studio 0:72534f9863ad -0.52 0.03 planning,coordination&client
invoice-luma_studio 0:4df00f9a2b20 -0.52 0.03 liaison.
@@ -2391,47 +2391,47 @@ invoice-luma_studio_long 0:32e8369a9c4f 0.04 0.80 qty
invoice-luma_studio_long 0:d1062d072acd 0.04 0.78 unitprice
invoice-luma_studio_long 0:7c512b48ba8a 0.04 0.84 vat
invoice-luma_studio_long 0:cf38d95c9c6b 0.04 1.54 amount
-invoice-luma_studio_long 0:7b420577e50f 0.07 -0.10 £1,200.00
-invoice-luma_studio_long 0:7b420577e50f#1 0.07 -0.25 £1,200.00
-invoice-luma_studio_long 0:7b420577e50f#2 0.12 -0.10 £1,200.00
-invoice-luma_studio_long 0:7b420577e50f#3 0.12 -0.25 £1,200.00
-invoice-luma_studio_long 0:dc934fe30a94 0.07 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#1 0.08 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#2 0.09 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#3 0.10 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#4 0.11 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#5 0.12 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#6 0.13 0.00 20%
-invoice-luma_studio_long 0:dc934fe30a94#7 0.14 0.00 20%
+invoice-luma_studio_long 0:7b420577e50f -0.13 -0.10 £1,200.00
+invoice-luma_studio_long 0:7b420577e50f#1 -0.13 -0.25 £1,200.00
+invoice-luma_studio_long 0:7b420577e50f#2 -0.08 -0.10 £1,200.00
+invoice-luma_studio_long 0:7b420577e50f#3 -0.08 -0.25 £1,200.00
+invoice-luma_studio_long 0:dc934fe30a94 -0.13 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#1 -0.12 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#2 -0.11 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#3 -0.10 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#4 -0.09 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#5 -0.08 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#6 -0.07 0.00 20%
+invoice-luma_studio_long 0:dc934fe30a94#7 -0.06 0.00 20%
invoice-luma_studio_long 0:d8f4e3a46d2d 0.01 0.03 brandstrategyworkshop
invoice-luma_studio_long 0:d8f4e3a46d2d#1 0.06 0.03 brandstrategyworkshop
invoice-luma_studio_long 0:be624d692600 -0.56 0.03 discoverysession,research&brand
invoice-luma_studio_long 0:be624d692600#1 -0.51 0.03 discoverysession,research&brand
invoice-luma_studio_long 0:d6def76ba3d1 -0.55 0.03 positioning.phase1.
-invoice-luma_studio_long 0:07bcffc86e95 0.08 -0.10 £2,650.00
-invoice-luma_studio_long 0:07bcffc86e95#1 0.08 -0.25 £2,650.00
-invoice-luma_studio_long 0:07bcffc86e95#2 0.13 -0.10 £2,650.00
-invoice-luma_studio_long 0:07bcffc86e95#3 0.13 -0.25 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95 -0.12 -0.10 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95#1 -0.12 -0.25 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95#2 -0.07 -0.10 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95#3 -0.07 -0.25 £2,650.00
invoice-luma_studio_long 0:e302dd04d528 0.02 0.03 visualidentitydesign
invoice-luma_studio_long 0:e302dd04d528#1 0.07 0.03 visualidentitydesign
invoice-luma_studio_long 0:40e412f627fb -0.55 0.03 logosuite,colourpalette,typograp
invoice-luma_studio_long 0:40e412f627fb#1 -0.50 0.03 logosuite,colourpalette,typograp
invoice-luma_studio_long 0:270a9e26d2e0 -0.54 0.03 &guidelines.phase1.
-invoice-luma_studio_long 0:a20d66947acc 0.09 -0.10 £3,200.00
-invoice-luma_studio_long 0:a20d66947acc#1 0.09 -0.25 £3,200.00
-invoice-luma_studio_long 0:a20d66947acc#2 0.14 -0.10 £3,200.00
-invoice-luma_studio_long 0:a20d66947acc#3 0.14 -0.25 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc -0.11 -0.10 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc#1 -0.11 -0.25 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc#2 -0.06 -0.10 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc#3 -0.06 -0.25 £3,200.00
invoice-luma_studio_long 0:b25ac9cd66bd 0.03 0.03 websitedesign(upto8pages)
invoice-luma_studio_long 0:b25ac9cd66bd#1 0.08 0.03 websitedesign(upto8pages)
invoice-luma_studio_long 0:dab758788e6f -0.54 0.03 ux/uidesignfordesktopandmobile.
invoice-luma_studio_long 0:dab758788e6f#1 -0.49 0.03 ux/uidesignfordesktopandmobile.
invoice-luma_studio_long 0:bb1a79cda1ae -0.53 0.03 phase1.
-invoice-luma_studio_long 0:f916278cb74c 0.10 -0.05 £850.00
-invoice-luma_studio_long 0:f916278cb74c#1 0.10 -0.19 £850.00
+invoice-luma_studio_long 0:f916278cb74c -0.10 -0.05 £850.00
+invoice-luma_studio_long 0:f916278cb74c#1 -0.10 -0.19 £850.00
invoice-luma_studio_long 0:4a1359f78476 0.04 0.03 copywriting
invoice-luma_studio_long 0:e6a181326fc9 -0.53 0.03 websitecopy&keymessaging.phase
-invoice-luma_studio_long 0:07b417914317 0.11 -0.05 £600.00
-invoice-luma_studio_long 0:07b417914317#1 0.11 -0.19 £600.00
+invoice-luma_studio_long 0:07b417914317 -0.09 -0.05 £600.00
+invoice-luma_studio_long 0:07b417914317#1 -0.09 -0.19 £600.00
invoice-luma_studio_long 0:340455fbac25 0.05 0.03 projectmanagement
invoice-luma_studio_long 0:72534f9863ad -0.52 0.03 planning,coordination&client
invoice-luma_studio_long 0:8f09113ca513 -0.51 0.03 liaison.phase1.
@@ -2444,36 +2444,36 @@ invoice-luma_studio_long 1:32e8369a9c4f 0.18 0.80 qty
invoice-luma_studio_long 1:d1062d072acd 0.18 0.78 unitprice
invoice-luma_studio_long 1:7c512b48ba8a 0.18 0.84 vat
invoice-luma_studio_long 1:cf38d95c9c6b 0.18 1.54 amount
-invoice-luma_studio_long 1:f916278cb74c 0.21 -0.05 £850.00
-invoice-luma_studio_long 1:f916278cb74c#1 0.21 -0.19 £850.00
-invoice-luma_studio_long 1:f916278cb74c#2 0.26 -0.05 £850.00
-invoice-luma_studio_long 1:f916278cb74c#3 0.26 -0.19 £850.00
-invoice-luma_studio_long 1:f916278cb74c#4 0.31 -0.05 £850.00
-invoice-luma_studio_long 1:f916278cb74c#5 0.31 -0.19 £850.00
-invoice-luma_studio_long 1:dc934fe30a94 0.21 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#1 0.22 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#2 0.23 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#3 0.24 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#4 0.25 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#5 0.26 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#6 0.27 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#7 0.28 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#8 0.29 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#9 0.30 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#10 0.31 0.00 20%
-invoice-luma_studio_long 1:dc934fe30a94#11 0.32 0.00 20%
+invoice-luma_studio_long 1:f916278cb74c 0.01 -0.05 £850.00
+invoice-luma_studio_long 1:f916278cb74c#1 0.01 -0.19 £850.00
+invoice-luma_studio_long 1:f916278cb74c#2 0.06 -0.05 £850.00
+invoice-luma_studio_long 1:f916278cb74c#3 0.06 -0.19 £850.00
+invoice-luma_studio_long 1:f916278cb74c#4 0.11 -0.05 £850.00
+invoice-luma_studio_long 1:f916278cb74c#5 0.11 -0.19 £850.00
+invoice-luma_studio_long 1:dc934fe30a94 0.01 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#1 0.02 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#2 0.03 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#3 0.04 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#4 0.05 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#5 0.06 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#6 0.07 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#7 0.08 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#8 0.09 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#9 0.10 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#10 0.11 0.00 20%
+invoice-luma_studio_long 1:dc934fe30a94#11 0.12 0.00 20%
invoice-luma_studio_long 1:4a1359f78476 0.15 0.03 copywriting
invoice-luma_studio_long 1:4a1359f78476#1 0.20 0.03 copywriting
invoice-luma_studio_long 1:4a1359f78476#2 0.25 0.03 copywriting
invoice-luma_studio_long 1:e6a181326fc9 -0.41 0.03 websitecopy&keymessaging.phase
invoice-luma_studio_long 1:e6a181326fc9#1 -0.36 0.03 websitecopy&keymessaging.phase
invoice-luma_studio_long 1:e6a181326fc9#2 -0.31 0.03 websitecopy&keymessaging.phase
-invoice-luma_studio_long 1:07b417914317 0.22 -0.05 £600.00
-invoice-luma_studio_long 1:07b417914317#1 0.22 -0.19 £600.00
-invoice-luma_studio_long 1:07b417914317#2 0.27 -0.05 £600.00
-invoice-luma_studio_long 1:07b417914317#3 0.27 -0.19 £600.00
-invoice-luma_studio_long 1:07b417914317#4 0.32 -0.05 £600.00
-invoice-luma_studio_long 1:07b417914317#5 0.32 -0.19 £600.00
+invoice-luma_studio_long 1:07b417914317 0.02 -0.05 £600.00
+invoice-luma_studio_long 1:07b417914317#1 0.02 -0.19 £600.00
+invoice-luma_studio_long 1:07b417914317#2 0.07 -0.05 £600.00
+invoice-luma_studio_long 1:07b417914317#3 0.07 -0.19 £600.00
+invoice-luma_studio_long 1:07b417914317#4 0.12 -0.05 £600.00
+invoice-luma_studio_long 1:07b417914317#5 0.12 -0.19 £600.00
invoice-luma_studio_long 1:340455fbac25 0.16 0.03 projectmanagement
invoice-luma_studio_long 1:340455fbac25#1 0.21 0.03 projectmanagement
invoice-luma_studio_long 1:340455fbac25#2 0.26 0.03 projectmanagement
@@ -2481,28 +2481,28 @@ invoice-luma_studio_long 1:72534f9863ad -0.40 0.03 planning,coordination&client
invoice-luma_studio_long 1:72534f9863ad#1 -0.35 0.03 planning,coordination&client
invoice-luma_studio_long 1:72534f9863ad#2 -0.30 0.03 planning,coordination&client
invoice-luma_studio_long 1:ad05c4b80bfe -0.40 0.03 liaison.phase2.
-invoice-luma_studio_long 1:7b420577e50f 0.23 -0.10 £1,200.00
-invoice-luma_studio_long 1:7b420577e50f#1 0.23 -0.25 £1,200.00
-invoice-luma_studio_long 1:7b420577e50f#2 0.28 -0.10 £1,200.00
-invoice-luma_studio_long 1:7b420577e50f#3 0.28 -0.25 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f 0.03 -0.10 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f#1 0.03 -0.25 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f#2 0.08 -0.10 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f#3 0.08 -0.25 £1,200.00
invoice-luma_studio_long 1:d8f4e3a46d2d 0.17 0.03 brandstrategyworkshop
invoice-luma_studio_long 1:d8f4e3a46d2d#1 0.22 0.03 brandstrategyworkshop
invoice-luma_studio_long 1:be624d692600 -0.39 0.03 discoverysession,research&brand
invoice-luma_studio_long 1:be624d692600#1 -0.34 0.03 discoverysession,research&brand
invoice-luma_studio_long 1:b95a82d5055c -0.39 0.03 positioning.phase3.
-invoice-luma_studio_long 1:07bcffc86e95 0.24 -0.10 £2,650.00
-invoice-luma_studio_long 1:07bcffc86e95#1 0.24 -0.25 £2,650.00
-invoice-luma_studio_long 1:07bcffc86e95#2 0.29 -0.10 £2,650.00
-invoice-luma_studio_long 1:07bcffc86e95#3 0.29 -0.25 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95 0.04 -0.10 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95#1 0.04 -0.25 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95#2 0.09 -0.10 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95#3 0.09 -0.25 £2,650.00
invoice-luma_studio_long 1:e302dd04d528 0.18 0.03 visualidentitydesign
invoice-luma_studio_long 1:e302dd04d528#1 0.23 0.03 visualidentitydesign
invoice-luma_studio_long 1:40e412f627fb -0.38 0.03 logosuite,colourpalette,typograp
invoice-luma_studio_long 1:40e412f627fb#1 -0.33 0.03 logosuite,colourpalette,typograp
invoice-luma_studio_long 1:708bc0c2e87c -0.38 0.03 &guidelines.phase3.
-invoice-luma_studio_long 1:a20d66947acc 0.25 -0.10 £3,200.00
-invoice-luma_studio_long 1:a20d66947acc#1 0.25 -0.25 £3,200.00
-invoice-luma_studio_long 1:a20d66947acc#2 0.30 -0.10 £3,200.00
-invoice-luma_studio_long 1:a20d66947acc#3 0.30 -0.25 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc 0.05 -0.10 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc#1 0.05 -0.25 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc#2 0.10 -0.10 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc#3 0.10 -0.25 £3,200.00
invoice-luma_studio_long 1:b25ac9cd66bd 0.19 0.03 websitedesign(upto8pages)
invoice-luma_studio_long 1:b25ac9cd66bd#1 0.24 0.03 websitedesign(upto8pages)
invoice-luma_studio_long 1:dab758788e6f -0.37 0.03 ux/uidesignfordesktopandmobile.
@@ -4043,7 +4043,7 @@ rota-cobalt 0:d89987d22df9 -0.09 -0.21 soulgf/pianist18:30
rota-cobalt 0:fb980282ae0a -0.08 -0.22 pianist18:30/partner
rota-cobalt 0:3c1fc038017e -0.09 -0.26 meeting3:30pm
rota-cobalt 0:cc96b22615e5 0.22 -0.30 pianist19:00
-rota-cobalt 0:bc14a4ce6407 0.17 0.36 statuslegend
+rota-cobalt 0:bc14a4ce6407 -0.03 0.36 statuslegend
rota-cobalt 0:1f58b9145b24 -0.07 -0.02 request
rota-cobalt 0:b4dc66dde806 -0.07 -0.17 off
rota-cobalt 0:b4dc66dde806#1 -0.01 -0.40 off
@@ -4086,7 +4086,7 @@ rota-cobalt 0:08c4242b5f58 -0.07 -0.12 stock
rota-cobalt 0:f8f8922da592 0.18 -0.09 standby
rota-cobalt 0:c2fb788c7dee 0.18 -0.05 training
rota-cobalt 0:a18603086e5b -0.07 -0.04 support
-rota-cobalt 0:17ae69cc7e46 0.26 -0.38 covers
+rota-cobalt 0:17ae69cc7e46 0.01 -0.38 covers
rota-cobalt 0:412a5c513322 0.27 -0.21 l104/d50
rota-cobalt 0:304560bf3205 0.27 -0.15 l33/d57
rota-cobalt 0:ca6ce47ada53 0.27 -0.19 l32/d76
@@ -4095,7 +4095,7 @@ rota-cobalt 0:7062fafe1dab 0.27 -0.25 l65/d112
rota-cobalt 0:778cb57b62fa 0.27 -0.25 l73/d112
rota-cobalt 0:1b4471920f01 0.27 -0.18 l72/d83
rota-cobalt 0:288965a1f2c8 0.22 -0.37 management
-rota-cobalt 0:6a61eb7383e8 0.28 -0.09 rosa
+rota-cobalt 0:6a61eb7383e8 0.03 -0.09 rosa
rota-cobalt 0:3bb0f0bb5552 -0.01 -0.64 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#1 0.00 -0.68 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#2 0.00 -0.67 09:00-18:00
@@ -4123,7 +4123,7 @@ rota-cobalt 0:e669544e9895 0.15 0.01 08:00-16:00
rota-cobalt 0:e669544e9895#1 0.18 0.06 08:00-16:00
rota-cobalt 0:19c30663e8ef 0.51 0.81 16:00-21:00
rota-cobalt 0:19c30663e8ef#1 0.55 0.86 16:00-21:00
-rota-cobalt 0:c9affe7f7587 0.29 -0.02 tomas
+rota-cobalt 0:c9affe7f7587 0.04 -0.02 tomas
rota-cobalt 0:6d43df86fe4b 0.00 -0.64 16:00-00:00
rota-cobalt 0:6d43df86fe4b#1 0.00 -0.59 16:00-00:00
rota-cobalt 0:6d43df86fe4b#2 0.00 -0.63 16:00-00:00
@@ -4140,7 +4140,7 @@ rota-cobalt 0:6d43df86fe4b#12 0.05 -0.63 16:00-00:00
rota-cobalt 0:6d43df86fe4b#13 0.17 0.05 16:00-00:00
rota-cobalt 0:5a5f719aae11 0.15 0.00 11:00-16:00
rota-cobalt 0:df9ba122242c 0.22 -0.37 bartenders
-rota-cobalt 0:dbaf86dd80e3 0.29 -0.08 nadia
+rota-cobalt 0:dbaf86dd80e3 0.04 -0.08 nadia
rota-cobalt 0:0cdd9d9e75dc 0.15 0.04 09:00-16:00
rota-cobalt 0:0cdd9d9e75dc#1 0.16 0.06 09:00-16:00
rota-cobalt 0:0cdd9d9e75dc#2 0.16 0.09 09:00-16:00
@@ -4150,7 +4150,7 @@ rota-cobalt 0:f1e2ce9aa575#1 0.52 0.80 16:00-22:00
rota-cobalt 0:f1e2ce9aa575#2 0.52 0.86 16:00-22:00
rota-cobalt 0:f1e2ce9aa575#3 0.53 0.89 16:00-22:00
rota-cobalt 0:f1e2ce9aa575#4 0.53 0.85 16:00-22:00
-rota-cobalt 0:de7ee76e7373 0.30 -0.12 elias
+rota-cobalt 0:de7ee76e7373 0.05 -0.12 elias
rota-cobalt 0:61587c4c0d6d 0.00 -0.59 09:00-17:00
rota-cobalt 0:7195a8ed72b9 0.16 0.00 12:00-16:00
rota-cobalt 0:7195a8ed72b9#1 0.17 0.04 12:00-16:00
@@ -4162,16 +4162,16 @@ rota-cobalt 0:7195a8ed72b9#6 0.20 0.06 12:00-16:00
rota-cobalt 0:7195a8ed72b9#7 0.20 0.01 12:00-16:00
rota-cobalt 0:7195a8ed72b9#8 0.20 0.04 12:00-16:00
rota-cobalt 0:7195a8ed72b9#9 0.20 0.05 12:00-16:00
-rota-cobalt 0:953cb9ba8128 0.30 0.00 mirela
+rota-cobalt 0:953cb9ba8128 0.05 0.00 mirela
rota-cobalt 0:c1298a6e85c2 0.01 -0.63 12:00-18:00
rota-cobalt 0:c1298a6e85c2#1 0.02 -0.63 12:00-18:00
-rota-cobalt 0:86846e84f9bf 0.31 -0.07 sofia
-rota-cobalt 0:effcc54ba75f 0.31 -0.08 karol
-rota-cobalt 0:5c1e134d2d34 0.32 -0.01 henrik
-rota-cobalt 0:6326e0e8cfda 0.33 -0.08 lucia
+rota-cobalt 0:86846e84f9bf 0.06 -0.07 sofia
+rota-cobalt 0:effcc54ba75f 0.06 -0.08 karol
+rota-cobalt 0:5c1e134d2d34 0.07 -0.01 henrik
+rota-cobalt 0:6326e0e8cfda 0.08 -0.08 lucia
rota-cobalt 0:c235e502637c 0.26 -0.37 barbacks
-rota-cobalt 0:bd558de5f794 0.33 -0.06 oskar
-rota-cobalt 0:4941d6993ee9 0.33 -0.01 teodor
-rota-cobalt 0:1d3d3d6a07bd 0.34 -0.07 vesna
+rota-cobalt 0:bd558de5f794 0.08 -0.06 oskar
+rota-cobalt 0:4941d6993ee9 0.08 -0.01 teodor
+rota-cobalt 0:1d3d3d6a07bd 0.09 -0.07 vesna
rota-cobalt 0:59f2ff7c7c70 0.03 0.08 timesshownin24-hourformat.shiftc
rota-cobalt 0:594c41ec0506 0.03 -3.55 quaysidebar|31aug-6sep2026
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index 41d14b152..49ee62e39 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -31,7 +31,7 @@ invoice-classic_long 2 2 166 166 0.03 0.12 0
invoice-consulting 1 1 92 92 0.19 1.01 0
invoice-consulting_long 2 2 236 236 0.23 0.78 0
invoice-luma_studio 1 1 97 97 0.12 0.46 2
-invoice-luma_studio_long 3 3 192 192 0.20 0.41 2
+invoice-luma_studio_long 3 3 192 192 0.12 0.41 2
invoice-merchant 1 2 94 90 0.07 0.21 0
invoice-metered 1 1 99 99 0.10 0.83 0
invoice-modern 1 1 54 54 0.10 0.20 0
@@ -61,4 +61,4 @@ proposal-indigo 1 1 72 70 0.26 0.65 0
proposal-modern 1 1 44 44 0.05 0.17 0
proposal-northline 2 2 132 132 0.17 0.45 0
receipt-modern 1 1 57 57 0.09 0.29 0
-rota-cobalt 1 1 154 151 0.08 0.30 0
+rota-cobalt 1 1 154 151 0.06 0.22 0
diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
index 9e6e553c3..63f080e1b 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -2392,31 +2392,31 @@ invoice-luma_studio 0:32e8369a9c4f 0.03 -0.04 qty
invoice-luma_studio 0:d1062d072acd 0.03 0.00 unitprice
invoice-luma_studio 0:7c512b48ba8a 0.03 0.06 vat
invoice-luma_studio 0:cf38d95c9c6b 0.03 -0.12 amount
-invoice-luma_studio 0:7b420577e50f 0.11 -0.13 £1,200.00
-invoice-luma_studio 0:7b420577e50f#1 0.11 -0.31 £1,200.00
-invoice-luma_studio 0:dc934fe30a94 0.11 -0.04 20%
-invoice-luma_studio 0:dc934fe30a94#1 0.04 -0.04 20%
-invoice-luma_studio 0:dc934fe30a94#2 0.07 -0.04 20%
-invoice-luma_studio 0:dc934fe30a94#3 0.16 -0.04 20%
-invoice-luma_studio 0:dc934fe30a94#4 0.17 -0.04 20%
+invoice-luma_studio 0:7b420577e50f -0.01 -0.13 £1,200.00
+invoice-luma_studio 0:7b420577e50f#1 -0.01 -0.31 £1,200.00
+invoice-luma_studio 0:dc934fe30a94 -0.01 -0.04 20%
+invoice-luma_studio 0:dc934fe30a94#1 -0.08 -0.04 20%
+invoice-luma_studio 0:dc934fe30a94#2 -0.05 -0.04 20%
+invoice-luma_studio 0:dc934fe30a94#3 0.04 -0.04 20%
+invoice-luma_studio 0:dc934fe30a94#4 0.05 -0.04 20%
invoice-luma_studio 0:d8f4e3a46d2d 0.07 0.02 brandstrategyworkshop
invoice-luma_studio 0:be624d692600 -0.47 0.02 discoverysession,research&brand
invoice-luma_studio 0:40ca2aad940f -0.50 0.02 positioning.
-invoice-luma_studio 0:07bcffc86e95 0.04 -0.13 £2,650.00
-invoice-luma_studio 0:07bcffc86e95#1 0.04 -0.31 £2,650.00
+invoice-luma_studio 0:07bcffc86e95 -0.08 -0.13 £2,650.00
+invoice-luma_studio 0:07bcffc86e95#1 -0.08 -0.31 £2,650.00
invoice-luma_studio 0:e302dd04d528 0.12 0.02 visualidentitydesign
invoice-luma_studio 0:40e412f627fb -0.42 0.02 logosuite,colourpalette,typograp
invoice-luma_studio 0:9c17d116fe08 -0.45 0.02 &guidelines.
-invoice-luma_studio 0:a20d66947acc 0.07 -0.13 £3,200.00
-invoice-luma_studio 0:a20d66947acc#1 0.07 -0.31 £3,200.00
+invoice-luma_studio 0:a20d66947acc -0.05 -0.13 £3,200.00
+invoice-luma_studio 0:a20d66947acc#1 -0.05 -0.31 £3,200.00
invoice-luma_studio 0:b25ac9cd66bd 0.07 0.02 websitedesign(upto8pages)
invoice-luma_studio 0:dab758788e6f -0.11 0.02 ux/uidesignfordesktopandmobile.
-invoice-luma_studio 0:f916278cb74c 0.16 -0.08 £850.00
-invoice-luma_studio 0:f916278cb74c#1 0.16 -0.30 £850.00
+invoice-luma_studio 0:f916278cb74c 0.04 -0.08 £850.00
+invoice-luma_studio 0:f916278cb74c#1 0.04 -0.30 £850.00
invoice-luma_studio 0:4a1359f78476 0.16 0.02 copywriting
invoice-luma_studio 0:69afea703455 -0.02 0.02 websitecopy&keymessaging.
-invoice-luma_studio 0:07b417914317 0.17 -0.08 £600.00
-invoice-luma_studio 0:07b417914317#1 0.17 -0.30 £600.00
+invoice-luma_studio 0:07b417914317 0.05 -0.08 £600.00
+invoice-luma_studio 0:07b417914317#1 0.05 -0.30 £600.00
invoice-luma_studio 0:340455fbac25 0.13 0.02 projectmanagement
invoice-luma_studio 0:72534f9863ad -0.41 0.02 planning,coordination&client
invoice-luma_studio 0:4df00f9a2b20 -0.45 0.02 liaison.
@@ -2489,47 +2489,47 @@ invoice-luma_studio_long 0:32e8369a9c4f 0.03 -0.04 qty
invoice-luma_studio_long 0:d1062d072acd 0.03 0.00 unitprice
invoice-luma_studio_long 0:7c512b48ba8a 0.03 0.06 vat
invoice-luma_studio_long 0:cf38d95c9c6b 0.03 -0.12 amount
-invoice-luma_studio_long 0:7b420577e50f 0.11 -0.13 £1,200.00
-invoice-luma_studio_long 0:7b420577e50f#1 0.11 -0.31 £1,200.00
-invoice-luma_studio_long 0:7b420577e50f#2 0.17 -0.13 £1,200.00
-invoice-luma_studio_long 0:7b420577e50f#3 0.17 -0.31 £1,200.00
-invoice-luma_studio_long 0:dc934fe30a94 0.11 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#1 0.04 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#2 0.11 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#3 0.16 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#4 0.09 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#5 0.17 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#6 0.22 -0.04 20%
-invoice-luma_studio_long 0:dc934fe30a94#7 0.15 -0.04 20%
+invoice-luma_studio_long 0:7b420577e50f -0.01 -0.13 £1,200.00
+invoice-luma_studio_long 0:7b420577e50f#1 -0.01 -0.31 £1,200.00
+invoice-luma_studio_long 0:7b420577e50f#2 0.05 -0.13 £1,200.00
+invoice-luma_studio_long 0:7b420577e50f#3 0.05 -0.31 £1,200.00
+invoice-luma_studio_long 0:dc934fe30a94 -0.01 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#1 -0.08 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#2 -0.01 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#3 0.04 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#4 -0.03 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#5 0.05 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#6 0.10 -0.04 20%
+invoice-luma_studio_long 0:dc934fe30a94#7 0.03 -0.04 20%
invoice-luma_studio_long 0:d8f4e3a46d2d 0.07 0.02 brandstrategyworkshop
invoice-luma_studio_long 0:d8f4e3a46d2d#1 0.10 0.02 brandstrategyworkshop
invoice-luma_studio_long 0:be624d692600 -0.47 0.02 discoverysession,research&brand
invoice-luma_studio_long 0:be624d692600#1 -0.41 0.02 discoverysession,research&brand
invoice-luma_studio_long 0:d6def76ba3d1 -0.50 0.02 positioning.phase1.
-invoice-luma_studio_long 0:07bcffc86e95 0.04 -0.13 £2,650.00
-invoice-luma_studio_long 0:07bcffc86e95#1 0.04 -0.31 £2,650.00
-invoice-luma_studio_long 0:07bcffc86e95#2 0.22 -0.13 £2,650.00
-invoice-luma_studio_long 0:07bcffc86e95#3 0.22 -0.31 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95 -0.08 -0.13 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95#1 -0.08 -0.31 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95#2 0.10 -0.13 £2,650.00
+invoice-luma_studio_long 0:07bcffc86e95#3 0.10 -0.31 £2,650.00
invoice-luma_studio_long 0:e302dd04d528 0.12 0.02 visualidentitydesign
invoice-luma_studio_long 0:e302dd04d528#1 0.18 0.02 visualidentitydesign
invoice-luma_studio_long 0:40e412f627fb -0.42 0.02 logosuite,colourpalette,typograp
invoice-luma_studio_long 0:40e412f627fb#1 -0.36 0.02 logosuite,colourpalette,typograp
invoice-luma_studio_long 0:270a9e26d2e0 -0.45 0.02 &guidelines.phase1.
-invoice-luma_studio_long 0:a20d66947acc 0.11 -0.13 £3,200.00
-invoice-luma_studio_long 0:a20d66947acc#1 0.11 -0.31 £3,200.00
-invoice-luma_studio_long 0:a20d66947acc#2 0.15 -0.13 £3,200.00
-invoice-luma_studio_long 0:a20d66947acc#3 0.15 -0.31 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc -0.01 -0.13 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc#1 -0.01 -0.31 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc#2 0.03 -0.13 £3,200.00
+invoice-luma_studio_long 0:a20d66947acc#3 0.03 -0.31 £3,200.00
invoice-luma_studio_long 0:b25ac9cd66bd 0.07 0.02 websitedesign(upto8pages)
invoice-luma_studio_long 0:b25ac9cd66bd#1 0.23 0.02 websitedesign(upto8pages)
invoice-luma_studio_long 0:dab758788e6f -0.47 0.02 ux/uidesignfordesktopandmobile.
invoice-luma_studio_long 0:dab758788e6f#1 -0.31 0.02 ux/uidesignfordesktopandmobile.
invoice-luma_studio_long 0:bb1a79cda1ae -0.38 0.02 phase1.
-invoice-luma_studio_long 0:f916278cb74c 0.16 -0.08 £850.00
-invoice-luma_studio_long 0:f916278cb74c#1 0.16 -0.30 £850.00
+invoice-luma_studio_long 0:f916278cb74c 0.04 -0.08 £850.00
+invoice-luma_studio_long 0:f916278cb74c#1 0.04 -0.30 £850.00
invoice-luma_studio_long 0:4a1359f78476 0.12 0.02 copywriting
invoice-luma_studio_long 0:e6a181326fc9 -0.42 0.02 websitecopy&keymessaging.phase
-invoice-luma_studio_long 0:07b417914317 0.09 -0.08 £600.00
-invoice-luma_studio_long 0:07b417914317#1 0.09 -0.30 £600.00
+invoice-luma_studio_long 0:07b417914317 -0.03 -0.08 £600.00
+invoice-luma_studio_long 0:07b417914317#1 -0.03 -0.30 £600.00
invoice-luma_studio_long 0:340455fbac25 0.17 0.02 projectmanagement
invoice-luma_studio_long 0:72534f9863ad -0.37 0.02 planning,coordination&client
invoice-luma_studio_long 0:8f09113ca513 -0.40 0.02 liaison.phase1.
@@ -2542,36 +2542,36 @@ invoice-luma_studio_long 1:32e8369a9c4f 0.20 -0.04 qty
invoice-luma_studio_long 1:d1062d072acd 0.20 0.00 unitprice
invoice-luma_studio_long 1:7c512b48ba8a 0.20 0.06 vat
invoice-luma_studio_long 1:cf38d95c9c6b 0.20 -0.12 amount
-invoice-luma_studio_long 1:f916278cb74c 0.19 -0.08 £850.00
-invoice-luma_studio_long 1:f916278cb74c#1 0.19 -0.30 £850.00
-invoice-luma_studio_long 1:f916278cb74c#2 0.23 -0.08 £850.00
-invoice-luma_studio_long 1:f916278cb74c#3 0.23 -0.30 £850.00
-invoice-luma_studio_long 1:f916278cb74c#4 0.38 -0.08 £850.00
-invoice-luma_studio_long 1:f916278cb74c#5 0.38 -0.30 £850.00
-invoice-luma_studio_long 1:dc934fe30a94 0.19 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#1 0.24 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#2 0.17 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#3 0.22 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#4 0.30 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#5 0.23 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#6 0.28 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#7 0.35 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#8 0.28 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#9 0.33 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#10 0.38 -0.04 20%
-invoice-luma_studio_long 1:dc934fe30a94#11 0.34 -0.04 20%
+invoice-luma_studio_long 1:f916278cb74c 0.07 -0.08 £850.00
+invoice-luma_studio_long 1:f916278cb74c#1 0.07 -0.30 £850.00
+invoice-luma_studio_long 1:f916278cb74c#2 0.11 -0.08 £850.00
+invoice-luma_studio_long 1:f916278cb74c#3 0.11 -0.30 £850.00
+invoice-luma_studio_long 1:f916278cb74c#4 0.26 -0.08 £850.00
+invoice-luma_studio_long 1:f916278cb74c#5 0.26 -0.30 £850.00
+invoice-luma_studio_long 1:dc934fe30a94 0.07 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#1 0.12 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#2 0.05 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#3 0.10 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#4 0.18 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#5 0.11 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#6 0.16 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#7 0.23 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#8 0.16 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#9 0.21 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#10 0.26 -0.04 20%
+invoice-luma_studio_long 1:dc934fe30a94#11 0.22 -0.04 20%
invoice-luma_studio_long 1:4a1359f78476 0.14 0.02 copywriting
invoice-luma_studio_long 1:4a1359f78476#1 0.30 0.02 copywriting
invoice-luma_studio_long 1:4a1359f78476#2 0.33 0.02 copywriting
invoice-luma_studio_long 1:e6a181326fc9 -0.40 0.02 websitecopy&keymessaging.phase
invoice-luma_studio_long 1:e6a181326fc9#1 -0.24 0.02 websitecopy&keymessaging.phase
invoice-luma_studio_long 1:e6a181326fc9#2 -0.21 0.02 websitecopy&keymessaging.phase
-invoice-luma_studio_long 1:07b417914317 0.24 -0.08 £600.00
-invoice-luma_studio_long 1:07b417914317#1 0.24 -0.30 £600.00
-invoice-luma_studio_long 1:07b417914317#2 0.28 -0.08 £600.00
-invoice-luma_studio_long 1:07b417914317#3 0.28 -0.30 £600.00
-invoice-luma_studio_long 1:07b417914317#4 0.34 -0.08 £600.00
-invoice-luma_studio_long 1:07b417914317#5 0.34 -0.30 £600.00
+invoice-luma_studio_long 1:07b417914317 0.12 -0.08 £600.00
+invoice-luma_studio_long 1:07b417914317#1 0.12 -0.30 £600.00
+invoice-luma_studio_long 1:07b417914317#2 0.16 -0.08 £600.00
+invoice-luma_studio_long 1:07b417914317#3 0.16 -0.30 £600.00
+invoice-luma_studio_long 1:07b417914317#4 0.22 -0.08 £600.00
+invoice-luma_studio_long 1:07b417914317#5 0.22 -0.30 £600.00
invoice-luma_studio_long 1:340455fbac25 0.19 0.02 projectmanagement
invoice-luma_studio_long 1:340455fbac25#1 0.23 0.02 projectmanagement
invoice-luma_studio_long 1:340455fbac25#2 0.41 0.02 projectmanagement
@@ -2579,28 +2579,28 @@ invoice-luma_studio_long 1:72534f9863ad -0.35 0.02 planning,coordination&client
invoice-luma_studio_long 1:72534f9863ad#1 -0.31 0.02 planning,coordination&client
invoice-luma_studio_long 1:72534f9863ad#2 -0.13 0.02 planning,coordination&client
invoice-luma_studio_long 1:ad05c4b80bfe -0.38 0.02 liaison.phase2.
-invoice-luma_studio_long 1:7b420577e50f 0.17 -0.13 £1,200.00
-invoice-luma_studio_long 1:7b420577e50f#1 0.17 -0.31 £1,200.00
-invoice-luma_studio_long 1:7b420577e50f#2 0.35 -0.13 £1,200.00
-invoice-luma_studio_long 1:7b420577e50f#3 0.35 -0.31 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f 0.05 -0.13 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f#1 0.05 -0.31 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f#2 0.23 -0.13 £1,200.00
+invoice-luma_studio_long 1:7b420577e50f#3 0.23 -0.31 £1,200.00
invoice-luma_studio_long 1:d8f4e3a46d2d 0.24 0.02 brandstrategyworkshop
invoice-luma_studio_long 1:d8f4e3a46d2d#1 0.28 0.02 brandstrategyworkshop
invoice-luma_studio_long 1:be624d692600 -0.30 0.02 discoverysession,research&brand
invoice-luma_studio_long 1:be624d692600#1 -0.24 0.02 discoverysession,research&brand
invoice-luma_studio_long 1:b95a82d5055c -0.33 0.02 positioning.phase3.
-invoice-luma_studio_long 1:07bcffc86e95 0.22 -0.13 £2,650.00
-invoice-luma_studio_long 1:07bcffc86e95#1 0.22 -0.31 £2,650.00
-invoice-luma_studio_long 1:07bcffc86e95#2 0.28 -0.13 £2,650.00
-invoice-luma_studio_long 1:07bcffc86e95#3 0.28 -0.31 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95 0.10 -0.13 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95#1 0.10 -0.31 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95#2 0.16 -0.13 £2,650.00
+invoice-luma_studio_long 1:07bcffc86e95#3 0.16 -0.31 £2,650.00
invoice-luma_studio_long 1:e302dd04d528 0.17 0.02 visualidentitydesign
invoice-luma_studio_long 1:e302dd04d528#1 0.35 0.02 visualidentitydesign
invoice-luma_studio_long 1:40e412f627fb -0.37 0.02 logosuite,colourpalette,typograp
invoice-luma_studio_long 1:40e412f627fb#1 -0.19 0.02 logosuite,colourpalette,typograp
invoice-luma_studio_long 1:708bc0c2e87c -0.28 0.02 &guidelines.phase3.
-invoice-luma_studio_long 1:a20d66947acc 0.30 -0.13 £3,200.00
-invoice-luma_studio_long 1:a20d66947acc#1 0.30 -0.31 £3,200.00
-invoice-luma_studio_long 1:a20d66947acc#2 0.33 -0.13 £3,200.00
-invoice-luma_studio_long 1:a20d66947acc#3 0.33 -0.31 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc 0.18 -0.13 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc#1 0.18 -0.31 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc#2 0.21 -0.13 £3,200.00
+invoice-luma_studio_long 1:a20d66947acc#3 0.21 -0.31 £3,200.00
invoice-luma_studio_long 1:b25ac9cd66bd 0.25 0.02 websitedesign(upto8pages)
invoice-luma_studio_long 1:b25ac9cd66bd#1 0.28 0.02 websitedesign(upto8pages)
invoice-luma_studio_long 1:dab758788e6f -0.29 0.02 ux/uidesignfordesktopandmobile.
@@ -4149,7 +4149,7 @@ rota-cobalt 0:cc96b22615e5 0.32 0.09 pianist19:00
rota-cobalt 0:ffe6874516db 0.32 0.17 mc10pax/pianist19:00mc2pax//60pa
rota-cobalt 0:cd49e19a7e3e -0.07 0.21 /ffexhire/cleancubed
rota-cobalt 0:672c5d949aee -0.10 0.26 icemachine&area
-rota-cobalt 0:bc14a4ce6407 0.20 0.71 statuslegend
+rota-cobalt 0:bc14a4ce6407 0.08 0.71 statuslegend
rota-cobalt 0:1f58b9145b24 0.09 0.38 request
rota-cobalt 0:b4dc66dde806 0.09 0.08 off
rota-cobalt 0:b4dc66dde806#1 0.05 -0.19 off
@@ -4192,7 +4192,7 @@ rota-cobalt 0:08c4242b5f58 0.09 0.21 stock
rota-cobalt 0:f8f8922da592 0.33 0.37 standby
rota-cobalt 0:c2fb788c7dee 0.33 0.36 training
rota-cobalt 0:a18603086e5b 0.09 0.43 support
-rota-cobalt 0:17ae69cc7e46 0.30 -0.11 covers
+rota-cobalt 0:17ae69cc7e46 0.06 -0.11 covers
rota-cobalt 0:412a5c513322 0.28 0.09 l104/d50
rota-cobalt 0:304560bf3205 0.28 -0.01 l33/d57
rota-cobalt 0:ca6ce47ada53 0.28 0.05 l32/d76
@@ -4201,7 +4201,7 @@ rota-cobalt 0:7062fafe1dab 0.28 0.11 l65/d112
rota-cobalt 0:778cb57b62fa 0.28 0.16 l73/d112
rota-cobalt 0:1b4471920f01 0.28 0.19 l72/d83
rota-cobalt 0:288965a1f2c8 0.19 -0.03 management
-rota-cobalt 0:6a61eb7383e8 0.30 0.17 rosa
+rota-cobalt 0:6a61eb7383e8 0.06 0.17 rosa
rota-cobalt 0:3bb0f0bb5552 0.05 -0.35 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#1 0.08 -0.33 09:00-18:00
rota-cobalt 0:3bb0f0bb5552#2 0.08 -0.25 09:00-18:00
@@ -4229,7 +4229,7 @@ rota-cobalt 0:e669544e9895 0.20 0.40 08:00-16:00
rota-cobalt 0:e669544e9895#1 0.22 0.38 08:00-16:00
rota-cobalt 0:19c30663e8ef 0.26 1.24 16:00-21:00
rota-cobalt 0:19c30663e8ef#1 0.40 1.22 16:00-21:00
-rota-cobalt 0:c9affe7f7587 0.32 0.29 tomas
+rota-cobalt 0:c9affe7f7587 0.08 0.29 tomas
rota-cobalt 0:6d43df86fe4b 0.06 -0.35 16:00-00:00
rota-cobalt 0:6d43df86fe4b#1 0.06 -0.33 16:00-00:00
rota-cobalt 0:6d43df86fe4b#2 0.08 -0.39 16:00-00:00
@@ -4246,7 +4246,7 @@ rota-cobalt 0:6d43df86fe4b#12 0.16 -0.39 16:00-00:00
rota-cobalt 0:6d43df86fe4b#13 0.25 0.30 16:00-00:00
rota-cobalt 0:5a5f719aae11 0.10 0.32 11:00-16:00
rota-cobalt 0:df9ba122242c 0.25 -0.03 bartenders
-rota-cobalt 0:dbaf86dd80e3 0.34 0.30 nadia
+rota-cobalt 0:dbaf86dd80e3 0.10 0.30 nadia
rota-cobalt 0:0cdd9d9e75dc 0.12 0.30 09:00-16:00
rota-cobalt 0:0cdd9d9e75dc#1 0.14 0.38 09:00-16:00
rota-cobalt 0:0cdd9d9e75dc#2 0.15 0.32 09:00-16:00
@@ -4256,7 +4256,7 @@ rota-cobalt 0:f1e2ce9aa575#1 0.31 1.16 16:00-22:00
rota-cobalt 0:f1e2ce9aa575#2 0.31 1.22 16:00-22:00
rota-cobalt 0:f1e2ce9aa575#3 0.35 1.16 16:00-22:00
rota-cobalt 0:f1e2ce9aa575#4 0.35 1.22 16:00-22:00
-rota-cobalt 0:de7ee76e7373 0.35 0.20 elias
+rota-cobalt 0:de7ee76e7373 0.11 0.20 elias
rota-cobalt 0:61587c4c0d6d 0.10 -0.33 09:00-17:00
rota-cobalt 0:7195a8ed72b9 0.14 0.32 12:00-16:00
rota-cobalt 0:7195a8ed72b9#1 0.19 0.30 12:00-16:00
@@ -4268,16 +4268,16 @@ rota-cobalt 0:7195a8ed72b9#6 0.31 0.38 12:00-16:00
rota-cobalt 0:7195a8ed72b9#7 0.31 0.40 12:00-16:00
rota-cobalt 0:7195a8ed72b9#8 0.20 0.30 12:00-16:00
rota-cobalt 0:7195a8ed72b9#9 0.20 0.30 12:00-16:00
-rota-cobalt 0:953cb9ba8128 0.39 0.33 mirela
+rota-cobalt 0:953cb9ba8128 0.15 0.33 mirela
rota-cobalt 0:c1298a6e85c2 0.14 -0.35 12:00-18:00
rota-cobalt 0:c1298a6e85c2#1 0.17 -0.39 12:00-18:00
-rota-cobalt 0:86846e84f9bf 0.41 0.20 sofia
-rota-cobalt 0:effcc54ba75f 0.42 0.31 karol
-rota-cobalt 0:5c1e134d2d34 0.44 0.33 henrik
-rota-cobalt 0:6326e0e8cfda 0.34 0.19 lucia
+rota-cobalt 0:86846e84f9bf 0.17 0.20 sofia
+rota-cobalt 0:effcc54ba75f 0.18 0.31 karol
+rota-cobalt 0:5c1e134d2d34 0.20 0.33 henrik
+rota-cobalt 0:6326e0e8cfda 0.10 0.19 lucia
rota-cobalt 0:c235e502637c 0.39 -0.03 barbacks
-rota-cobalt 0:bd558de5f794 0.39 0.22 oskar
-rota-cobalt 0:4941d6993ee9 0.40 0.29 teodor
-rota-cobalt 0:1d3d3d6a07bd 0.42 0.32 vesna
+rota-cobalt 0:bd558de5f794 0.15 0.22 oskar
+rota-cobalt 0:4941d6993ee9 0.16 0.29 teodor
+rota-cobalt 0:1d3d3d6a07bd 0.18 0.32 vesna
rota-cobalt 0:59f2ff7c7c70 0.08 -0.06 timesshownin24-hourformat.shiftc
rota-cobalt 0:594c41ec0506 0.08 -3.62 quaysidebar|31aug-6sep2026
diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv
index da47486e7..b2dba33ca 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv
@@ -30,8 +30,8 @@ invoice-classic 1 1 56 56 0.10 0.36 0
invoice-classic_long 2 2 166 166 0.05 0.20 0
invoice-consulting 1 1 92 92 0.38 1.02 0
invoice-consulting_long 2 2 236 236 0.40 0.76 0
-invoice-luma_studio 1 1 97 97 0.14 0.41 0
-invoice-luma_studio_long 3 3 192 192 0.21 0.38 0
+invoice-luma_studio 1 1 97 97 0.11 0.41 0
+invoice-luma_studio_long 3 3 192 192 0.17 0.37 0
invoice-merchant 1 1 94 94 0.13 0.22 0
invoice-metered 1 1 99 99 0.09 0.69 0
invoice-modern 1 1 54 54 0.11 0.26 0
@@ -61,4 +61,4 @@ proposal-indigo 1 1 72 70 0.40 0.62 0
proposal-modern 1 1 44 44 0.05 0.20 0
proposal-northline 2 2 132 132 0.20 0.43 0
receipt-modern 1 1 57 57 0.21 0.39 0
-rota-cobalt 1 1 154 154 0.17 0.38 0
+rota-cobalt 1 1 154 154 0.15 0.31 0
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java
index 04593c530..0f75e5009 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxInk.java
@@ -3,6 +3,7 @@
import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphSpan;
import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
+import com.demcha.compose.engine.components.content.text.TextStyle;
import com.demcha.compose.engine.render.pdf.PdfFont;
import com.demcha.compose.font.FontLibrary;
import org.apache.pdfbox.pdmodel.font.PDFont;
@@ -42,34 +43,55 @@ static double[] of(ParagraphLine line, FontLibrary fonts) {
if (!(span instanceof ParagraphTextSpan text)) {
return null;
}
- PdfFont font = fonts.getFont(text.textStyle().fontName(), PdfFont.class).orElse(null);
- if (font == null) {
+ double[] reach = of(text.text(), text.textStyle(), fonts);
+ if (reach == null) {
return null;
}
- PDFont face = font.fontType(text.textStyle().decoration());
- if (!(face instanceof PDVectorFont outlines)) {
- return null;
+ above = Math.max(above, reach[0]);
+ below = Math.max(below, reach[1]);
+ }
+ return new double[]{above, below};
+ }
+
+ /**
+ * The reach of the letters of a text set in one style, in points: {@code {above, below}} its
+ * baseline, each at least 0; {@code null} when the font is not known or a glyph has no outline
+ * to read.
+ *
+ * @param text the text
+ * @param style the style it is set in
+ * @param fonts the fonts the layout measured it with
+ */
+ static double[] of(String text, TextStyle style, FontLibrary fonts) {
+ PdfFont font = fonts.getFont(style.fontName(), PdfFont.class).orElse(null);
+ if (font == null) {
+ return null;
+ }
+ PDFont face = font.fontType(style.decoration());
+ if (!(face instanceof PDVectorFont outlines)) {
+ return null;
+ }
+ double above = 0;
+ double below = 0;
+ double scale = style.size() / 1000.0;
+ String shown = font.sanitizeForRender(style, text);
+ for (int i = 0; i < shown.length(); ) {
+ int codePoint = shown.codePointAt(i);
+ i += Character.charCount(codePoint);
+ if (Character.isWhitespace(codePoint)) {
+ continue;
}
- double scale = text.textStyle().size() / 1000.0;
- String shown = font.sanitizeForRender(text.textStyle(), text.text());
- for (int i = 0; i < shown.length(); ) {
- int codePoint = shown.codePointAt(i);
- i += Character.charCount(codePoint);
- if (Character.isWhitespace(codePoint)) {
+ try {
+ byte[] bytes = face.encode(new String(Character.toChars(codePoint)));
+ int code = face.readCode(new ByteArrayInputStream(bytes));
+ Rectangle2D bounds = outlines.getNormalizedPath(code).getBounds2D();
+ if (bounds.isEmpty()) {
continue;
}
- try {
- byte[] bytes = face.encode(new String(Character.toChars(codePoint)));
- int code = face.readCode(new ByteArrayInputStream(bytes));
- Rectangle2D bounds = outlines.getNormalizedPath(code).getBounds2D();
- if (bounds.isEmpty()) {
- continue;
- }
- above = Math.max(above, bounds.getMaxY() * scale);
- below = Math.max(below, -bounds.getMinY() * scale);
- } catch (IOException | IllegalArgumentException unreadable) {
- return null;
- }
+ above = Math.max(above, bounds.getMaxY() * scale);
+ below = Math.max(below, -bounds.getMinY() * scale);
+ } catch (IOException | IllegalArgumentException unreadable) {
+ return null;
}
}
return new double[]{above, below};
@@ -87,14 +109,22 @@ static double[] of(ParagraphLine line, FontLibrary fonts) {
*/
static boolean readInTheLayoutsUnits(ParagraphLine line, FontLibrary fonts) {
for (ParagraphSpan span : line.spans()) {
- if (span instanceof ParagraphTextSpan text) {
- PdfFont font = fonts.getFont(text.textStyle().fontName(), PdfFont.class).orElse(null);
- if (font != null && font.fontType(text.textStyle().decoration()) instanceof PDType1Font type1
- && !type1.isEmbedded()) {
- return false;
- }
+ if (span instanceof ParagraphTextSpan text && !readInTheLayoutsUnits(text.textStyle(), fonts)) {
+ return false;
}
}
return true;
}
+
+ /**
+ * Whether the letters of a text set in one style are read in the units the layout measured
+ * them in ({@link #readInTheLayoutsUnits(ParagraphLine, FontLibrary)}).
+ *
+ * @param style the style the text is set in
+ * @param fonts the fonts the layout measured it with
+ */
+ static boolean readInTheLayoutsUnits(TextStyle style, FontLibrary fonts) {
+ PdfFont font = fonts.getFont(style.fontName(), PdfFont.class).orElse(null);
+ return font == null || !(font.fontType(style.decoration()) instanceof PDType1Font type1) || type1.isEmbedded();
+ }
}
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 73bcfe35a..e869b78fe 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
@@ -197,8 +197,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
private static final double LEAST_RAISE_INTO_THE_SPACE_ABOVE = 0.1;
/**
* The least a row of text cells is moved by its margins, in points ({@link #moveTheRowsText}),
- * for the same reason: Word sets lines on its 0.12pt grid, and a twip of margin moves a line a
- * step or nothing.
+ * and a cell by the height of its lines ({@link #seatByTheLines}), for the same reason: Word
+ * sets lines on its 0.12pt grid, and a twip of margin moves a line a step or nothing.
*/
private static final double LEAST_ROW_TEXT_MOVE = 0.1;
/** How a drawing the page paints is anchored, as the report says it (see DocxDrawingAnchors). */
@@ -333,6 +333,10 @@ public final class DocxSemanticBackend implements SemanticBackend {
// see moveTheRowsText.
private final java.util.Map textCellRaises =
new java.util.IdentityHashMap<>();
+ // Those cells as the layout measured them, whose letters bound how far their lines are moved by
+ // their height (seatByTheLines). Taken out with them: see moveTheRowsText.
+ private final java.util.Map textCellsMeasured =
+ new java.util.IdentityHashMap<>();
// The paragraph whose lines last fell short of the page and what was owed below it for that:
// see settleTheLinesWithThePage, takeBackTheRoundingOwedBy.
private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP roundingOwedBy;
@@ -838,6 +842,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
lineTopsTakenIn.clear();
cellsGivingTheirPadding.clear();
textCellRaises.clear();
+ textCellsMeasured.clear();
roundingOwedBy = null;
roundingOwed = 0;
risenLines.clear();
@@ -12468,11 +12473,40 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement,
if (lines.stream().allMatch(line -> line == null || line.isBlank())) {
textCellRaises.put(cell.getCTTc(), Double.NaN);
} else if (lineHeight.isPresent()) {
- double below = belowThePage(layout.measuredCell(node, placement.row(), placement.column()),
- cell.getParagraphs().get(0));
+ TableResolvedCell measured = layout.measuredCell(node, placement.row(), placement.column());
+ double below = belowThePage(measured, cell.getParagraphs().get(0));
if (!Double.isNaN(below)) {
textCellRaises.put(cell.getCTTc(), below);
+ textCellsMeasured.put(cell.getCTTc(), measured);
+ }
+ }
+ }
+
+ /**
+ * How far a text cell's letters reach above and below their baseline, in points
+ * ({@link DocxInk#of(String, TextStyle, FontLibrary)}), or {@code null} where they are not read:
+ * a face the measured fonts do not hold, or a standard face the export does not embed, whose
+ * letters are read through a stand-in ({@link DocxInk#readInTheLayoutsUnits}).
+ */
+ private double[] inkOf(TableResolvedCell measured) {
+ TextStyle face = measured.style().textStyle();
+ try {
+ if (!DocxInk.readInTheLayoutsUnits(face, measuredFonts())) {
+ return null;
}
+ double above = 0;
+ double below = 0;
+ for (String line : measured.lines()) {
+ double[] reach = DocxInk.of(line == null ? "" : line, face, measuredFonts());
+ if (reach == null) {
+ return null;
+ }
+ above = Math.max(above, reach[0]);
+ below = Math.max(below, reach[1]);
+ }
+ return new double[]{above, below};
+ } catch (RuntimeException unknownFace) {
+ return null;
}
}
@@ -12540,7 +12574,12 @@ private double belowThePage(TableResolvedCell measured, XWPFParagraph first) {
* of Lato, the two called opposite ways, stood 0.58pt low. The position counts in half points,
* and LibreOffice moves text further than it is raised, by up to half as much again
* ({@link #raiseIntoTheSpaceAbove}); from half a point up that overshoot is less than what is
- * left, and under it more, so less is left as it is.
+ * left, and under it more. Less, from a tenth of a point, is moved by the height of the cell's
+ * lines, traded for the space after them ({@link #seatByTheLines}), which both editors move it
+ * by as written. Half a point and more is not: {@code ConsultingInvoice}'s amounts, so moved,
+ * stood nearer the page in their rows in LibreOffice, but further from it on the page, the
+ * position's overshoot no longer making up for their rows standing 0.3pt low there
+ * (measured).
*/
private void moveTheRowsText(XWPFTableRow row) {
List cells = row.getTableCells();
@@ -12572,11 +12611,73 @@ private void moveTheRowsText(XWPFTableRow row) {
}
for (int i = 0; i < cells.size(); i++) {
double left = calls[i] - move / POINT_TO_TWIP;
+ TableResolvedCell measured = textCellsMeasured.remove(cells.get(i).getCTTc());
if (Math.abs(left) >= LEAST_BASELINE_SHIFT_POINTS) {
for (XWPFParagraph para : cells.get(i).getParagraphs()) {
raiseRunsFrom(para, 0, Math.round(left * HALF_POINTS_PER_POINT));
}
+ } else if (measured != null && Math.abs(left) >= LEAST_ROW_TEXT_MOVE) {
+ double[] ink = inkOf(measured);
+ if (ink != null) {
+ seatByTheLines(cells.get(i), left, ink);
+ }
+ }
+ }
+ }
+
+ /**
+ * Moves a text cell's lines by trading the height of each for the space after it, as far as
+ * its letters and that space allow.
+ *
+ * Word and LibreOffice stand an exact line's baseline four fifths of the way down it
+ * ({@link DocxTextBands#BASELINE_SHARE}). Each line written shorter, the space after it taller
+ * by as much, keeps the cell's content as tall and each line's top where it was, and stands the
+ * text four fifths of the difference higher; each written taller, the space after it shorter,
+ * that much lower. {@code CobaltRota}'s staff names, beside the day
+ * cells its chips are composed in, stood 0.25pt low in their rows in Word, 0.2pt in
+ * LibreOffice on Windows and 0.19pt in LibreOffice 24.2; written so, they stood within 0.08pt of
+ * the page's in all three, every row its height (measured). The line alone written shorter
+ * keeps the row its height in Word and on Windows, but LibreOffice 24.2 holds a row to its
+ * cells' content, the margins inside its height: the covers row, whose value cells' lines were
+ * its tallest content, came out 0.8pt shorter there.
+ *
+ * Only a cell whose every paragraph is one exact line, no line broken in it; a line is cut
+ * no nearer its letters than {@link DocxStackedLines#INK_MARGIN} at its top and its foot, as
+ * Word cuts an exact line's letters there on screen, and one whose letters already stand nearer
+ * is not cut. A line made taller takes no more than the space after each holds. What neither
+ * allows is left as it is.
+ *
+ * @param low how far Word sets the cell's lines below the page's, in points; negative where it
+ * sets them higher
+ * @param ink how far the cell's letters reach above and below their baseline, in points
+ */
+ private static void seatByTheLines(XWPFTableCell cell, double low, double[] ink) {
+ List lines = new ArrayList<>();
+ // The least line that keeps the letters clear of its top and its foot, its baseline four
+ // fifths of the way down it.
+ long least = (long) Math.ceil(POINT_TO_TWIP * Math.max(
+ (ink[0] + DocxStackedLines.INK_MARGIN) / DocxTextBands.BASELINE_SHARE,
+ (ink[1] + DocxStackedLines.INK_MARGIN) / (1 - DocxTextBands.BASELINE_SHARE)));
+ long most = Long.MAX_VALUE;
+ for (XWPFParagraph para : cell.getParagraphs()) {
+ CTPPr properties = para.getCTP().getPPr();
+ CTSpacing spacing = properties != null && properties.isSetSpacing() ? properties.getSpacing() : null;
+ Long line = spacing != null && spacing.isSetLineRule() && spacing.getLineRule() == STLineSpacingRule.EXACT
+ ? writtenTwips(spacing.getLine()) : null;
+ if (line == null || para.getRuns().stream().anyMatch(run -> run.getCTR().sizeOfBrArray() > 0)) {
+ return;
}
+ most = Math.min(most, low > 0 ? line - least : twipsOf(spacing.isSetAfter() ? spacing.getAfter() : null));
+ lines.add(spacing);
+ }
+ long twips = Math.min(Math.round(Math.abs(low) * POINT_TO_TWIP / DocxTextBands.BASELINE_SHARE), most);
+ if (lines.isEmpty() || twips <= 0) {
+ return;
+ }
+ long cut = low > 0 ? twips : -twips;
+ for (CTSpacing spacing : lines) {
+ spacing.setLine(BigInteger.valueOf(writtenTwips(spacing.getLine()) - cut));
+ spacing.setAfter(BigInteger.valueOf(twipsOf(spacing.isSetAfter() ? spacing.getAfter() : null) + cut));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java
index 805d3df36..770b86566 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java
@@ -11,14 +11,17 @@
import com.demcha.compose.document.table.DocumentTableTextAnchor;
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.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
/**
* A table text cell's lines stand on the page's baselines in Word, through the row's margins.
@@ -154,7 +157,9 @@ void aRowWhoseCellsCallForOppositeWaysIsLeftAsItWas() throws Exception {
}
assertThat(poppins).as("the premise: half a point and more").isGreaterThanOrEqualTo(0.5);
assertThat(position(row.getCell(0))).isEqualTo(Math.round(poppins * 2));
+ assertThat(after(row.getCell(0))).as("its line as it was").isZero();
assertThat(position(row.getCell(1))).as("Lato's quarter point is left").isZero();
+ assertThat(after(row.getCell(1))).as("with no space after its line to take").isZero();
}
}
@@ -197,15 +202,107 @@ void aRowHoldingAComposedCellIsLeftAsItWas() throws Exception {
// The composed paragraph is seated by its own writer; the margins would move it again.
try (XWPFDocument document = export(page -> page.addTable(t -> t
.columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
- .rowCells(cell("Text", poppins(8), DocumentTableTextAnchor.CENTER),
- DocumentTableCell.node(new ParagraphBuilder().text("Composed").textStyle(poppins(8))
- .margin(DocumentInsets.zero()).build()).withStyle(style(poppins(8),
- DocumentTableTextAnchor.CENTER)))))) {
+ .rowCells(cell("Text", poppins(8), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
assertThat(margins(text)).containsExactly(PADDING, PADDING);
assertThat(position(text)).as("the text cell moved by its position instead")
.isEqualTo(Math.round((0.8 * line(text) - POPPINS_ASCENT * 8) * 2));
+ assertThat(after(text)).as("its line as it was").isZero();
+ }
+ }
+
+ @Test
+ void aCellCallingForUnderHalfAPointTradesItsLineForTheSpaceAfterIt() throws Exception {
+ // Poppins at 6pt stands 0.42pt low in Word: under a half point, no position moves it.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(cell("Text", poppins(6), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
+ XWPFTableRow row = document.getTables().get(0).getRow(0);
+ XWPFTableCell text = row.getCell(0);
+ double low = 0.8 * written(text) - POPPINS_ASCENT * 6;
+
+ assertThat(low).as("the premise: a tenth of a point and more, under a half").isBetween(0.1, 0.5);
+ assertThat(margins(text)).containsExactly(PADDING, PADDING);
+ assertThat(after(text)).as("its line traded for the space after it, the cell as tall")
+ .isEqualTo(Math.round(low * 20 / 0.8)).isPositive();
+ assertThat(0.8 * line(text) - POPPINS_ASCENT * 6).as("the baseline at the page's")
+ .isCloseTo(0, within(0.02));
+ assertThat(position(text)).isZero();
+ assertThat(after(row.getCell(1))).as("the composed paragraph's line is its own").isZero();
+ }
+ }
+
+ @Test
+ void aCellWhoseLettersReachNearItsLinesFootIsLeftAsItWas() throws Exception {
+ // Poppins' descenders stand nearer the foot of its line than Word's cut leaves them.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(cell("gypsy", poppins(6), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
+ XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
+
+ assertThat(0.8 * line(text) - POPPINS_ASCENT * 6).as("the premise").isBetween(0.1, 0.5);
+ assertThat(after(text)).as("its line as it was").isZero();
+ assertThat(position(text)).isZero();
+ }
+ }
+
+ @Test
+ void aCellOfLinesBrokenInOneParagraphIsLeftAsItWas() throws Exception {
+ // A taller or shorter line would move its second line from the first.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.lines("One", "Two").withStyle(style(poppins(6), DocumentTableTextAnchor.CENTER)),
+ composed("Composed"))))) {
+ XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
+
+ assertThat(text.getParagraphs()).as("the premise: one paragraph, its lines broken").hasSize(1);
+ assertThat(0.8 * line(text) - POPPINS_ASCENT * 6).as("the premise").isBetween(0.1, 0.5);
+ assertThat(after(text)).isZero();
+ }
+ }
+
+ @Test
+ void aCellOfSeveralLinesTradesEachForTheSpaceAfterIt() throws Exception {
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.lines("One", "Two").withStyle(DocumentTableStyle.builder()
+ .padding(DocumentInsets.of(4)).stroke(DocumentStroke.of(DocumentColor.BLACK, 0))
+ .textStyle(poppins(6)).textAnchor(DocumentTableTextAnchor.CENTER).lineSpacing(2).build()),
+ composed("Composed"))))) {
+ XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph first = text.getParagraphs().get(0);
+ XWPFParagraph second = text.getParagraphs().get(1);
+ // The last line has no space after it of its own: what it has is its cut.
+ long cut = afterOf(second);
+ double low = 0.8 * (lineOf(second) + cut) / 20.0 - POPPINS_ASCENT * 6;
+
+ assertThat(cut).isEqualTo(Math.round(low * 20 / 0.8)).isPositive();
+ assertThat(lineOf(first)).as("each line cut alike").isEqualTo(lineOf(second));
+ assertThat(afterOf(first)).as("the space between the lines, and the cut").isEqualTo(40 + cut);
+ }
+ }
+
+ @Test
+ void aCellSetHighTakesItsTallerLineFromTheSpaceAfterIt() throws Exception {
+ // Padded more than the composed cell beside it, its padding past the row's margins is
+ // written above and below its line, and the line takes from what is below it.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.text("Lato").withStyle(DocumentTableStyle.builder()
+ .padding(DocumentInsets.of(6)).stroke(DocumentStroke.of(DocumentColor.BLACK, 0))
+ .textStyle(lato(8)).textAnchor(DocumentTableTextAnchor.CENTER).build()),
+ composed("Composed"))))) {
+ XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
+ CTSpacing spacing = text.getParagraphs().get(0).getCTP().getPPr().getSpacing();
+ long before = DocxTwips.of(spacing.getBefore());
+ long grown = before - after(text);
+ double high = 0.8 * (line(text) - grown / 20.0) - LATO_ASCENT * 8;
+
+ assertThat(before).as("the premise: the padding past the row's margins").isEqualTo(40);
+ assertThat(high).as("the premise: Word sets Lato high").isNegative();
+ assertThat(grown).as("its line as much taller as the space after it shorter")
+ .isEqualTo(Math.round(-high * 20 / 0.8));
}
}
@@ -264,10 +361,85 @@ void aMoveUnderATenthOfAPointIsLeftAsItWas() throws Exception {
}
}
+ @Test
+ void aCallUnderATenthOfAPointLeavesTheLineAsItWas() throws Exception {
+ // Beside a composed cell, as above: a twip of line moves a line a step of Word's grid or
+ // nothing. Lato at 3pt stands high by a twip and more, padded so as to have space after it.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.text("Lato").withStyle(DocumentTableStyle.builder()
+ .padding(DocumentInsets.of(6)).stroke(DocumentStroke.of(DocumentColor.BLACK, 0))
+ .textStyle(lato(3)).textAnchor(DocumentTableTextAnchor.CENTER).build()),
+ composed("Composed"))))) {
+ XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
+
+ assertThat(0.8 * line(cell) - LATO_ASCENT * 3).as("the premise: a twip and more, under a tenth")
+ .isBetween(-0.1, -0.025);
+ assertThat(after(cell)).as("the space after it as its padding gives it").isEqualTo(40);
+ }
+ }
+
+ @Test
+ void aCellInAStandardFaceTheExportDoesNotEmbedKeepsItsLine() throws Exception {
+ // Its letters are read through a font standing in for it, in that font's units: not known.
+ // Hyphens, low enough that read so they would leave the line room to cut.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(cell("---", helvetica(14), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
+ XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
+
+ assertThat(0.8 * line(cell) - HELVETICA_ASCENT * 14).as("the premise: a tenth of a point and more, under a half")
+ .isBetween(0.1, 0.5);
+ assertThat(after(cell)).isZero();
+ }
+ }
+
+ @Test
+ void whatTheMarginsLeaveUnderHalfAPointIsMovedByTheLine() throws Exception {
+ // The row moves by Helvetica's 6 twips at 14pt; Poppins at 6pt calls for more.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(cell("Text", poppins(6), DocumentTableTextAnchor.CENTER),
+ cell("LARGE", helvetica(14), DocumentTableTextAnchor.CENTER))))) {
+ XWPFTableRow row = document.getTables().get(0).getRow(0);
+ double left = 0.8 * written(row.getCell(0)) - POPPINS_ASCENT * 6 - DocxExports.DEFAULT_LINE_RAISE / 20.0;
+
+ for (XWPFTableCell cell : row.getTableCells()) {
+ assertThat(margins(cell)).containsExactly(PADDING - DocxExports.DEFAULT_LINE_RAISE,
+ PADDING + DocxExports.DEFAULT_LINE_RAISE);
+ }
+ assertThat(left).as("the premise: a tenth of a point and more left").isBetween(0.1, 0.5);
+ assertThat(after(row.getCell(0))).isEqualTo(Math.round(left * 20 / 0.8)).isPositive();
+ assertThat(after(row.getCell(1))).as("nothing left of Helvetica's").isZero();
+ }
+ }
+
+ @Test
+ void aLineIsCutNoNearerTheTopsOfItsLettersThanItsFoot() throws Exception {
+ // Poppins' ring above its capital A reaches 0.96 of an em: cut by all its call, the line
+ // would stand the ring closer to its top than Word keeps letters on screen.
+ try (XWPFDocument document = export(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(cell("ÅÅ", poppins(6), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
+ XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
+ double low = 0.8 * written(text) - POPPINS_ASCENT * 6;
+
+ assertThat(low).as("the premise").isBetween(0.1, 0.5);
+ assertThat(after(text)).as("cut, but by less than its call").isPositive()
+ .isLessThan(Math.round(low * 20 / 0.8));
+ }
+ }
+
private static DocumentTableCell cell(String text, DocumentTextStyle face, DocumentTableTextAnchor anchor) {
return DocumentTableCell.text(text).withStyle(style(face, anchor));
}
+ /** A cell composed from a paragraph, in Poppins as the text cells beside it. */
+ private static DocumentTableCell composed(String text) {
+ return DocumentTableCell.node(new ParagraphBuilder().text(text).textStyle(poppins(8))
+ .margin(DocumentInsets.zero()).build()).withStyle(style(poppins(8), DocumentTableTextAnchor.CENTER));
+ }
+
private static DocumentTableStyle style(DocumentTextStyle face, DocumentTableTextAnchor anchor) {
return DocumentTableStyle.builder().padding(DocumentInsets.of(4)).stroke(DocumentStroke.of(DocumentColor.BLACK, 0))
.textStyle(face).textAnchor(anchor).build();
@@ -290,6 +462,28 @@ private static double line(XWPFTableCell cell) {
return DocxTwips.of(cell.getParagraphs().get(0).getCTP().getPPr().getSpacing().getLine()) / 20.0;
}
+ /**
+ * The exact line a cell's first paragraph stood at before it was traded for the space after
+ * it, in points: for a cell with no space after its line of its own.
+ */
+ private static double written(XWPFTableCell cell) {
+ return line(cell) + after(cell) / 20.0;
+ }
+
+ /** The space after a cell's first paragraph, in twips; 0 for none. */
+ private static long after(XWPFTableCell cell) {
+ return afterOf(cell.getParagraphs().get(0));
+ }
+
+ private static long afterOf(XWPFParagraph paragraph) {
+ CTSpacing spacing = paragraph.getCTP().getPPr().getSpacing();
+ return spacing.isSetAfter() ? DocxTwips.of(spacing.getAfter()) : 0;
+ }
+
+ private static long lineOf(XWPFParagraph paragraph) {
+ return DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine());
+ }
+
/** The {@code w:position} of a cell's first run, in half points; 0 for none. */
private static long position(XWPFTableCell cell) {
var properties = cell.getParagraphs().get(0).getCTP().getRArray(0).getRPr();
From fa3a5901e4d1765003a5b8d60b81781ff727c515 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 11 Oct 2026 21:05:40 +0100
Subject: [PATCH 2/2] docs(docx): name the probe behind the covers row and the
lines that move further
- The covers row that came out 0.8pt shorter in LibreOffice 24.2 was a
probe writing the lines alone shorter, the covers values' composed
lines among them; the CHANGELOG entry and seatByTheLines say so.
- The CHANGELOG entry counts its corpus at the median, and names the
lines that move further: 21 amounts of LumaStudioInvoice and its long
version in LibreOffice on Windows, from 0.07-0.09pt low to
0.11-0.13pt high, and 6 in Word, a step of its grid.
- DocxCellSeatTest: the class Javadoc names the position and the line
trade, the premises read the line as written, and the top-guard test
is renamed.
---
CHANGELOG.md | 13 ++++++++-----
.../semantic/docx/DocxSemanticBackend.java | 9 +++++----
.../backend/semantic/docx/DocxCellSeatTest.java | 15 +++++++++------
3 files changed, 22 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7699c23b3..568c15476 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,9 +23,10 @@ follow semantic versioning; release dates are ISO 8601.
moves four fifths of the difference in both editors. Only where every paragraph of the cell is
one line, in a face whose letters the export reads — not a standard face it does not embed — and
no nearer the letters' tops and feet than three quarters of a point, where Word cuts an exact
- line's letters on screen. The line alone written shorter keeps a row its height
- in Word and on Windows, but LibreOffice 24.2 holds a row to its cells' content: the covers row,
- whose value cells' lines were its tallest content, came out 0.8pt shorter there (measured).
+ line's letters on screen. A line alone written shorter keeps a row its height in Word and on
+ Windows, but LibreOffice 24.2 holds a row to its cells' content: written so on a probe, the
+ covers values' lines among them, the covers row, whose tallest content those lines were, came
+ out 0.8pt shorter there (measured).
From half a point up the position moves it as before: written by the line instead,
`ConsultingInvoice`'s amounts stood nearer the page in their rows in LibreOffice, where the
position moves text further than it says, and the rows themselves stand 0.3pt low there, which
@@ -36,8 +37,10 @@ follow semantic versioning; release dates are ISO 8601.
median moves from 0.17pt to 0.15pt in Word, its p90 from 0.38pt to 0.31pt, and from 0.08pt to
0.06pt in LibreOffice on Windows, its p90 from 0.30pt to 0.22pt; `LumaStudioInvoice`'s from
0.14pt to 0.11pt in Word, its long version's from 0.21pt to 0.17pt in Word and from 0.20pt to
- 0.12pt in LibreOffice on Windows. No document of the fidelity corpus moves further in Word or in
- LibreOffice on Windows. LibreOffice 24.2 sets each of `CobaltRota`'s staff rows a twip shorter
+ 0.12pt in LibreOffice on Windows. No document's median moves further in Word or in LibreOffice
+ on Windows. Line by line, 21 amounts of `LumaStudioInvoice` and its long version, 0.07pt to
+ 0.09pt low in LibreOffice on Windows, stand 0.11pt to 0.13pt high there, and 6, 0.04pt low in
+ Word, 0.08pt high, a step of its grid. LibreOffice 24.2 sets each of `CobaltRota`'s staff rows a twip shorter
than the page, holding them to their content, so the rows above the names stand higher there,
and the names, seated in their rows, a few hundredths of a point further from the page.
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 e869b78fe..b013af938 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
@@ -12636,10 +12636,11 @@ private void moveTheRowsText(XWPFTableRow row) {
* that much lower. {@code CobaltRota}'s staff names, beside the day
* cells its chips are composed in, stood 0.25pt low in their rows in Word, 0.2pt in
* LibreOffice on Windows and 0.19pt in LibreOffice 24.2; written so, they stood within 0.08pt of
- * the page's in all three, every row its height (measured). The line alone written shorter
- * keeps the row its height in Word and on Windows, but LibreOffice 24.2 holds a row to its
- * cells' content, the margins inside its height: the covers row, whose value cells' lines were
- * its tallest content, came out 0.8pt shorter there.
+ * the page's in all three, every row its height (measured). A line alone written shorter
+ * keeps its row its height in Word and on Windows, but LibreOffice 24.2 holds a row to its
+ * cells' content, the margins inside its height: written so on a probe, the covers values'
+ * lines among them, the covers row, whose tallest content those lines were, came out 0.8pt
+ * shorter there.
*
* Only a cell whose every paragraph is one exact line, no line broken in it; a line is cut
* no nearer its letters than {@link DocxStackedLines#INK_MARGIN} at its top and its foot, as
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java
index 770b86566..ace91e1d1 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellSeatTest.java
@@ -24,13 +24,16 @@
import static org.assertj.core.api.Assertions.within;
/**
- * A table text cell's lines stand on the page's baselines in Word, through the row's margins.
+ * A table text cell's lines stand on the page's baselines in Word, through the row's margins, its
+ * text's position and the height of its lines.
*
*
The page sets a cell's line its face's ascent below the line's top; Word stands an exact
* line's baseline four fifths of the way down it. Measured, a line of 8pt Poppins stood 0.58pt
* low in Word and 0.55 in LibreOffice, of Lato 0.24 high, whatever the cell's anchor. Every cell of
* a row takes the row's largest margins, so the margins move every cell's text at once: the top
- * made smaller by as much as the bottom is made larger, and the row keeps its height.
+ * made smaller by as much as the bottom is made larger, and the row keeps its height. What they
+ * leave is moved by the text's position from half a point up, and under it by each line's height
+ * traded for the space after it.
*/
class DocxCellSeatTest {
@@ -241,7 +244,7 @@ void aCellWhoseLettersReachNearItsLinesFootIsLeftAsItWas() throws Exception {
.rowCells(cell("gypsy", poppins(6), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
- assertThat(0.8 * line(text) - POPPINS_ASCENT * 6).as("the premise").isBetween(0.1, 0.5);
+ assertThat(0.8 * written(text) - POPPINS_ASCENT * 6).as("the premise").isBetween(0.1, 0.5);
assertThat(after(text)).as("its line as it was").isZero();
assertThat(position(text)).isZero();
}
@@ -257,7 +260,7 @@ void aCellOfLinesBrokenInOneParagraphIsLeftAsItWas() throws Exception {
XWPFTableCell text = document.getTables().get(0).getRow(0).getCell(0);
assertThat(text.getParagraphs()).as("the premise: one paragraph, its lines broken").hasSize(1);
- assertThat(0.8 * line(text) - POPPINS_ASCENT * 6).as("the premise").isBetween(0.1, 0.5);
+ assertThat(0.8 * written(text) - POPPINS_ASCENT * 6).as("the premise").isBetween(0.1, 0.5);
assertThat(after(text)).isZero();
}
}
@@ -388,7 +391,7 @@ void aCellInAStandardFaceTheExportDoesNotEmbedKeepsItsLine() throws Exception {
.rowCells(cell("---", helvetica(14), DocumentTableTextAnchor.CENTER), composed("Composed"))))) {
XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0);
- assertThat(0.8 * line(cell) - HELVETICA_ASCENT * 14).as("the premise: a tenth of a point and more, under a half")
+ assertThat(0.8 * written(cell) - HELVETICA_ASCENT * 14).as("the premise: a tenth of a point and more, under a half")
.isBetween(0.1, 0.5);
assertThat(after(cell)).isZero();
}
@@ -415,7 +418,7 @@ void whatTheMarginsLeaveUnderHalfAPointIsMovedByTheLine() throws Exception {
}
@Test
- void aLineIsCutNoNearerTheTopsOfItsLettersThanItsFoot() throws Exception {
+ void aLineIsCutNoNearerItsLettersTopsThanWordKeepsThem() throws Exception {
// Poppins' ring above its capital A reaches 0.96 of an em: cut by all its call, the line
// would stand the ring closer to its top than Word keeps letters on screen.
try (XWPFDocument document = export(page -> page.addTable(t -> t