From 1ed283c7e40063781ca00b102338dcfddd3d88f3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 10:48:13 +0100 Subject: [PATCH 1/2] fix(docx): hold a row's auto column's editor point in the gap after it - A row column sized to its content is given a point for an editor's face. It came out of the row's weight columns and moved what stood right of it a point right: ReceiptModern's leaders and EditorialProposal's signature lines started 0.9 to 1pt right of the page's in Word. - The column already holds the next child's left margin, and a paragraph's own right side gives what the paragraph does not write as an indent (writtenSides, now shared with writeParagraph). A paragraph set left takes what that leaves from the gap after its column, which then stands where the page sets it. - What neither holds still comes out of the weight columns, and the row is named (right end) unless they hold only spacers or it is under a tenth of a point: ReceiptModern's amounts at the row's end and VioletGrid's section heading rules, among twenty rows nothing named before. - A rule in a row's column counts its left margin once. Counted again, CharcoalGold's heading rules started 9.25pt right of the page's in Word and VioletGrid's credential ones 12.3pt. - Measured on Windows, in Word: leaders within 0.06pt, signature lines within 0.18pt, credential rules within 0.07pt; no word moves in Word or LibreOffice. - Tests: the gap holding the point, the last column taking it from the weight column, a neighbour's margin and a paragraph's own right side as room, a thin gap, a section and a centred paragraph, the row's padding, a spacer, a tenth of a point, and a rule's margin in a row's column. --- CHANGELOG.md | 23 +++ docs/recipes/docx-export.md | 11 +- .../semantic/docx/DocxSemanticBackend.java | 144 ++++++++++---- .../semantic/docx/DocxEditorSlackTest.java | 177 ++++++++++++++++-- .../docx/DocxNodeFieldLedgerTest.java | 12 +- .../backend/semantic/docx/DocxRuleTest.java | 17 ++ .../docx/DocxValueFieldLedgerTest.java | 9 +- 7 files changed, 336 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e40488a48..b9f84d9d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export holds a row's auto column's editor point in the gap after it.** + - A row column sized to its content (`DocumentRowColumn.auto()`) is given a point for an + editor's face. It came out of the row's weight columns and moved what stood right of the auto + column a point right: `ReceiptModern`'s leaders and `EditorialProposal`'s signature lines + started 0.9 to 1pt right of the page's in Word. + - The column already holds the next child's left margin, and a paragraph's own right side gives + what the paragraph does not write as an indent. A paragraph set left takes what that does not + give from the gap after its column, which stands where the page sets it. + - What neither holds still comes out of the weight columns, and the row is named (`right end`) + unless they hold only spacers or it is under a tenth of a point. Twenty rows of the corpus + are named, among them + `ReceiptModern`'s amounts at the row's end, 0.9 to 1pt left in both editors, and + `VioletGrid`'s section heading rules, a point right. Nothing named them before. + - A rule in a row's column counts its left margin once: the column starts past it. Counted + again, `CharcoalGold`'s heading rules started 9.25pt right of the page's in Word, their 9pt + margin again, and `VioletGrid`'s credential rules 12.3pt, their 11.3pt margin again and the + point. + - Measured on Windows, in Word: the leaders within 0.06pt of the page's, the signature lines + within 0.18pt and `VioletGrid`'s credential rules within 0.07pt. `CharcoalGold`'s heading + rules stand with their words, which stand 0.7pt left in both editors. LibreOffice sets each + end of a rule 1.4 and 1.5pt in, as before. No word moves in either editor. + - 4 of the corpus's 62 documents change. The notes go from 1365 to 1385. + - **A DOCX export ends a panel hung out of its table cell where the page ends it.** - A panel whose left border the page centres on its cell's text edge is written with a negative indent, its table half that border wider than the panel. LibreOffice and Word start a nested diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 06fcef880..d49a5e101 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -199,7 +199,7 @@ it cannot work out for itself: |---|---| | Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is not owed or hung below its paragraph, as Word sets lines on a grid of its own, 0.12pt apart (measured), but carried on to the next paragraph's and settled with it once the two come to more; what is carried stays on its page, starts again where the space owed is set anew — a band's or a column's layer, below a band — and in each cell, whose end settles what its own lines carry there, however little — what they fall short of the page owed below its last block, what they pass it by hung below the cell as a line hanging there is — and a paragraph that owed for its lines and whose pictures then grow them takes that back and carries on what the lines above it carried (measured: `Panel`'s card lines, each written a fiftieth of a point taller than the page's, stood a median 0.46pt low in Word and 0.39pt in LibreOffice on Windows; carried, 0.35 and 0.34pt). A list's item is settled with the page as a paragraph is, its lines carried with the paragraphs' around it (measured: `Panel`'s skills and projects, items of two lines and more, each line a fiftieth of a point taller than the page's, ran 0.21 and 0.35pt taller in Word and grew their cards by as much; items and cells' ends settled, `Panel` stood a median 0.21pt from the page in Word and in LibreOffice on Windows). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One opening a column of a row of columns or of a stack's columns takes it from the space above the row, which the row is lifted out of, its other cells starting as much lower inside (one opening with a table below a hairline paragraph holding the lift) and a row held to a height held as much taller — only where the lift raises the first line half a point or more above where sharing the gaps out sets it, a smaller rise leaving a cell's last paragraph high with nothing under it to lower it into; not on a page's first row or in an overlay (measured: `EditorialProposal`'s goal cells, three lines each, 1.46 and 0.56pt off the page in Word, stand within 0.16pt; `OrangeOps`' degree, two lines 0.45pt low and 0.45pt high, within 0.27pt). One with no room above it for the gap otherwise (a cell padded less, a gap too small) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it, and so do a band's later layer and the space below the band — a stack laying one layer over another, a shape container — which measure what follows them from the page too (measured: `ConsultingInvoice`'s contact lines, in a band, half a point low in Word and both LibreOffice builds and its address 0.3pt, within 0.03pt raised; a band's first paragraph or list item, where a band in the flow, a stack of one layer's included, opens its page, is not raised, as a paragraph's there is not). A line pair's shared line moves up into the space written above it, not a cell's padding, for both its halves, by the lesser of what they call for, where neither is moved by its position — moved for one alone, it would move the other too — and not where a half is cut to its own measure or the layout opens a page with the pair (measured: `ConsultingInvoice`'s bank details, a label and its value to a line, 0.7 to 0.8pt low in Word and both LibreOffice builds, stand 0.2 to 0.33pt low raised, the rest a drift all under its line-item table shares; `LumaStudioInvoice`'s, in Carlito, which both editors set higher than the export reckons, rise too far: in its short invoice the bank details stand 0.27pt high in Word and 0.34pt in LibreOffice, where they stood 0.03 and 0.14pt high). Not a line in another overlay — a canvas, a text box — whose writers measure what follows them from the page and pay nothing, though a stack of one layer is the flow, its lines raised and its rows lifted as there (measured: `SlateOrange`'s experience timeline, set in one, its employers' lines and their items 0.12 to 0.36pt nearer the page in Word and 0.2pt in LibreOffice, `SubscriptionInvoice`'s contact rows 0.12 to 0.24pt and 0.25pt); not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, the first of a stack of one layer it moves there whole among them or the first of a band in the flow that opens its page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. A line of its own Word sets higher than the page by a tenth of a point or more, and under half a point, is lowered by the space between it and what follows: its space above grows by what is left of that space once the block under it has taken its own of it — the gap between its lines, its own raise, the room its pictures reach into, a row's lift out of the space above its table — above that block, or below the line where a table or a cell's end follows, and the block under it stands where it did. What nothing gives — a block on a later page, whose space above Word drops, a block in an overlay, a page break, a block the layout moves to the next page, a column's later layer — it is not lowered by, and where the section's end leaves the space below a cell's last line out, the lowering that space gave is taken back with it. Not a list's item, which its own list lowers, nor a line in an overlay (a stack of one layer is the flow), one opening its page or a panel, cut to fit or not on one page (measured, with the rules below a heading hung as below: `LetterSidebarPortrait`'s lines, 0.41pt high by median in Word, stand within 0.07pt, `TimelineMinimal`'s within 0.05pt in Word and LibreOffice). What the space above cannot give of a raise, and a line Word sets half a point or more high, is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them — one that cannot, and stands less high itself, has it taken back as far — and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or under its layer stack's column, or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | | Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them; read off the table's rows, so a table with an `auto` column and a column edge no row of it shows has none, and its columns are the editor's (measured on one such table: Word set them as the page did, LibreOffice on Windows otherwise), and the report names it (`TableNode`) | -| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed | +| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. The column already holds the next child's left margin, and a paragraph's own right side gives what the paragraph does not write as an indent; a paragraph set left takes what that does not give from the gap after its column, which stands where the page sets it: widened instead, it moved what stood right of it a point right in Word, `ReceiptModern`'s leaders and `EditorialProposal`'s signature lines. What neither holds comes out of the row's weight columns, so the row keeps its width, and what stands at the edges between them moves up to that much — `ReceiptModern`'s amounts at the row's end 0.9 to 1pt left in both editors — which the report names (`right end`) unless the weight columns giving it hold only spacers or it is under a tenth of a point. A row with no weight column gives what its gaps hold and no more; one with no auto column, or no stated columns (weights, an even split), is written as placed | The space a block holds above and below itself needs no measuring and is written from the document: a paragraph's `margin` and `padding` become `w:spacing`, and a container hands @@ -1199,7 +1199,14 @@ editor's point — starts that much right in Word too, where Word's reach used t part of it (measured on the corpus: in Word 280 strokes nearer the page's and 13 further, `ReceiptModern`'s leaders and `VioletGrid`'s separators starting 1pt right instead of 0.45pt left; in LibreOffice 281 rules moved in, 249 by 1.4pt at the left and -1.5pt at the right; no word moved in either). A rule shorter than both keeps a tenth of +1.5pt at the right; no word moved in either). A row's auto column takes its point from the +gap after it where it can, and `ReceiptModern`'s leaders start within 0.06pt of the page's +in Word, `EditorialProposal`'s signature lines within 0.18pt; `VioletGrid`'s section heading +rules, against their words with no gap between, still start a point right, and the row's +report names it (`right end`). In a row's column a rule's left margin is counted once, the +column starting past it: counted again, `CharcoalGold`'s heading rules started 9.25pt right +of the page's in Word, their 9pt margin again, and `VioletGrid`'s credential rules 12.3pt, +their 11.3pt margin again and the point. A rule shorter than both keeps a tenth of a point of its paragraph, which Word still draws its reach past (measured: rules of 1, 2 and 3pt stood 3.0, 2.88 and 3.12pt long in Word, each centred where the page centres it). A header's or footer's separator is its text's own paragraph 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 c2a23011d..8444c942d 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 @@ -10980,6 +10980,25 @@ private void settleThePageReferences() { fieldReferences.clear(); } + /** + * What a paragraph writes of its own sides as indents, in points: its margin and padding there, + * less, in a table cell, the editor's points off each side it is not set from + * ({@link #EDITOR_SLACK_POINTS}); a side below zero as it is. + * + * @param left its left margin and padding, as far as its paragraph writes them + * @param right its right margin and padding + * @param inACell whether it is written in a table cell + * @return the left and the right indent it writes of them + */ + private static double[] writtenSides(ParagraphNode node, double left, double right, boolean inACell) { + double spare = inACell ? EDITOR_SLACK_POINTS : 0; + boolean leftToRight = ParagraphDirection.resolve(node) != TextDirection.RTL; + boolean fromTheLeft = leftToRight && node.align() == TextAlign.LEFT; + boolean fromTheRight = leftToRight && node.align() == TextAlign.RIGHT; + return new double[]{left < 0 ? left : Math.max(0, left - (fromTheLeft ? 0 : spare)), + right < 0 ? right : Math.max(0, right - (fromTheRight ? 0 : spare))}; + } + private void writeParagraph(XWPFDocument document, ParagraphNode node) { if (overTheFlowDepth > 0) { writeTextOverTheFlow(document, node); @@ -11015,10 +11034,6 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { // cell started 2pt left of the page's, every right-aligned one ended 2pt right of it. // A centred line, and a right-to-left one, is spared on both sides. A cell of a row the // layout placed already starts where the paragraph does, past its left margin. - double spare = currentCell != null ? EDITOR_SLACK_POINTS : 0; - boolean leftToRight = ParagraphDirection.resolve(node) != TextDirection.RTL; - boolean fromTheLeft = leftToRight && node.align() == TextAlign.LEFT; - boolean fromTheRight = leftToRight && node.align() == TextAlign.RIGHT; double left = (node == leftMarginInCell ? 0 : node.margin().left()) + node.padding().left(); double right = node.margin().right() + node.padding().right(); // A side the paragraph is pulled out past, by a margin below zero, stands out as far, with @@ -11026,8 +11041,9 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { // than the cell's: what a margin below zero, its own or a block's round it, takes past that // is not written, and is named (PULLED_OUT_OF_ITS_CELL), unless a line of a word set past // its box stands out alone (letTheLineStandOut). - double ownLeft = left < 0 ? left : Math.max(0, left - (fromTheLeft ? 0 : spare)); - double ownRight = right < 0 ? right : Math.max(0, right - (fromTheRight ? 0 : spare)); + double[] sides = writtenSides(node, left, right, currentCell != null); + double ownLeft = sides[0]; + double ownRight = sides[1]; double shortLeft = currentCell == null ? 0 : Math.max(0, -(insetLeft + ownLeft)); double shortRight = currentCell == null ? 0 : Math.max(0, -(insetRight + ownRight)); insetLeft += ownLeft + shortLeft; @@ -14642,7 +14658,11 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod * lengths, and the report says so.

*/ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule rule) { - double sideLeft = node.margin().left() + node.padding().left(); + // A cell of a row the layout placed already starts past its left margin: counted again, + // CharcoalGold's heading rules started 9.25pt right of the page's in Word, their 9pt margin + // again, and VioletGrid's credential rules 12.3pt, their 11.3pt margin again and the + // editor's point their column was given. + double sideLeft = (node == leftMarginInCell ? 0 : node.margin().left()) + node.padding().left(); double sideRight = node.margin().right() + node.padding().right(); java.util.OptionalDouble placed = layout.placedWidth(node); double boxWidth = placed.isPresent() @@ -18850,7 +18870,8 @@ private static List placedColumns(RowNode node, double[] starts) { } /** - * Widens a row's auto columns by a point, taken from its weight columns. + * Gives a row's auto columns the editor's point: out of the gap beside each where it holds it, + * the rest out of the row's weight columns, which is named. * *

An auto column is exactly as wide as its child's unwrapped content, and an editor * setting that content in its own substitute for the face wraps it — the reason a table's @@ -18858,50 +18879,107 @@ private static List placedColumns(RowNode node, double[] starts) { * Measured through LibreOffice, a table of contents — auto label, weight leader, auto page * number — broke "Intro" into "Intr" and "o".

* - *

A row is as wide as the layout placed it, so the point comes out of the weight - * columns, which on the page take whatever the others leave: the row keeps its width, and - * a fixed column its size. Each weight column gives in proportion to its text box and - * never more than it has. Written as placed: a row with no auto column or no weight - * column, and one with no stated columns — weights or an even split. A row cannot state - * columns and be laid out as flex; {@code RowNode} refuses the pair.

- * - *

The point comes out of the weight column's text box, not only its empty part — - * which the layout does not report, as a paragraph or a line fills its slot. Weight - * columns in a row with auto columns hold leaders, rules and spacers; one holding text - * that fills it to the point could wrap a line sooner in the editor.

- */ - private static List withRowEditorSlack(RowNode node, List columns) { + *

A column runs from its child's start to the next child's, so its text box already holds + * the next child's left margin, and a paragraph's own right side gives what it does not write + * as an indent ({@link #roomInItsColumn}): CharcoalGold's heading words have the 9pt their rule + * stands in by, and take none of the point from anywhere. What of the point that room does not + * give, a paragraph set left takes from the gap after its column, the column's right margin, + * and its column stands where the page sets it. Widened instead, it moved whatever stood right of it: measured in Word, + * ReceiptModern's leaders started 0.95 to 1.04pt right of the page's, and EditorialProposal's + * signature lines 0.88 to 1.02pt. The row's padding after its last column is not a gap: a + * painted row's panel takes it out of its columns ({@link #withoutTheRowsPadding}). A paragraph + * set otherwise, or a child that is not a paragraph, takes none of it from a gap: set right, a + * paragraph in an auto column claims its row's whole width on the page, and what another child + * holds may lean either way.

+ * + *

What neither gives comes out of the weight columns, which on the page take whatever the + * others leave: the row keeps its width, and a fixed column its size. Each weight column gives + * in proportion to its text box and never more than it has, and what stands at the edges + * between the columns moves that much off the page's — ReceiptModern's amounts, set after their + * leaders at the row's end, 0.9 to 1pt left in both editors — which the report names + * ({@code right end}) unless the weight columns giving it hold only spacers or it is under a + * tenth of a point. A row with no weight column gives what its gaps hold and no more. Written + * as placed: a row with no auto column, and one with no stated columns — weights or an even + * split. A row cannot state columns and be laid out as flex; {@code RowNode} refuses the + * pair.

+ */ + private List withRowEditorSlack(RowNode node, List columns) { List specs = node.columns(); if (specs.size() != columns.size()) { return columns; } - int autoColumns = 0; + List held = new ArrayList<>(columns); + int last = columns.size() - 1; + double[] wants = new double[columns.size()]; + double wanted = 0; double spare = 0; for (int index = 0; index < columns.size(); index++) { DocumentRowColumn.Type type = specs.get(index).type(); - if (type == DocumentRowColumn.Type.AUTO) { - autoColumns++; - } else if (type == DocumentRowColumn.Type.WEIGHT) { + if (type == DocumentRowColumn.Type.WEIGHT) { spare += textBoxOf(columns.get(index)); } + if (type != DocumentRowColumn.Type.AUTO) { + continue; + } + DocumentNode child = node.children().get(index); + double need = Math.max(0, EDITOR_COLUMN_SLACK_POINTS - roomInItsColumn(child, columns.get(index))); + boolean setLeft = child instanceof ParagraphNode paragraph && paragraph.align() == TextAlign.LEFT; + // The gap after it, its column's right margin: the row's padding, at its end, is not one. + CellColumn column = columns.get(index); + double fromTheGap = setLeft && index < last ? Math.min(need, column.trailing()) : 0; + held.set(index, new CellColumn(column.width(), column.leading(), column.trailing() - fromTheGap)); + wants[index] = need - fromTheGap; + wanted += wants[index]; } - if (autoColumns == 0 || !(spare > 0)) { - return columns; + if (!(wanted > 0) || !(spare > 0)) { + return held; } - double slack = Math.min(EDITOR_COLUMN_SLACK_POINTS, spare / autoColumns); - double taken = slack * autoColumns; - List widened = new ArrayList<>(columns.size()); - for (int index = 0; index < columns.size(); index++) { - CellColumn column = columns.get(index); + double taken = Math.min(wanted, spare); + boolean movesWhatTheyHold = false; + List widened = new ArrayList<>(held.size()); + for (int index = 0; index < held.size(); index++) { + CellColumn column = held.get(index); DocumentRowColumn.Type type = specs.get(index).type(); - double change = type == DocumentRowColumn.Type.AUTO ? slack + double change = type == DocumentRowColumn.Type.AUTO ? wants[index] * taken / wanted : type == DocumentRowColumn.Type.WEIGHT ? -taken * textBoxOf(column) / spare : 0; + if (change < 0 && !(node.children().get(index) instanceof SpacerNode)) { + movesWhatTheyHold = true; + } widened.add(new CellColumn(column.width() + change, column.leading(), column.trailing())); } + // Less than a tenth of a point off is not seen. + if (movesWhatTheyHold && taken >= 0.1) { + report.add(DocxExportReport.Severity.APPROXIMATED, RIGHT_END, layout.pathOf(node), + "its columns sized to their content written " + pointsOf(taken) + "pt wider than the page sets " + + "them, the room an editor setting their text in its own face may need, which the gaps beside " + + "them do not hold: it comes out of its columns sized by weight, and what stands at the edges " + + "between them moves up to that much off where the page sets it"); + } return widened; } + /** + * The room a row's child has in its column's text box past what the page gives it, in points: + * the next child's left margin, which the column runs past ({@link #placedColumns}), and, for a + * paragraph, what of its own right side it does not write as an indent ({@link #writtenSides}). + * None where the layout did not place it. + */ + private double roomInItsColumn(DocumentNode child, CellColumn column) { + java.util.OptionalDouble width = layout.placedWidth(child); + if (width.isEmpty()) { + return 0; + } + if (child instanceof ParagraphNode paragraph) { + // In the row's cell, which starts past its left margin. + double[] sides = writtenSides(paragraph, paragraph.padding().left(), + paragraph.margin().right() + paragraph.padding().right(), true); + double text = width.getAsDouble() - paragraph.padding().left() - paragraph.padding().right(); + return Math.max(0, textBoxOf(column) - sides[0] - sides[1] - text); + } + return Math.max(0, textBoxOf(column) - width.getAsDouble() - Math.max(0, child.margin().right())); + } + /** The width a row column leaves its content, between its margins. */ private static double textBoxOf(CellColumn column) { return Math.max(0, column.width() - column.leading() - column.trailing()); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java index 051188f31..8b7103eae 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java @@ -7,6 +7,7 @@ import com.demcha.compose.document.dsl.RowBuilder; import com.demcha.compose.document.layout.PlacedNode; import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload; +import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.node.TextDirection; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; @@ -318,19 +319,148 @@ void aTableWithAStatedWidthKeepsItExactly() throws Exception { } @Test - void aRowsAutoColumnsTakeTheirPointFromTheWeightColumnBetweenThem() throws Exception { + void aRowsAutoColumnTakesItsPointOutOfTheGapAfterItAndTheLastOneFromTheWeightColumn() throws Exception { // A table of contents' entry: label, leader, page number. Measured through LibreOffice, - // with the columns as placed the label wrapped mid-word ("Intr" / "o"). + // with the columns as placed the label wrapped mid-word ("Intr" / "o"). The label takes + // its point from the 6pt gap after it, and the leader starts where the page starts it; + // the page number, at the row's end, has no gap after it and takes its point from the + // leader, which is named. ExportedRow exported = exportRow(row -> row .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1), DocumentRowColumn.auto()) .addParagraph("Intro").addParagraph("…").addParagraph("12")); - assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0) + SLACK_TWIPS); - assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1) - 2 * SLACK_TWIPS); + assertThat(exported.gridTwips(0)).as("the label's column where the page sets it").isEqualTo(exported.placedTwips(0)); + assertThat(exported.rightMarginTwips(0)).as("the gap, less the point").isEqualTo(6 * 20 - SLACK_TWIPS); + assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1) - SLACK_TWIPS); assertThat(exported.gridTwips(2)).isEqualTo(exported.placedTwips(2) + SLACK_TWIPS); assertThat(exported.gridTwips(0) + exported.gridTwips(1) + exported.gridTwips(2)) .as("the row keeps the width the layout placed it at") .isEqualTo(exported.placedTwips(0) + exported.placedTwips(1) + exported.placedTwips(2)); + assertThat(exported.notes(RIGHT_END)).singleElement().satisfies(note -> assertThat(note.detail()) + .startsWith("its columns sized to their content written 1pt wider than the page sets them") + .contains("what stands at the edges between them moves up to that much off where the page sets it")); + } + + @Test + void aGapThinnerThanThePointGivesWhatItHasAndTheWeightColumnTheRest() throws Exception { + ExportedRow exported = exportRow(row -> row + .spacing(0.4) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph("Experience").addParagraph("…")); + + assertThat(exported.rightMarginTwips(0)).as("the whole gap given").isZero(); + assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0) + SLACK_TWIPS - 8); + assertThat(exported.notes(RIGHT_END)).singleElement() + .satisfies(note -> assertThat(note.detail()).contains("written 0.6pt wider")); + } + + @Test + void anAutoColumnWhoseNeighbourStandsInByItsMarginNeedsNoPoint() throws Exception { + // A heading and a rule 9pt in from it, as CharcoalGold draws them: the label's column runs + // to where the rule starts, past its margin, and already holds the room. + ExportedRow exported = exportRow(row -> row + .spacing(0) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph("Experience") + .addLine(line -> line.fill().thickness(0.6).margin(new DocumentInsets(0, 0, 0, 9)))); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0)); + assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1)); + assertThat(exported.notes(RIGHT_END)).isEmpty(); + } + + @Test + void anAutoColumnWithNoGapTakesItsPointFromTheWeightColumnNamed() throws Exception { + ExportedRow exported = exportRow(row -> row + .spacing(0) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph("Experience").addParagraph("…")); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0) + SLACK_TWIPS); + assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1) - SLACK_TWIPS); + assertThat(exported.notes(RIGHT_END)).hasSize(1); + } + + @Test + void theRowsPaddingAfterItsLastColumnIsNotAGap() throws Exception { + // A painted row's panel takes the padding out of its columns: the point stays out of it. + ExportedRow exported = exportRow(row -> row + .padding(new DocumentInsets(0, 4, 0, 0)) + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.auto()) + .addParagraph("…").addParagraph("12")); + + assertThat(exported.rightMarginTwips(1)).as("the row's padding, whole").isEqualTo(4 * 20L); + assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1) + SLACK_TWIPS); + assertThat(exported.notes(RIGHT_END)).hasSize(1); + } + + @Test + void aChildThatIsNotAParagraphTakesNoneOfThePointFromTheGap() throws Exception { + // What a section holds may lean either way. + ExportedRow exported = exportRow(row -> row + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addSection(section -> section.addParagraph("Experience")).addParagraph("…")); + + assertThat(exported.rightMarginTwips(0)).as("the gap, whole").isEqualTo(6 * 20L); + assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0) + SLACK_TWIPS); + } + + @Test + void anAutoParagraphsOwnRightSideGivesTheRoomItsParagraphDoesNotIndent() throws Exception { + // In a cell, a paragraph set left writes its right side 2pt short as its indent: a 3pt + // margin leaves it 2pt past its text, and it needs none of the point; a 0.4pt one leaves + // it 0.4pt, and the rest comes out of the weight column. + java.util.function.DoubleFunction margined = margin -> { + try { + return exportRow(row -> row + .spacing(0) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Experience").margin(new DocumentInsets(0, margin, 0, 0))) + .addParagraph("…")); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }; + ExportedRow wide = margined.apply(3); + assertThat(wide.gridTwips(0)).isEqualTo(wide.placedTwips(0)); + assertThat(wide.notes(RIGHT_END)).isEmpty(); + + ExportedRow thin = margined.apply(0.4); + assertThat(thin.gridTwips(0)).isEqualTo(thin.placedTwips(0) + SLACK_TWIPS - 8); + assertThat(thin.notes(RIGHT_END)).singleElement() + .satisfies(note -> assertThat(note.detail()).contains("written 0.6pt wider")); + } + + @Test + void aCentredAutoParagraphTakesNoneOfThePointFromTheGap() throws Exception { + ExportedRow exported = exportRow(row -> row + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Experience").align(TextAlign.CENTER)).addParagraph("…")); + + assertThat(exported.rightMarginTwips(0)).as("the gap, whole").isEqualTo(6 * 20L); + } + + @Test + void lessThanATenthOfAPointOffIsNotNamed() throws Exception { + ExportedRow exported = exportRow(row -> row + .spacing(0.95) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph("Experience").addParagraph("…")); + + assertThat(exported.rightMarginTwips(0)).isZero(); + assertThat(exported.notes(RIGHT_END)).isEmpty(); + } + + @Test + void aWeightColumnHoldingASpacerGivesThePointUnnamed() throws Exception { + // A spacer stands nowhere a reader sees. + ExportedRow exported = exportRow(row -> row + .spacing(0) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph("Experience").addSpacer(0)); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0) + SLACK_TWIPS); + assertThat(exported.notes(RIGHT_END)).isEmpty(); } @Test @@ -360,6 +490,7 @@ void aRowWithNoWeightColumnHasNoPointToGiveItsFixedColumnAtItsWidth() throws Exc @Test void aFixedColumnBesideThemKeepsItsSize() throws Exception { ExportedRow exported = exportRow(row -> row + .spacing(0) .columns(DocumentRowColumn.fixed(60), DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) .addParagraph("Invoice").addParagraph("No. 2041").addParagraph("…")); @@ -370,20 +501,21 @@ void aFixedColumnBesideThemKeepsItsSize() throws Exception { @Test void twoWeightColumnsGiveThePointInProportionToTheirTextBoxes() throws Exception { - // A wide gap, so a split by column width — the middle column carries the gap after it - // as its right margin, the last none — lands whole twips away from one by text box. + // A wide gap, so a split by column width — each weight column carries the gap after it as + // its right margin — lands whole twips away from one by text box. At the row's end, the + // number has no gap after it to take its point from. ExportedRow exported = exportRow(row -> row .spacing(60) - .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1), DocumentRowColumn.weight(3)) - .addParagraph("Intro").addParagraph("a").addParagraph("b")); + .columns(DocumentRowColumn.weight(1), DocumentRowColumn.weight(3), DocumentRowColumn.auto()) + .addParagraph("a").addParagraph("b").addParagraph("12")); - double first = exported.placed()[1] - 60; - double second = exported.placed()[2]; + double first = exported.placed()[0] - 60; + double second = exported.placed()[1] - 60; double point = DocxSemanticBackend.EDITOR_COLUMN_SLACK_POINTS; + assertThat(exported.gridTwips(0)) + .isEqualTo(Math.round((exported.placed()[0] - point * first / (first + second)) * 20.0)); assertThat(exported.gridTwips(1)) - .isEqualTo(Math.round((exported.placed()[1] - point * first / (first + second)) * 20.0)); - assertThat(exported.gridTwips(2)) - .isEqualTo(Math.round((exported.placed()[2] - point * second / (first + second)) * 20.0)); + .isEqualTo(Math.round((exported.placed()[1] - point * second / (first + second)) * 20.0)); } @Test @@ -396,8 +528,8 @@ void aRowOfWeightsWithNoStatedColumnsIsWrittenAsPlaced() throws Exception { assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1)); } - /** A row exported through a session, with the column spans the layout placed it at. */ - private record ExportedRow(XWPFTable table, double[] placed) { + /** A row exported through a session, with the column spans the layout placed it at and the report. */ + private record ExportedRow(XWPFTable table, double[] placed, DocxExportReport report) { long gridTwips(int column) { return DocxTwips.of(table.getCTTbl().getTblGrid().getGridColArray(column).getW()); @@ -406,6 +538,15 @@ long gridTwips(int column) { long placedTwips(int column) { return Math.round(placed[column] * 20.0); } + + /** A column's cell's right margin as written, in twips. */ + long rightMarginTwips(int column) { + return DocxTwips.of(table.getRow(0).getCell(column).getCTTc().getTcPr().getTcMar().getRight().getW()); + } + + List notes(String subject) { + return report.bySubject().getOrDefault(subject, List.of()); + } } private static ExportedRow exportRow(Consumer content) throws Exception { @@ -433,9 +574,11 @@ private static ExportedRow exportRow(Consumer content) throws Except : children.get(index + 1).placementX(); placed[index] = end - start; } - byte[] docx = session.export(new DocxSemanticBackend()); + AtomicReference report = new AtomicReference<>(); + byte[] docx = session.export(new DocxSemanticBackend(report::set)); XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx)); - return new ExportedRow(document.getTables().get(0), placed); + assertThat(report.get().bySubject()).as("laid out").doesNotContainKey("measured geometry"); + return new ExportedRow(document.getTables().get(0), placed, report.get()); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index bf72a5094..68d9a81a3 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -149,6 +149,15 @@ record Entry(Fate fate, String note) { + "wider than the page, and by what a hung left border gave up that such a line " + "takes back, named (right end)"; + // A row's auto column's text is given a point of slack, out of the gap beside it where that + // holds it (DocxEditorSlackTest). + static final String ROW_AUTO_ROOM = "the room an editor's face is given in a row's auto column, held in the " + + "room its child leaves and, for a paragraph set left, the gap after " + + "it; what they do not hold, out of its weight columns, what " + + "stands at the edges between them up to that much off the page's, " + + "named unless they hold only spacers or it is under a tenth of a " + + "point (right end)"; + // LibreOffice and Word start a nested table at its cell's text. static final String LEFT_EDGE = "a panel's left edge in a table cell, its left border hanging out of the cell or " + "its margin below zero, which LibreOffice and Word set at the cell's text: up " @@ -389,7 +398,8 @@ record Entry(Fate fate, String note) { "cornerRadius:REPORTED:a panel's corners, which a Word table cell does not round; with no paint " + "it paints nothing", "borders:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + ROW_PAINT, - "columns:GAP:" + ROW_COLUMNS_UNLAID + "; " + ROW_COLUMNS, "verticalAlign:WRITTEN", + "columns:GAP:" + ROW_COLUMNS_UNLAID + "; " + ROW_AUTO_ROOM + "; " + ROW_COLUMNS, + "verticalAlign:WRITTEN", "arrangement:REPORTED:" + ROW_COLUMNS); // The wrappers the layout adds round a timeline's parts, public records the export writes // through to their child. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java index baa6022e3..2d461d20b 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java @@ -8,6 +8,7 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentCornerRadius; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.table.DocumentTableCell; import org.apache.poi.xwpf.usermodel.XWPFDocument; @@ -477,6 +478,22 @@ void aRuleInAPaddedSectionStartsAndEndsInsideIt() throws Exception { } } + @Test + void aRuleInARowsColumnCountsItsLeftMarginOnce() throws Exception { + // A heading and a rule 9pt in from it, as CharcoalGold draws them: the rule's column starts + // past its margin, so its paragraph starts at the cell's text, in by Word's reach alone. + // Counted again, the rule started 9pt right of the page's in Word. + try (XWPFDocument document = export(page -> page.addRow(row -> row + .spacing(0) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph("Experience") + .addLine(l -> l.fill().stroke(DocumentStroke.of(ACCENT, 1)).margin(new DocumentInsets(0, 0, 0, 9)))))) { + XWPFParagraph rule = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0); + + assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getLeft())).isEqualTo(REACH_LEFT); + } + } + @Test void aSolidPaintedBarIsARuleToo() throws Exception { try (XWPFDocument document = export(page -> page diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java index af85fc758..28f293a81 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java @@ -38,6 +38,7 @@ import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.PULL_UNRECKONED; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.RIGHT_BORDER; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.RIGHT_END; +import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.ROW_AUTO_ROOM; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.ROW_COLUMNS_UNLAID; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.SEAT_UNREAD; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.SPACE_BELOW_AT_THE_FOOT; @@ -418,8 +419,8 @@ class DocxValueFieldLedgerTest { "START:WRITTEN"); value(com.demcha.compose.document.style.DocumentRowColumn.class, "type:REPORTED:an auto column, with those beside it, where the layout does not show where the row's " - + "children start, Word's, sized to their text; with no layout, in the section's note; any other is " - + "written", + + "children start, Word's, sized to their text; with no layout, in the section's note; " + + ROW_AUTO_ROOM + "; any other is written", "value:GAP:" + ROW_COLUMNS_UNLAID + "; a fixed width or a weight beside an auto column, where the layout does not show where the " + "row's children start, Word's with the rest, named; in a row its arrangement or a growing spacer " @@ -427,8 +428,8 @@ class DocxValueFieldLedgerTest { value(com.demcha.compose.document.style.DocumentRowColumn.Type.class, "AUTO:REPORTED:where the layout does not show where the row's children start — composed in a table " + "cell with a child its lines do not place, or inside a panel or another table in its cell — Word's, " - + "sized to its text, with the columns beside it; with no layout, in the section's note; any other is " - + "written", + + "sized to its text, with the columns beside it; with no layout, in the section's note; " + + ROW_AUTO_ROOM + "; any other is written", "FIXED:GAP:" + ROW_COLUMNS_UNLAID + "; beside an auto column, where the layout does not show where the row's children start, " + "Word's with the rest, named; any other is written", From 086e2de1edaf7ff310cdf2a7463e120a1ffe28d8 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 11:17:36 +0100 Subject: [PATCH 2/2] fix(docx): count no room in a side a margin below zero pulls out - A row cell holds a paragraph's side at its text, so a right margin below zero gives an auto column no room: counted, a label pulled 2pt into a neighbour's 2pt margin got no point. - writtenSides names its paragraph and says a cell holds a side below zero at its text. - The rule test checks the right end of a rule in a row's column too. - The CHANGELOG, the recipe and the javadoc give ReceiptModern's amounts as measured on all eight rows, 0.84 to 1.1pt left in Word and 0.86 to 1.02pt in LibreOffice, and the leaders and signature lines 0.88 to 1.04pt right before. The CHANGELOG says no line breaks differently or moves down, and no word moves across by more than 0.4pt. --- CHANGELOG.md | 8 +++++--- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 17 ++++++++++------- .../semantic/docx/DocxEditorSlackTest.java | 14 ++++++++++++++ .../backend/semantic/docx/DocxRuleTest.java | 2 ++ 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f84d9d8..e2488c9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,15 @@ follow semantic versioning; release dates are ISO 8601. - A row column sized to its content (`DocumentRowColumn.auto()`) is given a point for an editor's face. It came out of the row's weight columns and moved what stood right of the auto column a point right: `ReceiptModern`'s leaders and `EditorialProposal`'s signature lines - started 0.9 to 1pt right of the page's in Word. + started 0.88 to 1.04pt right of the page's in Word. - The column already holds the next child's left margin, and a paragraph's own right side gives what the paragraph does not write as an indent. A paragraph set left takes what that does not give from the gap after its column, which stands where the page sets it. - What neither holds still comes out of the weight columns, and the row is named (`right end`) unless they hold only spacers or it is under a tenth of a point. Twenty rows of the corpus are named, among them - `ReceiptModern`'s amounts at the row's end, 0.9 to 1pt left in both editors, and + `ReceiptModern`'s amounts at the row's end, 0.84 to 1.1pt left in Word and 0.86 to 1.02pt in + LibreOffice, and `VioletGrid`'s section heading rules, a point right. Nothing named them before. - A rule in a row's column counts its left margin once: the column starts past it. Counted again, `CharcoalGold`'s heading rules started 9.25pt right of the page's in Word, their 9pt @@ -28,7 +29,8 @@ follow semantic versioning; release dates are ISO 8601. - Measured on Windows, in Word: the leaders within 0.06pt of the page's, the signature lines within 0.18pt and `VioletGrid`'s credential rules within 0.07pt. `CharcoalGold`'s heading rules stand with their words, which stand 0.7pt left in both editors. LibreOffice sets each - end of a rule 1.4 and 1.5pt in, as before. No word moves in either editor. + end of a rule 1.4 and 1.5pt in, as before. No line breaks differently or moves down in either + editor, and no word moves across by more than 0.4pt. - 4 of the corpus's 62 documents change. The notes go from 1365 to 1385. - **A DOCX export ends a panel hung out of its table cell where the page ends it.** diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index d49a5e101..68cd0eaea 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -199,7 +199,7 @@ it cannot work out for itself: |---|---| | Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is not owed or hung below its paragraph, as Word sets lines on a grid of its own, 0.12pt apart (measured), but carried on to the next paragraph's and settled with it once the two come to more; what is carried stays on its page, starts again where the space owed is set anew — a band's or a column's layer, below a band — and in each cell, whose end settles what its own lines carry there, however little — what they fall short of the page owed below its last block, what they pass it by hung below the cell as a line hanging there is — and a paragraph that owed for its lines and whose pictures then grow them takes that back and carries on what the lines above it carried (measured: `Panel`'s card lines, each written a fiftieth of a point taller than the page's, stood a median 0.46pt low in Word and 0.39pt in LibreOffice on Windows; carried, 0.35 and 0.34pt). A list's item is settled with the page as a paragraph is, its lines carried with the paragraphs' around it (measured: `Panel`'s skills and projects, items of two lines and more, each line a fiftieth of a point taller than the page's, ran 0.21 and 0.35pt taller in Word and grew their cards by as much; items and cells' ends settled, `Panel` stood a median 0.21pt from the page in Word and in LibreOffice on Windows). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One opening a column of a row of columns or of a stack's columns takes it from the space above the row, which the row is lifted out of, its other cells starting as much lower inside (one opening with a table below a hairline paragraph holding the lift) and a row held to a height held as much taller — only where the lift raises the first line half a point or more above where sharing the gaps out sets it, a smaller rise leaving a cell's last paragraph high with nothing under it to lower it into; not on a page's first row or in an overlay (measured: `EditorialProposal`'s goal cells, three lines each, 1.46 and 0.56pt off the page in Word, stand within 0.16pt; `OrangeOps`' degree, two lines 0.45pt low and 0.45pt high, within 0.27pt). One with no room above it for the gap otherwise (a cell padded less, a gap too small) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it, and so do a band's later layer and the space below the band — a stack laying one layer over another, a shape container — which measure what follows them from the page too (measured: `ConsultingInvoice`'s contact lines, in a band, half a point low in Word and both LibreOffice builds and its address 0.3pt, within 0.03pt raised; a band's first paragraph or list item, where a band in the flow, a stack of one layer's included, opens its page, is not raised, as a paragraph's there is not). A line pair's shared line moves up into the space written above it, not a cell's padding, for both its halves, by the lesser of what they call for, where neither is moved by its position — moved for one alone, it would move the other too — and not where a half is cut to its own measure or the layout opens a page with the pair (measured: `ConsultingInvoice`'s bank details, a label and its value to a line, 0.7 to 0.8pt low in Word and both LibreOffice builds, stand 0.2 to 0.33pt low raised, the rest a drift all under its line-item table shares; `LumaStudioInvoice`'s, in Carlito, which both editors set higher than the export reckons, rise too far: in its short invoice the bank details stand 0.27pt high in Word and 0.34pt in LibreOffice, where they stood 0.03 and 0.14pt high). Not a line in another overlay — a canvas, a text box — whose writers measure what follows them from the page and pay nothing, though a stack of one layer is the flow, its lines raised and its rows lifted as there (measured: `SlateOrange`'s experience timeline, set in one, its employers' lines and their items 0.12 to 0.36pt nearer the page in Word and 0.2pt in LibreOffice, `SubscriptionInvoice`'s contact rows 0.12 to 0.24pt and 0.25pt); not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, the first of a stack of one layer it moves there whole among them or the first of a band in the flow that opens its page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. A line of its own Word sets higher than the page by a tenth of a point or more, and under half a point, is lowered by the space between it and what follows: its space above grows by what is left of that space once the block under it has taken its own of it — the gap between its lines, its own raise, the room its pictures reach into, a row's lift out of the space above its table — above that block, or below the line where a table or a cell's end follows, and the block under it stands where it did. What nothing gives — a block on a later page, whose space above Word drops, a block in an overlay, a page break, a block the layout moves to the next page, a column's later layer — it is not lowered by, and where the section's end leaves the space below a cell's last line out, the lowering that space gave is taken back with it. Not a list's item, which its own list lowers, nor a line in an overlay (a stack of one layer is the flow), one opening its page or a panel, cut to fit or not on one page (measured, with the rules below a heading hung as below: `LetterSidebarPortrait`'s lines, 0.41pt high by median in Word, stand within 0.07pt, `TimelineMinimal`'s within 0.05pt in Word and LibreOffice). What the space above cannot give of a raise, and a line Word sets half a point or more high, is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line set low keeps what the space above does not take of it; set high by half a point or more, it is lowered by the space above it instead, marker and text together, the items under it giving that back from the space above them — one that cannot, and stands less high itself, has it taken back as far — and what the list's last items still owe hung below the list as a line hanging below a block is — taken out of the space above what follows, or at a cell's end out of what the cell owes under the list, the room under it in its row or under its layer stack's column, or a card's padding — and taken back, the items as high as before, where that hang is let go: below a table's composed cell, under a column's later layer, at a block the layout sets on a later page, which takes nothing of the hang left on the page above, or at a section's end, among others (measured: `MidnightNavy`'s duties, 0.70 to 1.06pt high in Word and 0.82 to 1.04pt in LibreOffice, stand within 0.2pt); not where the layout opens a page with the item, sets its list over two pages in a cell, opens a card with it, or sets it in an overlay — a stack laying one layer over another, a band, a shape container — though a stack of one layer is the flow. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | | Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them; read off the table's rows, so a table with an `auto` column and a column edge no row of it shows has none, and its columns are the editor's (measured on one such table: Word set them as the page did, LibreOffice on Windows otherwise), and the report names it (`TableNode`) | -| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. The column already holds the next child's left margin, and a paragraph's own right side gives what the paragraph does not write as an indent; a paragraph set left takes what that does not give from the gap after its column, which stands where the page sets it: widened instead, it moved what stood right of it a point right in Word, `ReceiptModern`'s leaders and `EditorialProposal`'s signature lines. What neither holds comes out of the row's weight columns, so the row keeps its width, and what stands at the edges between them moves up to that much — `ReceiptModern`'s amounts at the row's end 0.9 to 1pt left in both editors — which the report names (`right end`) unless the weight columns giving it hold only spacers or it is under a tenth of a point. A row with no weight column gives what its gaps hold and no more; one with no auto column, or no stated columns (weights, an even split), is written as placed | +| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. The column already holds the next child's left margin, and a paragraph's own right side gives what the paragraph does not write as an indent; a paragraph set left takes what that does not give from the gap after its column, which stands where the page sets it: widened instead, it moved what stood right of it a point right in Word, `ReceiptModern`'s leaders and `EditorialProposal`'s signature lines. What neither holds comes out of the row's weight columns, so the row keeps its width, and what stands at the edges between them moves up to that much — `ReceiptModern`'s amounts at the row's end 0.84 to 1.1pt left in Word and 0.86 to 1.02pt in LibreOffice — which the report names (`right end`) unless the weight columns giving it hold only spacers or it is under a tenth of a point. A row with no weight column gives what its gaps hold and no more; one with no auto column, or no stated columns (weights, an even split), is written as placed | The space a block holds above and below itself needs no measuring and is written from the document: a paragraph's `margin` and `padding` become `w:spacing`, and a container hands 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 8444c942d..d72fc2690 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 @@ -10981,10 +10981,12 @@ private void settleThePageReferences() { } /** - * What a paragraph writes of its own sides as indents, in points: its margin and padding there, + * What a paragraph asks of its own sides as indents, in points: its margin and padding there, * less, in a table cell, the editor's points off each side it is not set from - * ({@link #EDITOR_SLACK_POINTS}); a side below zero as it is. + * ({@link #EDITOR_SLACK_POINTS}); a side below zero as it is, which a table cell then holds at + * its text ({@link #writeParagraph}). * + * @param node the paragraph, whose direction and alignment say which side it is set from * @param left its left margin and padding, as far as its paragraph writes them * @param right its right margin and padding * @param inACell whether it is written in a table cell @@ -18896,9 +18898,9 @@ private static List placedColumns(RowNode node, double[] starts) { * others leave: the row keeps its width, and a fixed column its size. Each weight column gives * in proportion to its text box and never more than it has, and what stands at the edges * between the columns moves that much off the page's — ReceiptModern's amounts, set after their - * leaders at the row's end, 0.9 to 1pt left in both editors — which the report names - * ({@code right end}) unless the weight columns giving it hold only spacers or it is under a - * tenth of a point. A row with no weight column gives what its gaps hold and no more. Written + * leaders at the row's end, 0.84 to 1.1pt left in Word and 0.86 to 1.02pt in LibreOffice — + * which the report names ({@code right end}) unless the weight columns giving it hold only + * spacers or it is under a tenth of a point. A row with no weight column gives what its gaps hold and no more. Written * as placed: a row with no auto column, and one with no stated columns — weights or an even * split. A row cannot state columns and be laid out as flex; {@code RowNode} refuses the * pair.

@@ -18971,11 +18973,12 @@ private double roomInItsColumn(DocumentNode child, CellColumn column) { return 0; } if (child instanceof ParagraphNode paragraph) { - // In the row's cell, which starts past its left margin. + // In the row's cell, which starts past its left margin and holds no side of it below + // zero (writeParagraph): a side a margin below zero pulls out stands at the cell's text. double[] sides = writtenSides(paragraph, paragraph.padding().left(), paragraph.margin().right() + paragraph.padding().right(), true); double text = width.getAsDouble() - paragraph.padding().left() - paragraph.padding().right(); - return Math.max(0, textBoxOf(column) - sides[0] - sides[1] - text); + return Math.max(0, textBoxOf(column) - Math.max(0, sides[0]) - Math.max(0, sides[1]) - text); } return Math.max(0, textBoxOf(column) - width.getAsDouble() - Math.max(0, child.margin().right())); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java index 8b7103eae..3ab5ff1de 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java @@ -431,6 +431,20 @@ void anAutoParagraphsOwnRightSideGivesTheRoomItsParagraphDoesNotIndent() throws .satisfies(note -> assertThat(note.detail()).contains("written 0.6pt wider")); } + @Test + void anAutoParagraphsSidePulledOutIsNoRoom() throws Exception { + // A right margin below zero pulls its side 2pt into the 2pt its neighbour stands in by: its + // cell holds that side at its text, so the column has no room past the page's line. + ExportedRow exported = exportRow(row -> row + .spacing(0) + .columns(DocumentRowColumn.auto(), DocumentRowColumn.weight(1)) + .addParagraph(p -> p.text("Experience").margin(new DocumentInsets(0, -2, 0, 0))) + .addParagraph(p -> p.text("…").margin(new DocumentInsets(0, 0, 0, 2)))); + + assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0) + SLACK_TWIPS); + assertThat(exported.notes(RIGHT_END)).hasSize(1); + } + @Test void aCentredAutoParagraphTakesNoneOfThePointFromTheGap() throws Exception { ExportedRow exported = exportRow(row -> row diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java index 2d461d20b..56b6078e2 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java @@ -491,6 +491,8 @@ void aRuleInARowsColumnCountsItsLeftMarginOnce() throws Exception { XWPFParagraph rule = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().get(0); assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getLeft())).isEqualTo(REACH_LEFT); + assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getRight())).as("to its cell's end") + .isEqualTo(REACH_RIGHT); } }