From 43361bd0e017523accb26db242dd86ee1b60f1bf Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 22 Oct 2026 09:51:28 +0100 Subject: [PATCH 1/2] fix(docx): name what of a paragraph's pull up nothing gives back Word has no space above a paragraph below zero. A paragraph's, a page reference's or a rule's top edge below zero comes out of the space owed above it, and a paragraph's off the foot of the one line above it and the top of its own line; what none of them gave was dropped without a note. standsIntoTheSpaceAbove now names the block's own pull left after every gift (space above), a container's edge carried down and a border below the block above aside: in the flow the block and what follows stand that much lower, in a table cell up to that much, its row as much taller where the cell sets it. The first block of a resumed layer and a block opening a page in the flow are not reckoned here yet, and stay a narrowed gap in the ledger. riseInsideItsOwnLine returns what it took. --- CHANGELOG.md | 19 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 13 +- .../semantic/docx/DocxSemanticBackend.java | 61 ++++- .../docx/DocxNodeFieldLedgerTest.java | 22 +- .../semantic/docx/DocxPanelTailTest.java | 10 +- .../semantic/docx/DocxTopEdgePullTest.java | 214 ++++++++++++++++++ 7 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b103212b..3bc915db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names what of a paragraph's pull up nothing above it gives back.** Word has no + space above a paragraph below zero. A paragraph's, a page reference's or a rule's top edge + below zero comes out of the space owed above it, and a paragraph's also off the foot of the one + line above it and the top of its own line, as far as each gives. What none gives was dropped + without a note. + - The note (`space above`) names the block's own pull, not a border below the block above, + which is named apart. In the flow, the block and what follows it stand that much lower. In + a table cell, the block stands up to that much lower, and where the cell sets its row's + height the row is that much taller. + - Not reckoned yet, and not named: the first block of a band's layer or of a row of columns' + later layer, which resumes at the page's distance from what is above it, and a block opening + a page in the flow. + - Measured in Word on Windows: `TealPulse`'s contact pairs are pulled 4.32pt up in a centred + row, a 2.16pt lift on the page, past what a line holding an icon gives. They stood 1.2pt + lower. Written as space below them, which shifts a centred paragraph as much, they rose + 0.8pt, but their row grew past the page's and the page under it stood 2.2 to 2.8pt lower, so + the pull is named, not written. + - 5 paragraphs of the corpus's 62 documents gain the note: the 4 pairs and Obsidian's wordmark, + 0.1pt. None of the DOCX changes. - **A DOCX export names a panel's right border its table cell's edge would cut off.** Word draws a nested table's right border outside the table and, on screen, cuts off what passes its cell's edge. A bordered panel reaching that edge has its table written narrower, out of its right diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 5f558239b..8786b55e1 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -66,7 +66,7 @@ Payload records live in `core` under | Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| -| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level | +| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; a top edge below zero comes out of the space owed above the paragraph, the foot of the one line above it and the top of its own line, and what none gives is not written and is reported (`space above`), the paragraph and what follows that much lower in the flow, and in a table cell up to that much, its row as much taller where the cell sets the row's height — not yet on the first block of a band's layer or of a row of columns' later layer, or on a block opening a page in the flow; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level | | List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items, a drawn marker or a marker holding a percent sign before a digit, which Word reads in a level's text as a level's number, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell that its table's lines are not matched to, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named | | Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius, the padding above and below the letters and a padding below zero on any side are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; a picture on a later line of another height is set by that measure too, and where the paragraph's lines are not read, nothing moves a picture beside text off the baseline: a paragraph's note names either, not a list item's; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index e835a3086..bcbeaee93 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -241,8 +241,17 @@ that line's letters leave room below them where Word draws them, three quarters clear: the line written shorter, its text lowered by the four fifths of that Word's baseline would rise. The rest it takes from the top of its own line, where it is one line of text, as far as its letters leave room above them, its text seated where the page sets it; what neither -can give is not written. Text laid over the flow in text boxes owes no space, so its edges -move nothing. +can give is not written, and the report names the block's own pull (`space above`), a border +below the block above named apart. In the flow the block and what follows it stand that much +lower. In a table cell the page sets the cell's content that much shorter, so the block stands +up to that much lower, and where the cell sets its row's height the row is that much taller. +The first block of a band's layer or of a row of columns' later layer, which resumes at the +page's distance from what is above it, and a block opening a page in the flow are not reckoned +this way yet, and not named. Measured: `TealPulse`'s +contact pairs, pulled 4.32pt up in a centred row past what a line holding an icon gives, a +2.16pt lift on the page, stood 1.2pt lower in Word; written as space below them instead, they +rose 0.8pt and grew their row past the page's, the page under it 2.2 to 2.8pt lower. Text laid +over the flow in text boxes owes no space, so its edges move nothing. A negative bottom `margin` or `padding` — a paragraph's, an image's, a barcode's or a rule's — pulls the next paragraph up into it the same way: it comes out of the space above that 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 671957453..48e7e7697 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 @@ -8981,7 +8981,8 @@ private static boolean drawn(CTBorder border) { * {@link #newBodyParagraph} nets it against everything owed above, as the page sums it. * Where that cannot give it all, a paragraph takes the rest off the foot of the line written * just before it ({@link #takeFromTheLineAbove}), then off the top of its own line - * ({@link #riseInsideItsOwnLine}); what neither can give stays unwritten. Text laid over the flow owes no + * ({@link #riseInsideItsOwnLine}); what neither can give stays unwritten, and is named + * ({@link #nameAPullNotGivenBack}). Text laid over the flow owes no * space ({@link #writeOverTheFlow}), so its edges move nothing.

*/ private void standsIntoTheSpaceAbove(DocumentNode node) { @@ -8992,10 +8993,55 @@ private void standsIntoTheSpaceAbove(DocumentNode node) { if (shortBy > 0.01 && node instanceof ParagraphNode paragraph) { shortBy -= takeFromTheLineAbove(shortBy, paragraph); if (shortBy > 0.01) { - riseInsideItsOwnLine(paragraph, shortBy); + shortBy -= riseInsideItsOwnLine(paragraph, shortBy); } } + // Only the block's own pull is its to name: what the space was short of before it is + // a container's edge above it, and a border below the block above is named apart + // (nameTheTailNotTaken), so it is not counted here. + double notGivenBack = Math.min(-edge, -(carriedSpacingBefore + pendingSpacingAfter)); + if (notGivenBack > TAIL_CLEARANCE && !spaceAboveWrittenElsewhere(node)) { + nameAPullNotGivenBack(node, notGivenBack); + } + } + } + + /** + * Whether the space Word writes above a block is not the space owed here, so a pull reckoned + * against it would be named wrong: a band's layer or a row of columns' later layer resumes at + * the page's distance from what is above it ({@link #resumeHere}), and a block opening a page in + * the flow — the document's first among them — has the space above it held there or dropped, as + * Word drops it. In a table cell the space owed is the cell's own, on whatever page. + */ + private boolean spaceAboveWrittenElsewhere(DocumentNode node) { + return !Double.isNaN(resumeSpacing) || opensItsPageInTheFlow(node) + || currentCell == null && (startsAPageOfItsOwn(node) || lastEndPage < 0); + } + + /** + * Names what of a block's top edge below zero nothing above it gives back, which Word does not + * write: a paragraph's space above is never below zero. + * + *

In the flow the block and what follows it stand that much lower, as under a pull of the + * block above ({@link #nameTheTailNotTaken}). In a table cell the page sets the cell's content + * that much shorter: the block stands up to that much lower, by as much less as the cell's + * alignment takes up, and where the cell sets its row's height the row is that much taller. + * {@code TealPulse}'s contact strip pulls each pair 4.32pt up in its centred row, a 2.16pt lift on + * the page, past the room its line holds above an icon's: in Word they stood 1.2pt lower. Written + * as space below them, which shifts a centred paragraph as much, the pairs rose 0.8pt and their + * row grew past the page's, the page under it 2.2 to 2.8pt lower (measured).

+ * + * @param pull what of the pull is not given back, in points + */ + private void nameAPullNotGivenBack(DocumentNode node, double pull) { + if (currentCell == null) { + nameTheTailNotTaken(node, 0, 0, 0, pull); + return; } + report.add(DocxExportReport.Severity.APPROXIMATED, "space above", layout.pathOf(node), + "its top edge below zero pulls it " + pointsOf(pull) + "pt further up than anything above it in " + + "its table cell gives back, which Word does not write: it stands up to that much lower than the " + + "page sets it, and where its cell sets its row's height, the row is that much taller"); } /** @@ -9082,25 +9128,28 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) { /** * Rises a one-line paragraph inside its own line by up to {@code points}, where the room above * its letters allows, as {@link #riseIntoItsLine} does for a container's first line. + * + * @return how far it rose, in points */ - private void riseInsideItsOwnLine(ParagraphNode paragraph, double points) { + private double riseInsideItsOwnLine(ParagraphNode paragraph, double points) { if (layout.lineCount(paragraph) != 1 || stackedLineHeights.containsKey(paragraph)) { - return; + return 0; } java.util.OptionalDouble own = layout.lineHeight(paragraph); java.util.Optional line = layout.firstLine(paragraph); double[] ink = inkOf(paragraph); if (own.isEmpty() || line.isEmpty() || ink == null) { - return; + return 0; } double aboveTheLetters = line.get().lineHeight() - line.get().baselineOffsetFromBottom() - ink[0] - DocxStackedLines.INK_MARGIN; double taken = Math.min(points, aboveTheLetters); if (!(taken > 0.01) || taken >= own.getAsDouble()) { - return; + return 0; } stackedLineHeights.put(paragraph, new DocxStackedLines.Line(own.getAsDouble() - taken, -taken, 0)); carriedSpacingBefore += taken; + return taken; } /** 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 516218706..fe945c487 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 @@ -114,6 +114,11 @@ record Entry(Fate fate, String note) { + "first line takes, on the panel; below, what neither the space " + "below it nor its padding takes, on the block below " + "(space above); " + BORDER_WIDTH + "; any other is written"; + // A paragraph's, a page reference's or a rule's own edge below zero is taken out of what is + // round it (standsIntoTheSpaceAbove); Word has no space above below zero. + private static final String PULLED_UP = "anywhere else, a top below zero out of the space above it and, a " + + "paragraph's, the foot of the one line above and the top of its own " + + "line, as far as they give; what none gives, named (space above)"; // An edge below zero pulls the next block up; Word sets no block over another. private static final String PULL = "below zero below it, the pull the space under it and the next block's own " + "top edge do not give back, on the next block (space above); any other is " @@ -152,6 +157,10 @@ record Entry(Fate fate, String note) { static final String TOP_PULL = "a top below zero: on a picture, a barcode or a block written as a table, a " + "table, a row or a panel, not written; on another block, what the space above " + "it and the lines round it cannot give, not written; neither named"; + static final String PULL_UNRECKONED = "a top below zero on the first block of a band's layer or of a row of " + + "columns' later layer, which resumes at the page's distance from what is " + + "above it, or on a block opening a page in the flow, the document's first " + + "among them: not reckoned against the space Word writes there, not named"; static final String BOTTOM_PULL = "a bottom below zero on a list, a table, a row, a stack, a canvas, a shape " + "container in the flow or a block with no paint of its own: the next block " + "not pulled up by it, set that much lower, not named"; @@ -187,7 +196,7 @@ record Entry(Fate fate, String note) { /** The known cases: a gap's note opens with one of them, or the ledger fails. */ private static final List GAPS = List.of(TOP_PULL, BOTTOM_PULL, PADDING_BELOW_ZERO, SPACE_BELOW_AT_THE_FOOT, - CELL_PADDING, LIST_PICTURE, SEAT_UNREAD, ROW_COLUMNS_UNLAID, DRAWING_OVER_TEXT, + PULL_UNRECKONED, CELL_PADDING, LIST_PICTURE, SEAT_UNREAD, ROW_COLUMNS_UNLAID, DRAWING_OVER_TEXT, COMPOSED_STACK_LAYER, LAYER_PAST_THE_EDGE, LAYER_UNLAID, PANEL_WIDER_THAN_ITS_CELL); private static final Map, Map> NODES = new LinkedHashMap<>(); @@ -281,8 +290,8 @@ record Entry(Fate fate, String note) { "stroke:REPORTED:its translucency, as a rule's, flattened against the colour under it, and as a " + "rule, " + BORDER_WIDTH + "; drawn, its alpha is written; any other is written", "linkTarget:REPORTED", - "bookmarkOptions:REPORTED", "padding:GAP:" + TOP_PULL + "; any other is written", - "margin:GAP:" + TOP_PULL + "; any other is written", "transform:REPORTED", + "bookmarkOptions:REPORTED", "padding:GAP:" + PULL_UNRECKONED + "; as a rule, " + PULLED_UP + "; any other is written", + "margin:GAP:" + PULL_UNRECKONED + "; as a rule, " + PULLED_UP + "; any other is written", "transform:REPORTED", "dashPattern:REPORTED", "anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a rule's other than butt, a border ending flat; drawn, it is written", "fillWidth:WRITTEN", "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in"); @@ -321,8 +330,8 @@ record Entry(Fate fate, String note) { node(PageReferenceNode.class, "name:INERT", "anchor:REPORTED:where the anchor has no bookmark, its number is written as text", "textStyle:WRITTEN", "align:WRITTEN", "placeholderText:WRITTEN", - "padding:GAP:" + TOP_PULL + "; the sides its alignment sets it from", - "margin:GAP:" + TOP_PULL + "; the sides its alignment sets it from"); + "padding:GAP:" + PULL_UNRECKONED + "; " + PULLED_UP + "; the sides its alignment sets it from", + "margin:GAP:" + PULL_UNRECKONED + "; " + PULLED_UP + "; the sides its alignment sets it from"); node(ParagraphNode.class, "name:INERT", "text:REPORTED:where the page reads it as markdown, written as the page sets it where its lines " + "hold the pieces read so, a heading written taller than its line named; its marks written as letters " @@ -339,7 +348,8 @@ record Entry(Fate fate, String note) { "bookmarkOptions:REPORTED:a title that is not the text Word lists it by, a pair's whole line for " + "a side of one; a level past Word's ninth that shares it with another; the right side's of a " + "pair whose left holds the level", - "padding:GAP:" + TOP_PULL + "; " + PULL, "margin:GAP:" + TOP_PULL + "; " + PULL, + "padding:GAP:" + PULL_UNRECKONED + "; " + PULLED_UP + "; " + PULL, + "margin:GAP:" + PULL_UNRECKONED + "; " + PULLED_UP + "; " + PULL, "autoSize:REPORTED:where the layout does not tell the size the page fits the text in the " + "paragraph's style to — no lines read, lines in sizes that do not say which is the " + "paragraph's, or one paragraph added at more than one place — the text written at its " diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java index a8342cdca..a3f91cb5c 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java @@ -651,14 +651,20 @@ void aHangATableBelowDoesNotTakeIsNamedOnTheTable() throws Exception { @Test void aParagraphPulledUpStandsBelowNoBorder() throws Exception { // An edge below zero pulls a paragraph up, as TealPulse's contact pairs are in their - // strip: it is no border below a panel, and nothing is named for it. + // strip: it is no border below a panel. What of the pull nothing gives back is named as + // the pull it is, the border nowhere. Exported exported = export(page -> page.addParagraph("Above") .addParagraph(p -> p.text("Pulled").margin(new DocumentInsets(-4, 0, 0, 0))) .addRow(row -> row.addParagraph(p -> p.text("Lifted").margin(new DocumentInsets(-4, 0, 0, 0))) .addParagraph("Beside")) .addParagraph("Below")); - assertThat(exported.report().bySubject()).doesNotContainKey("space above"); + List notes = exported.report().bySubject().get("space above"); + assertThat(notes).hasSize(2).allSatisfy(note -> assertThat(note.detail()).doesNotContain("border")); + assertThat(notes.get(0).path()).as("in the flow").doesNotContain("RowNode"); + assertThat(notes.get(0).detail()).contains("over the block above it"); + assertThat(notes.get(1).path()).as("in the row's cell").contains("RowNode"); + assertThat(notes.get(1).detail()).startsWith("its top edge below zero pulls it 4pt further up"); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java new file mode 100644 index 000000000..5dbb7c3c8 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java @@ -0,0 +1,214 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.RowVerticalAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A paragraph's, a page reference's or a rule's top edge below zero pulls it up into what is + * above it. Word has no space above below zero, so the export takes the pull out of the space + * owed above, and a paragraph's also off the foot of the line above and the top of its own line; + * what none of them gives is not written, and the report names it, though not yet on a band's + * later layer or a block opening a page, where the space Word writes is reckoned elsewhere. + * + *

The paragraphs here run to two lines on a 160pt column, so neither line can give anything: + * only a one-line paragraph rises inside its line, and only one above gives its foot.

+ */ +class DocxTopEdgePullTest { + + private static final String SUBJECT = "space above"; + private static final String TWO_LINES = "Alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo"; + private static final Pattern IN_THE_FLOW = Pattern.compile( + "^it stands (\\d+(?:\\.\\d+)?)pt lower than the page sets it, and what follows with it: the page sets " + + "it (\\d+(?:\\.\\d+)?)pt over the block above it, and Word sets no block over another$"); + private static final Pattern IN_A_CELL = Pattern.compile( + "^its top edge below zero pulls it (\\d+(?:\\.\\d+)?)pt further up than anything above it in its " + + "table cell gives back, which Word does not write: it stands up to that much lower than the page " + + "sets it, and where its cell sets its row's height, the row is that much taller$"); + + @Test + void aParagraphPulledPastTheSpaceAboveItIsNamedWithWhatFollows() throws Exception { + // 6pt up out of a 2pt gap: 4pt the page sets it over the paragraph above. + List notes = notes(page -> page + .addParagraph(p -> p.name("Above").text(TWO_LINES).margin(new DocumentInsets(0, 0, 2, 0))) + .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-6, 0, 0, 0)))); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED); + assertThat(notes.get(0).path()).contains("Pulled"); + Matcher flow = IN_THE_FLOW.matcher(notes.get(0).detail()); + assertThat(flow.matches()).as("the note: %s", notes.get(0).detail()).isTrue(); + assertThat(Double.parseDouble(flow.group(1))).isCloseTo(4, within(0.01)); + } + + @Test + void aPullTheSpaceAboveGivesBackIsNotNamed() throws Exception { + assertThat(notes(page -> page + .addParagraph(p -> p.name("Above").text(TWO_LINES).margin(new DocumentInsets(0, 0, 10, 0))) + .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-6, 0, 0, 0))))) + .isEmpty(); + } + + @Test + void aPullTheLineAboveGivesBackIsNotNamed() throws Exception { + // The one line above holds more than half a point below its letters, and gives that. + assertThat(notes(page -> page + .addParagraph(p -> p.name("Above").text("Above")) + .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-0.5, 0, 0, 0))))) + .isEmpty(); + } + + @Test + void aOneLineParagraphThatRisesInsideItsLineIsNotNamed() throws Exception { + // Letters with nothing over their x-height leave room at the top of their line, in a face + // whose letters the export reads, and give it. + DocumentTextStyle lato = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build(); + assertThat(notes(page -> page + .addParagraph(p -> p.name("Above").text(TWO_LINES)) + .addParagraph(p -> p.name("Pulled").text("ace ace").textStyle(lato) + .margin(new DocumentInsets(-1, 0, 0, 0))))) + .isEmpty(); + } + + @Test + void aRulePulledPastTheSpaceAboveItIsNamed() throws Exception { + List notes = notes(page -> page + .addParagraph(p -> p.name("Above").text(TWO_LINES)) + .addLine(line -> line.name("Rule").horizontal(100).thickness(1) + .color(DocumentColor.rgb(0, 0, 0)).margin(new DocumentInsets(-3, 0, 0, 0)))); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).path()).contains("Rule"); + Matcher flow = IN_THE_FLOW.matcher(notes.get(0).detail()); + assertThat(flow.matches()).as("the note: %s", notes.get(0).detail()).isTrue(); + assertThat(Double.parseDouble(flow.group(1))).isCloseTo(3, within(0.01)); + } + + @Test + void aParagraphOpeningItsCellIsNamedAsItsCellSetsIt() throws Exception { + // A row centring a paragraph pulled 4pt up beside a taller one: nothing above it in its cell. + List notes = notes(page -> page.addRow("Strip", row -> row + .verticalAlign(RowVerticalAlign.CENTER) + .addParagraph(p -> p.name("Tall").text(TWO_LINES + " " + TWO_LINES)) + .addParagraph(p -> p.name("Pair").text(TWO_LINES).margin(new DocumentInsets(-4, 0, 0, 0))))); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).path()).contains("Pair"); + Matcher cell = IN_A_CELL.matcher(notes.get(0).detail()); + assertThat(cell.matches()).as("the note: %s", notes.get(0).detail()).isTrue(); + assertThat(Double.parseDouble(cell.group(1))).isCloseTo(4, within(0.01)); + } + + @Test + void onlyTheBlocksOwnPullIsNamedOnIt() throws Exception { + // A section pulled 3pt up opens with a paragraph pulled 2pt more: the paragraph's note + // names its own 2pt, the section's edge being the section's. + List notes = notes(page -> page + .addParagraph(p -> p.name("Above").text(TWO_LINES)) + .addSection("Pulling", section -> section.margin(new DocumentInsets(-3, 0, 0, 0)) + .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-2, 0, 0, 0))))); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).path()).contains("Pulled"); + Matcher flow = IN_THE_FLOW.matcher(notes.get(0).detail()); + assertThat(flow.matches()).as("the note: %s", notes.get(0).detail()).isTrue(); + assertThat(Double.parseDouble(flow.group(1))).isCloseTo(2, within(0.01)); + } + + @Test + void aBorderBelowThePanelAboveIsNotCountedInThePull() throws Exception { + // 10pt up out of a 6pt gap under a stroked card: 4pt of pull, the card's border below + // being the card's, named on its own. + List notes = notes(page -> page.spacing(6) + .addSection("Card", card -> card.stroke(com.demcha.compose.document.style.DocumentStroke.of( + DocumentColor.rgb(0, 0, 0), 2)) + .addParagraph(p -> p.text(TWO_LINES))) + .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-10, 0, 0, 0)))); + + List pulls = notes.stream() + .map(note -> IN_THE_FLOW.matcher(note.detail())) + .filter(Matcher::matches) + .map(flow -> Double.parseDouble(flow.group(2))) + .toList(); + assertThat(pulls).as("the pull over the block above, and no more").singleElement() + .satisfies(pull -> assertThat(pull).isCloseTo(4, within(0.01))); + } + + @Test + void aPullOnABandsLaterLayerIsNotReckonedHere() throws Exception { + // A later layer resumes at the page's distance from the layers above it, which is not the + // space owed here. + assertThat(notes(page -> page.addParagraph("Above").addLayerStack(stack -> stack + .topLeft(new com.demcha.compose.document.dsl.ParagraphBuilder().text("Opening").build()) + .bottomLeft(new com.demcha.compose.document.dsl.ParagraphBuilder().name("Caption").text("Caption") + .margin(new DocumentInsets(-4, 0, 0, 0)).build())))).isEmpty(); + } + + @Test + void aParagraphOpeningACellOnALaterPageIsNamed() throws Exception { + // In a table cell the space owed is the cell's own, though the row opens the page. + List notes = notes(page -> { + page.addParagraph(TWO_LINES).addPageBreak(pageBreak -> { }); + page.addRow("Strip", row -> row + .addParagraph(p -> p.name("Tall").text(TWO_LINES + " " + TWO_LINES)) + .addParagraph(p -> p.name("Pair").text(TWO_LINES).margin(new DocumentInsets(-4, 0, 0, 0)))); + }); + + assertThat(notes).hasSize(1); + assertThat(notes.get(0).path()).contains("Pair"); + Matcher cell = IN_A_CELL.matcher(notes.get(0).detail()); + assertThat(cell.matches()).as("the note: %s", notes.get(0).detail()).isTrue(); + assertThat(Double.parseDouble(cell.group(1))).isCloseTo(4, within(0.01)); + } + + @Test + void aParagraphInAPanelMovedToTheNextPageIsNamed() throws Exception { + // A panel kept whole that does not fit under the paragraphs above moves to the next page; + // in its cell the space owed is the cell's own. + List notes = notes(page -> { + for (int index = 0; index < 8; index++) { + page.addParagraph(TWO_LINES); + } + page.addSection("Card", card -> card.fillColor(DocumentColor.rgb(238, 243, 249)).keepTogether() + .addParagraph(p -> p.name("Title").text(TWO_LINES)) + .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-4, 0, 0, 0))) + .addParagraph(p -> p.text(TWO_LINES))); + }); + + assertThat(notes).extracting(DocxExportReport.Note::path).anySatisfy(path -> assertThat(path).contains("Pulled")); + } + + @Test + void aPullOnTheDocumentsFirstBlockIsNotReckonedHere() throws Exception { + assertThat(notes(page -> page + .addParagraph(p -> p.name("First").text(TWO_LINES).margin(new DocumentInsets(-4, 0, 0, 0))))) + .isEmpty(); + } + + @Test + void anEdgeAtZeroOrAboveIsNotNamed() throws Exception { + assertThat(notes(page -> page + .addParagraph(p -> p.name("Above").text(TWO_LINES)) + .addParagraph(p -> p.name("Below").text(TWO_LINES).margin(new DocumentInsets(3, 0, 0, 0))))) + .isEmpty(); + } + + /** The space-above notes of an export the layout stands behind. */ + private static List notes(Consumer content) throws Exception { + DocxExportReport report = DocxExports.report(200, 400, 20, content); + assertThat(report.bySubject()).as("laid out").doesNotContainKey("measured geometry"); + return report.bySubject().getOrDefault(SUBJECT, List.of()); + } +} From 432e18ec894b6bef6316d0e77176b33b78c2ad61 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 22 Oct 2026 10:27:18 +0100 Subject: [PATCH 2/2] fix(docx): narrow the pull note to where the space above is reckoned A block after a page break opens its page with nothing above it there: its pull is no longer named as set over a block above. A side of an overlay's line pair takes its pull out of the space above alone and is not named; the ledgers, the recipe, the matrix and the CHANGELOG say so, and the value ledger's top inset carries the narrowed gap too. riseInsideItsOwnLine keeps its void shape, the remainder being read from the space owed. --- CHANGELOG.md | 9 +++-- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 8 +++-- .../semantic/docx/DocxSemanticBackend.java | 35 +++++++++++-------- .../docx/DocxNodeFieldLedgerTest.java | 6 ++-- .../semantic/docx/DocxTopEdgePullTest.java | 34 +++++++++++++----- .../docx/DocxValueFieldLedgerTest.java | 4 ++- 7 files changed, 65 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc915db7..18f95c53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,12 @@ follow semantic versioning; release dates are ISO 8601. which is named apart. In the flow, the block and what follows it stand that much lower. In a table cell, the block stands up to that much lower, and where the cell sets its row's height the row is that much taller. - - Not reckoned yet, and not named: the first block of a band's layer or of a row of columns' - later layer, which resumes at the page's distance from what is above it, and a block opening - a page in the flow. + - Not reckoned yet, and not named: + - the first block of a band's layer or of a row of columns' later layer, which resumes at the + page's distance from what is above it; + - a block opening a page in the flow: a section's first, one the layout moves to a new page, + or one after a page break; + - a side of an overlay's line pair, which takes its pull out of the space above alone. - Measured in Word on Windows: `TealPulse`'s contact pairs are pulled 4.32pt up in a centred row, a 2.16pt lift on the page, past what a line holding an icon gives. They stood 1.2pt lower. Written as space below them, which shifts a centred paragraph as much, they rose diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 8786b55e1..8be85c641 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -66,7 +66,7 @@ Payload records live in `core` under | Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| -| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; a top edge below zero comes out of the space owed above the paragraph, the foot of the one line above it and the top of its own line, and what none gives is not written and is reported (`space above`), the paragraph and what follows that much lower in the flow, and in a table cell up to that much, its row as much taller where the cell sets the row's height — not yet on the first block of a band's layer or of a row of columns' later layer, or on a block opening a page in the flow; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level | +| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a character the page draws nothing for (Unicode category C but the direction marks and joiners) is not written; a margin below zero sets a paragraph's, a list's or a block's text out past that side in the flow as the page does, in a table cell no further out than the cell's text, what is held back reported; a top edge below zero comes out of the space owed above the paragraph, the foot of the one line above it and the top of its own line, and what none gives is not written and is reported (`space above`), the paragraph and what follows that much lower in the flow, and in a table cell up to that much, its row as much taller where the cell sets the row's height — not yet on the first block of a band's layer or of a row of columns' later layer, on a block opening a page in the flow (a section's first, one moved to a new page, one after a page break), or on a side of an overlay's line pair, which takes its pull out of the space above alone; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it — a line pair's shared line by the lesser of its halves' calls where neither is moved by its position — in the flow, in cells, in a stack of one layer, which is the flow, and in a band, which takes back the raise of its last line before its next layer and below itself, not in another overlay nor a band's first paragraph or item where a band in the flow opens its page, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; a line of its own Word sets a tenth of a point or more, and under half a point, high is lowered by what is left of the space between it and what follows once the block under it has taken its own of it — its line gap, its raise, its pictures' room, a row's lift out of the space above its table — above that block, or written below the line before a table or at a cell's end, nothing under it moved (not where the next block is on a later page or in an overlay, nor at a page break, a block moved to the next page or a column's later layer, and where the section's end leaves a cell's space below out, the lowering it gave is taken back with it; not a line cut to fit, opening its page or a panel, not on one page, or in an overlay other than a stack of one layer, nor a list's item, lowered by its own list); what that space cannot give, and a line Word sets half a point or more high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, but lowered, where Word sets it high, by the space above it, as far as the items under it and what follows the list give that back, and taken back where the hang below the list is let go, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; the lines of a paragraph seated off its baseline that take another seat than its first, from half a point; a picture on a later line, set by the first line's measure, from half a point; pictures beside text standing on the baseline where the lines are not read; an outline title that is not the text Word lists, a level past the ninth that shares it with another, and the right side's entry where the left holds the line's level | | List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items, a drawn marker or a marker holding a percent sign before a digit, which Word reads in a level's text as a level's number, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell that its table's lines are not matched to, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named | | Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius, the padding above and below the letters and a padding below zero on any side are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; a picture on a later line of another height is set by that measure too, and where the paragraph's lines are not read, nothing moves a picture beside text off the baseline: a paragraph's note names either, not a list item's; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index bcbeaee93..d4b082ef0 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -245,9 +245,11 @@ can give is not written, and the report names the block's own pull (`space above below the block above named apart. In the flow the block and what follows it stand that much lower. In a table cell the page sets the cell's content that much shorter, so the block stands up to that much lower, and where the cell sets its row's height the row is that much taller. -The first block of a band's layer or of a row of columns' later layer, which resumes at the -page's distance from what is above it, and a block opening a page in the flow are not reckoned -this way yet, and not named. Measured: `TealPulse`'s +Not reckoned this way yet, and not named: the first block of a band's layer or of a row of +columns' later layer, which resumes at the page's distance from what is above it; a block +opening a page in the flow — a section's first, one the layout moves to a new page, one after a +page break; and a side of an overlay's line pair, which takes its pull out of the space above +alone. Measured: `TealPulse`'s contact pairs, pulled 4.32pt up in a centred row past what a line holding an icon gives, a 2.16pt lift on the page, stood 1.2pt lower in Word; written as space below them instead, they rose 0.8pt and grew their row past the page's, the page under it 2.2 to 2.8pt lower. Text laid 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 48e7e7697..e42929e12 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 @@ -547,6 +547,9 @@ public final class DocxSemanticBackend implements SemanticBackend { */ private long blocksWritten; + /** {@link #blocksWritten} once the last page break was written: equal, nothing has been since. */ + private long blocksAtTheLastBreak = -1; + /** Space the last body paragraph holds below itself, not yet written — see {@link #owePendingSpacingAfter}. */ private double pendingSpacingAfter; @@ -1149,6 +1152,7 @@ private byte[] write(List sections, Path outputFile) throws Exc writing = null; currentPage = 0; lastEndPage = -1; + blocksAtTheLastBreak = -1; topEdgeHeldAbove = null; topEdgeHeld = 0; topEdgeLine = null; @@ -8982,7 +8986,9 @@ private static boolean drawn(CTBorder border) { * Where that cannot give it all, a paragraph takes the rest off the foot of the line written * just before it ({@link #takeFromTheLineAbove}), then off the top of its own line * ({@link #riseInsideItsOwnLine}); what neither can give stays unwritten, and is named - * ({@link #nameAPullNotGivenBack}). Text laid over the flow owes no + * ({@link #nameAPullNotGivenBack}) where the space above it is the space owed here + * ({@link #spaceAboveWrittenElsewhere}). A side of an overlay's line pair is not this: it + * takes its pull out of the space above alone ({@link #writeLinePair}). Text laid over the flow owes no * space ({@link #writeOverTheFlow}), so its edges move nothing.

*/ private void standsIntoTheSpaceAbove(DocumentNode node) { @@ -8993,7 +8999,7 @@ private void standsIntoTheSpaceAbove(DocumentNode node) { if (shortBy > 0.01 && node instanceof ParagraphNode paragraph) { shortBy -= takeFromTheLineAbove(shortBy, paragraph); if (shortBy > 0.01) { - shortBy -= riseInsideItsOwnLine(paragraph, shortBy); + riseInsideItsOwnLine(paragraph, shortBy); } } // Only the block's own pull is its to name: what the space was short of before it is @@ -9010,12 +9016,14 @@ private void standsIntoTheSpaceAbove(DocumentNode node) { * Whether the space Word writes above a block is not the space owed here, so a pull reckoned * against it would be named wrong: a band's layer or a row of columns' later layer resumes at * the page's distance from what is above it ({@link #resumeHere}), and a block opening a page in - * the flow — the document's first among them — has the space above it held there or dropped, as - * Word drops it. In a table cell the space owed is the cell's own, on whatever page. + * the flow — a section's first, one the layout moves to a new page, one after a page break — + * has the space above it held there or dropped, as Word drops it, with no block above it on + * the page. In a table cell the space owed is the cell's own, on whatever page. */ private boolean spaceAboveWrittenElsewhere(DocumentNode node) { return !Double.isNaN(resumeSpacing) || opensItsPageInTheFlow(node) - || currentCell == null && (startsAPageOfItsOwn(node) || lastEndPage < 0); + || currentCell == null && (startsAPageOfItsOwn(node) || lastEndPage < 0 + || blocksWritten == blocksAtTheLastBreak); } /** @@ -9024,8 +9032,9 @@ private boolean spaceAboveWrittenElsewhere(DocumentNode node) { * *

In the flow the block and what follows it stand that much lower, as under a pull of the * block above ({@link #nameTheTailNotTaken}). In a table cell the page sets the cell's content - * that much shorter: the block stands up to that much lower, by as much less as the cell's - * alignment takes up, and where the cell sets its row's height the row is that much taller. + * that much shorter: the block stands up to that much lower, less where the cell sets its + * content at the middle or the foot, and where the cell sets its row's height the row is that + * much taller. * {@code TealPulse}'s contact strip pulls each pair 4.32pt up in its centred row, a 2.16pt lift on * the page, past the room its line holds above an icon's: in Word they stood 1.2pt lower. Written * as space below them, which shifts a centred paragraph as much, the pairs rose 0.8pt and their @@ -9128,28 +9137,25 @@ private double takeFromTheLineAbove(double points, ParagraphNode pulled) { /** * Rises a one-line paragraph inside its own line by up to {@code points}, where the room above * its letters allows, as {@link #riseIntoItsLine} does for a container's first line. - * - * @return how far it rose, in points */ - private double riseInsideItsOwnLine(ParagraphNode paragraph, double points) { + private void riseInsideItsOwnLine(ParagraphNode paragraph, double points) { if (layout.lineCount(paragraph) != 1 || stackedLineHeights.containsKey(paragraph)) { - return 0; + return; } java.util.OptionalDouble own = layout.lineHeight(paragraph); java.util.Optional line = layout.firstLine(paragraph); double[] ink = inkOf(paragraph); if (own.isEmpty() || line.isEmpty() || ink == null) { - return 0; + return; } double aboveTheLetters = line.get().lineHeight() - line.get().baselineOffsetFromBottom() - ink[0] - DocxStackedLines.INK_MARGIN; double taken = Math.min(points, aboveTheLetters); if (!(taken > 0.01) || taken >= own.getAsDouble()) { - return 0; + return; } stackedLineHeights.put(paragraph, new DocxStackedLines.Line(own.getAsDouble() - taken, -taken, 0)); carriedSpacingBefore += taken; - return taken; } /** @@ -19917,6 +19923,7 @@ private void writePageBreak(XWPFDocument document) { flushSpacingAfter(); XWPFParagraph para = document.createParagraph(); blocksWritten++; + blocksAtTheLastBreak = blocksWritten; // It stands on the page the break ends, and carries what is drawn there when nothing else // written on that page does (DocxDrawingAnchors.breakOn): a shape alone before a break // was dropped, its note saying it was drawn. 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 fe945c487..677a0a4d3 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 @@ -159,8 +159,10 @@ record Entry(Fate fate, String note) { + "it and the lines round it cannot give, not written; neither named"; static final String PULL_UNRECKONED = "a top below zero on the first block of a band's layer or of a row of " + "columns' later layer, which resumes at the page's distance from what is " - + "above it, or on a block opening a page in the flow, the document's first " - + "among them: not reckoned against the space Word writes there, not named"; + + "above it; on a block opening a page in the flow, a section's first, one " + + "the layout moves to a new page or one after a page break; or on a side " + + "of an overlay's line pair, which takes it out of the space above alone: " + + "not reckoned against the space Word writes there, not named"; static final String BOTTOM_PULL = "a bottom below zero on a list, a table, a row, a stack, a canvas, a shape " + "container in the flow or a block with no paint of its own: the next block " + "not pulled up by it, set that much lower, not named"; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java index 5dbb7c3c8..b89db5e00 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTopEdgePullTest.java @@ -20,11 +20,12 @@ * A paragraph's, a page reference's or a rule's top edge below zero pulls it up into what is * above it. Word has no space above below zero, so the export takes the pull out of the space * owed above, and a paragraph's also off the foot of the line above and the top of its own line; - * what none of them gives is not written, and the report names it, though not yet on a band's - * later layer or a block opening a page, where the space Word writes is reckoned elsewhere. + * what none of them gives is not written, and the report names it, though not yet on a resumed + * layer's first block, on a block opening a page in the flow, or on a side of an overlay's line + * pair, where the space Word writes is reckoned elsewhere. * - *

The paragraphs here run to two lines on a 160pt column, so neither line can give anything: - * only a one-line paragraph rises inside its line, and only one above gives its foot.

+ *

The paragraphs here run to more than one line on a 160pt column, so neither line can give + * anything: only a one-line paragraph rises inside its line, and only one above gives its foot.

*/ class DocxTopEdgePullTest { @@ -137,13 +138,13 @@ void aBorderBelowThePanelAboveIsNotCountedInThePull() throws Exception { .addParagraph(p -> p.text(TWO_LINES))) .addParagraph(p -> p.name("Pulled").text(TWO_LINES).margin(new DocumentInsets(-10, 0, 0, 0)))); - List pulls = notes.stream() + // The figures as the report writes them, whole points without a fraction. + List pulls = notes.stream() .map(note -> IN_THE_FLOW.matcher(note.detail())) .filter(Matcher::matches) - .map(flow -> Double.parseDouble(flow.group(2))) + .map(flow -> flow.group(2)) .toList(); - assertThat(pulls).as("the pull over the block above, and no more").singleElement() - .satisfies(pull -> assertThat(pull).isCloseTo(4, within(0.01))); + assertThat(pulls).as("the pull over the block above, and no more").containsExactly("4"); } @Test @@ -187,7 +188,21 @@ void aParagraphInAPanelMovedToTheNextPageIsNamed() throws Exception { .addParagraph(p -> p.text(TWO_LINES))); }); - assertThat(notes).extracting(DocxExportReport.Note::path).anySatisfy(path -> assertThat(path).contains("Pulled")); + List pulled = notes.stream().filter(note -> note.path().contains("Pulled")).toList(); + assertThat(pulled).hasSize(1); + Matcher cell = IN_A_CELL.matcher(pulled.get(0).detail()); + assertThat(cell.matches()).as("the note: %s", pulled.get(0).detail()).isTrue(); + assertThat(cell.group(1)).as("all of it, nothing in the card giving it back").isEqualTo("4"); + } + + @Test + void aPullOnABlockAfterAPageBreakIsNotReckonedHere() throws Exception { + // Nothing stands above it on its page, and Word drops the space above at the page's top. + assertThat(notes(page -> page + .addParagraph(p -> p.name("Before").text(TWO_LINES)) + .addPageBreak(pageBreak -> { }) + .addParagraph(p -> p.name("After").text(TWO_LINES).margin(new DocumentInsets(-4, 0, 0, 0))))) + .isEmpty(); } @Test @@ -201,6 +216,7 @@ void aPullOnTheDocumentsFirstBlockIsNotReckonedHere() throws Exception { void anEdgeAtZeroOrAboveIsNotNamed() throws Exception { assertThat(notes(page -> page .addParagraph(p -> p.name("Above").text(TWO_LINES)) + .addParagraph(p -> p.name("Level").text(TWO_LINES).margin(new DocumentInsets(0, 0, 0, 0))) .addParagraph(p -> p.name("Below").text(TWO_LINES).margin(new DocumentInsets(3, 0, 0, 0))))) .isEmpty(); } 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 8c996f2d3..178277291 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 @@ -33,6 +33,7 @@ import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.LIST_PICTURE; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.PADDING_BELOW_ZERO; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.PANEL_WIDER_THAN_ITS_CELL; +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.ROW_COLUMNS_UNLAID; import static com.demcha.compose.document.backend.semantic.docx.DocxNodeFieldLedgerTest.SEAT_UNREAD; @@ -271,7 +272,8 @@ class DocxValueFieldLedgerTest { + "; a page reference's on the side its alignment sets it from; a page field's at its " + "sides; a picture's padding drawn beside its text or over its badge; a chip's its shading does not " + "hold; any other is written", - "top:GAP:" + TOP_PULL + "; " + PADDING_BELOW_ZERO + "top:GAP:" + TOP_PULL + "; " + PULL_UNRECKONED + "; a paragraph's, a rule's or a page reference's " + + "otherwise named (space above); " + PADDING_BELOW_ZERO + "; a chart's or a spacer's; a page field's beside other parts; a picture's padding drawn " + "beside its text or over its badge; a chip's, above its letters; any other is written"); value(com.demcha.compose.document.style.DocumentTransform.class,