From 441b776d1c808dc0e0b54f20e7fabddb3bd2c971 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 16:19:28 +0100 Subject: [PATCH 1/2] fix(docx): name in the report what a paragraph's own fields lose An auto-sized paragraph was written at its style's size, a bulletOffset prefix's letters were never written and some paths wrote no prefix at all, and Word's outline listed a heading by its paragraph's text, not by its bookmark title. The paragraph's note now names the size the page fits its text to, the prefix's letters and the room a path leaves out, and an outline title, level or pair-side entry the file does not carry. The written bytes do not change. --- CHANGELOG.md | 28 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 15 +- render-docx/README.md | 11 +- .../semantic/docx/DocxSemanticBackend.java | 205 +++++++++++- .../docx/DocxNodeFieldLedgerTest.java | 16 +- .../docx/DocxParagraphReportTest.java | 299 ++++++++++++++++++ 7 files changed, 563 insertions(+), 13 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 775f9ec0e..a0ce2feb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's report names what a paragraph's own fields lose.** Several of a paragraph's + own fields were lost without a note: + - an auto-sized paragraph's text was written at its style's size, not the one the page fits it + to; + - a `bulletOffset` prefix's letters were never written, and a paragraph written over the flow, + as a side of an overlay's left-and-right pair or as a badge's initials lost a blank prefix too; + - Word's outline listed a heading by the text of its Word paragraph, not by its + `DocumentBookmarkOptions` title, and at no level past the ninth; + - a line an overlay's left-and-right pair shares is one Word paragraph: Word listed both sides' + text under the left one's level, and the right one's entry was lost. + + The paragraph's note (`ParagraphNode`) now names: + - the size its text is written at and the size the page fits it to, where Word, which holds a + size to the half point, sets them apart. Both are read from the layout's lines; a prefix the + page sets in the paragraph's style counts as its text. Where the lines are not read or do not + tell the size, the note says the fitted size is not measured; + - its prefix's letters before its first line. On a path that writes no prefix, it also names + the room the prefix sets lines in by, where that moves a line: not where the line is set from + the end the prefix does not stand at; + - an outline title that is not the text Word lists, a level past Word's ninth, and the right + side's outline entry where the left side holds the line's level. + + A paragraph set in a text box over the flow carries these on the note it already had. A page + zone's paragraphs are not named yet: that stays a gap of the `zones` option. Across + the DOCX fidelity corpus the report names no paragraph: `EngineeringResume`'s auto-sized name + fits at its own size. None of this changes what is written: the 62 documents of the corpus + export to the same bytes. In `DocxNodeFieldLedgerTest` a paragraph's `autoSize`, + `bulletOffset` and `bookmarkOptions` move from a gap to `REPORTED`; 4 node-field gaps remain. - **A DOCX export's report names what a list's items lose.** A centred or right-aligned list was written flush left, a list's lineSpacing went unwritten wherever the layout's items were not its own, its continuationIndent was never written before a wrapped line, and an item at the diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 2af9ef112..9d0937ca0 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -63,7 +63,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 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, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; 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 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 | +| 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 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, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; 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 is written at its style's size, not the one the page fits it to; 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 written and the size the page fits the text to, to Word's half point; an outline title that is not the text Word lists, a level past the ninth, 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 or a drawn marker, 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, 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, and the rows the page draws as a marker alone for blank items of a flagged list, which are not written | | 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 and the padding above and below the letters 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 this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is 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; 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 69b1a7195..63fe6ad3c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,7 +67,7 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, or in a header or footer. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | +| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; outside a header or footer, the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where they differ. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | @@ -378,8 +378,10 @@ level))` — also carries Word's own `HeadingN` style, which is what fills the N Pane, the outline view and a generated table of contents. The style states the outline level and no formatting, so the paragraph keeps the look its author gave it and "restyle all headings" in Word still reaches it. Only the levels the document uses are defined, and -a level past Word's nine is clamped. A heading is never inferred from type size: a large -first line claims nothing about structure. +a level past Word's nine is clamped. Word's outline lists a heading by its text, so a +`DocumentBookmarkOptions` title that is not the paragraph's text is not in the file; the +report names it, and a level past the ninth. A heading is never inferred from type size: a +large first line claims nothing about structure. ## What a list becomes @@ -722,8 +724,11 @@ tint it was flattened to. That is recorded with the rest. takes that much out of the cell's top padding first, then — unless the cell is painted or framed — lifts the table out of the gap above it, as far as that gap goes, and the row's other cells start that much lower inside. Each paragraph - keeps its bookmark and outline level. Two texts closer than 4pt stay one after the - other. Drawing in the band is drawn as shapes where the page draws it. + keeps its bookmark; the line is the left paragraph's heading, or the right one's where the + left is none, listed in Word's outline by both texts, and the report names a title that + is not that line and a right side's outline entry beside a left one's. + Two texts closer than 4pt stay one after the other. Drawing in the band is drawn as shapes + where the page draws it. - **Shape containers → inline layers.** DOCX has no portable equivalent of a graphics-state path clip, so the container's layers are written inline, in source order, without clipping — again with one warning per diff --git a/render-docx/README.md b/render-docx/README.md index 7f2213245..d94dfa160 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -89,7 +89,7 @@ What maps: the shape. - **Links and navigation.** A link is a `w:hyperlink`, to an address or to one of the document's anchors; an `anchor(...)` is a bookmark; a `bookmark(...)` outline level is - Word's `HeadingN` style, so the paragraph is in the Navigation Pane. + Word's `HeadingN` style, so the paragraph is in the Navigation Pane, listed by its text. - **Horizontal rules.** A horizontal line or an `addDivider` bar is a paragraph border. - **Barcodes and QR codes** are pictures of the same matrix the PDF draws, so they scan. - **Charts** are a table of their data. @@ -119,6 +119,15 @@ What is not written — each one is named in the export report - the marker column and `markerGap` of an item at a stated column — a list that nests, or a gap too narrow for its marker — while a flat hanging-indent list keeps the page's column; - a row the page draws as a marker alone, for a blank item. +- **What a paragraph's own fields set where Word cannot hold it**, named in the export report on + the paragraph: + - the size an auto-sized paragraph's text is fitted to, where it is not its style's; + - the letters of a `bulletOffset` prefix, and the room a prefix sets lines in by, where it moves + one, in a paragraph written over the flow, as a side of an overlay's left-and-right pair or as + a badge's initials; + - an outline entry's title where it is not the text Word lists the heading by — for a side of + an overlay's pair, the whole line — a level past Word's ninth, and the right side's entry of a + pair whose left side holds the line's level. Multi-section documents export through `MultiSectionDocument.toDocxBytes()`, `writeDocx(...)` and `buildDocx(...)` (Experimental): each section becomes a Word section 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 7c9b22d07..014bcc800 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 @@ -2345,8 +2345,11 @@ private void writeTextOverTheFlow(XWPFDocument document, ParagraphNode node) { } anchors.queue(List.of(DocxDrawings.Shape.textBox(x, top, width + spare, height, placed.startPage(), paragraphXml(para)))); + List lost = new ArrayList<>(); + lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it"); + lost.addAll(paragraphLost(node, false, null)); report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), - "laid over the flow, which gives it no room: set in a text box where the page sets it"); + String.join("; ", lost)); } /** @@ -6316,6 +6319,14 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine int rightAnchor = openAnchor(para, pair.right().anchor()); writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false); closeAnchor(para, rightAnchor); + // A Word paragraph has one outline level, and its outline lists the whole line by it: the + // left side's level where it has one, the right side's where not; the other's is lost. + String line = collapsedWhitespace(outlineTextOf(pair.left()) + " " + outlineTextOf(pair.right())); + boolean leftHeads = headingLevelOf(pair.left()) != null; + reportWrittenWithout(pair.left(), "written as one side of a line it shares", + paragraphLost(pair.left(), false, leftHeads ? line : null)); + reportWrittenWithout(pair.right(), "written as one side of a line it shares", + paragraphLost(pair.right(), false, leftHeads ? null : line)); if ((pair.left().keepWithNext() && layout.onOnePage(pair.left())) || (pair.right().keepWithNext() && layout.onOnePage(pair.right()))) { para.setKeepNext(true); @@ -6706,6 +6717,197 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { closeAnchor(para, anchor); lastWrittenNode = node; lastWrittenParagraph = para; + reportWrittenWithout(node, "written as a paragraph", paragraphLost(node, true, outlineTextOf(node))); + } + + /** + * What a paragraph's own fields lose on the way to Word, whichever path wrote it: the size an + * auto-sized paragraph's text is fitted to, its {@code bulletOffset} and its outline entry. + * + *

Its text is written at its style's size, not the one the page fits it to. A prefix's + * letters are never written. A path that does not write a prefix as a distance + * ({@link #indentAsThePrefixDoes}) leaves out the room it sets lines in by, too, wherever that + * moves a line: not where the line is set from the end the prefix stands off. Word's outline + * lists a heading by the text of its Word paragraph, at no level past the ninth.

+ * + * @param node the paragraph written + * @param prefixAsIndent whether the path writes a blank prefix as the paragraph's indent + * @param listedAs the text Word's outline lists the paragraph's heading by, or {@code null} + * where the path writes no outline level for it + * @return the phrases, empty when nothing of these is lost + */ + private List paragraphLost(ParagraphNode node, boolean prefixAsIndent, String listedAs) { + List lost = new ArrayList<>(4); + String size = autoSizeLost(node); + if (size != null) { + lost.add(size); + } + String prefix = node.bulletOffset(); + if (setsAPrefixBeforeTheFirstLine(node) && !prefix.isBlank()) { + lost.add("its bulletOffset's letters, \"" + prefix.strip() + "\", are not written before its first line"); + } + if (!prefixAsIndent && laysOutAPrefix(node) && !setFromTheEndThePrefixStandsOff(node)) { + lost.add("the room its bulletOffset sets its lines in by is not written"); + } + DocumentBookmarkOptions outline = node.bookmarkOptions(); + if (outline != null) { + if (listedAs == null) { + lost.add("its outline entry is not written"); + } else { + if (!collapsedWhitespace(outline.title()).equals(listedAs)) { + lost.add("its outline entry shows \"" + listedAs + "\", not its title \"" + outline.title() + "\""); + } + if (outline.level() > MAX_HEADING_LEVEL) { + lost.add("its outline entry is written at Word's ninth level, where the page nests it deeper"); + } + } + } + return lost; + } + + /** + * What an auto-sized paragraph's text loses of its size: written at its style's, where the + * page fits it to another Word holds apart, to the half point; or {@code null} when it does + * not, or nothing laid out takes the paragraph's style — a run with a style of its own keeps + * its size on the page. + */ + private String autoSizeLost(ParagraphNode node) { + if (node.autoSize() == null || !holdsTextInTheParagraphsStyle(node) && !laysOutAPrefix(node)) { + return null; + } + double written = node.textStyle().size(); + double fitted = fittedSize(node); + if (Double.isNaN(fitted)) { + return "its text is written at " + pointsOf(written) + "pt — the size the page fits it to is not measured"; + } + return wordsSize(fitted) != wordsSize(written) + ? "its text is written at " + pointsOf(written) + "pt, where the page fits it to " + pointsOf(fitted) + "pt" + : null; + } + + /** Whether any of a paragraph's text takes the paragraph's style: its plain text, or a run with no style of its own. */ + private static boolean holdsTextInTheParagraphsStyle(ParagraphNode node) { + if (node.inlineRuns().isEmpty()) { + return !node.text().isBlank(); + } + for (InlineRun run : node.inlineRuns()) { + InlineTextRun text = textOf(run); + if (text != null && text.textStyle() == null && !text.text().isBlank()) { + return true; + } + } + return false; + } + + /** + * Whether the page lays out a paragraph's prefix, in the paragraph's style: before a first line + * that holds something, or before the lines after it where it lays out more than one or its + * lines are not read. + */ + private boolean laysOutAPrefix(ParagraphNode node) { + DocumentTextIndent strategy = node.indentStrategy(); + return setsAPrefixBeforeTheFirstLine(node) + || !node.bulletOffset().isEmpty() + && (strategy == DocumentTextIndent.FROM_SECOND_LINE || strategy == DocumentTextIndent.ALL_LINES) + && layout.lineCount(node) != 1; + } + + /** Whether the page sets a paragraph's prefix before its first line, which holds something. */ + private static boolean setsAPrefixBeforeTheFirstLine(ParagraphNode node) { + DocumentTextIndent strategy = node.indentStrategy(); + return !node.bulletOffset().isEmpty() + && (strategy == DocumentTextIndent.FIRST_LINE || strategy == DocumentTextIndent.ALL_LINES) + && firstLineHoldsSomething(node); + } + + /** + * Whether a paragraph's lines are set from the end its prefix does not stand at, where the + * prefix moves none of their text: aligned right, or left in a right-to-left paragraph. + */ + private static boolean setFromTheEndThePrefixStandsOff(ParagraphNode node) { + return ParagraphDirection.resolve(node) == TextDirection.RTL + ? node.align() == TextAlign.LEFT : node.align() == TextAlign.RIGHT; + } + + /** + * The size the page sets a paragraph's text in where it takes the paragraph's style, read off + * its laid-out lines; {@code NaN} when they are not read, or do not tell it. A run with a style + * of its own is laid out at that style's size, so the paragraph's is a size no such run has + * or, where every size laid out is one, that one. + */ + private double fittedSize(ParagraphNode node) { + java.util.Set ownSizes = new java.util.HashSet<>(); + for (InlineRun run : node.inlineRuns()) { + InlineTextRun text = textOf(run); + if (text != null && text.textStyle() != null) { + ownSizes.add(text.textStyle().size()); + } + } + java.util.Set laidOut = new java.util.LinkedHashSet<>(); + for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) { + for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) { + if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan text + && text.width() > 0 && text.textStyle() != null) { + laidOut.add(text.textStyle().size()); + } + } + } + for (double size : laidOut) { + if (!ownSizes.contains(size)) { + return size; + } + } + return laidOut.size() == 1 ? laidOut.iterator().next() : Double.NaN; + } + + /** + * Whether a paragraph's first line holds anything, which the page sets a prefix before: an + * empty one, ended by a line break at once, it sets none before. + */ + private static boolean firstLineHoldsSomething(ParagraphNode node) { + if (node.inlineRuns().isEmpty()) { + return startsALine(node.text()); + } + for (InlineRun run : node.inlineRuns()) { + // A picture, a shape or a chip: the node keeps no chip without text. + if (!(run instanceof InlineTextRun text)) { + return true; + } + if (!text.text().isEmpty()) { + return startsALine(text.text()); + } + } + return false; + } + + /** Whether text puts something on the line it starts, rather than ending it at once. */ + private static boolean startsALine(String text) { + return !text.isEmpty() && text.charAt(0) != '\n' && text.charAt(0) != '\r'; + } + + /** A paragraph's text as Word's outline lists it: its letters, one space for each run of white space. */ + private static String outlineTextOf(ParagraphNode node) { + StringBuilder text = new StringBuilder(); + if (node.inlineRuns().isEmpty()) { + text.append(node.text()); + } + for (InlineRun run : node.inlineRuns()) { + InlineTextRun textRun = textOf(run); + if (textRun != null) { + text.append(textRun.text()); + } + } + return collapsedWhitespace(text.toString()); + } + + /** Text with its ends trimmed and each run of white space in it one space, as Word's outline shows a heading. */ + private static String collapsedWhitespace(String text) { + return text.strip().replaceAll("\\s+", " "); + } + + /** A size in points as a note gives it: to the hundredth, with no trailing zeros. */ + private static String pointsOf(double points) { + return java.math.BigDecimal.valueOf(Math.round(points * 100) / 100.0).stripTrailingZeros().toPlainString(); } /** @@ -8659,6 +8861,7 @@ private void writeTextBadge(XWPFDocument document, ShapeContainerNode badge, Par } finally { badgeText = null; } + reportWrittenWithout(initials, "written as its badge's text", paragraphLost(initials, false, null)); } /** A paragraph's text when it is text alone, or {@code null} when it holds anything else. */ 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 8c179b46a..7220a37e5 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 @@ -96,8 +96,8 @@ private record Entry(Fate fate, String note) { "headersAndFooters:WRITTEN", // The node entries below are the body's. A zone is written as one line of its // paragraphs' runs, page fields and tabs, which is a gap of its own. - "zones:GAP:in a page zone, a paragraph's alignment, spacing and direction and a row's columns, gap " - + "and padding; whatever else a zone holds is reported"); + "zones:GAP:in a page zone, a paragraph's alignment, spacing, direction, prefix, fitted size and " + + "outline entry and a row's columns, gap and padding; whatever else a zone holds is reported"); static { node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN"); @@ -162,9 +162,15 @@ private record Entry(Fate fate, String note) { "padding:REPORTED:the sides its alignment sets it from", "margin:REPORTED:the sides its alignment sets it from"); node(ParagraphNode.class, "name:INERT", "text:WRITTEN", "inlineRuns:WRITTEN", "textStyle:WRITTEN", - "align:WRITTEN", "lineSpacing:WRITTEN", "bulletOffset:GAP:the letters of a prefix that has any", - "indentStrategy:WRITTEN", "linkTarget:WRITTEN", "bookmarkOptions:GAP:the outline entry's own title", - "padding:WRITTEN", "margin:WRITTEN", "autoSize:GAP:the size the text was fitted to", + "align:WRITTEN", "lineSpacing:WRITTEN", + "bulletOffset:REPORTED:its letters before the first line; over the flow, as a side of an " + + "overlay's pair or as a badge's text, the room it sets lines in by where that moves one", + "indentStrategy:WRITTEN", "linkTarget:WRITTEN", + "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; the right side's of a pair whose left holds the level", + "padding:WRITTEN", "margin:WRITTEN", + "autoSize:REPORTED:the size the page fits the text in the paragraph's style to; with no layout, " + + "not measured", "verticalAlign:WRITTEN", "anchor:WRITTEN", "direction:WRITTEN"); node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN", "fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED", diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java new file mode 100644 index 000000000..6eb81e354 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java @@ -0,0 +1,299 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.node.TextDirection; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextIndent; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What a paragraph's own fields lose in the Word file is in the report: the size an auto-sized + * paragraph's text is fitted to, the prefix its {@code bulletOffset} sets before its lines, and its + * outline entry's title and level. + * + *

Each was drawn by the page and left out of the file without a note. A paragraph that keeps + * them — its text at the size the page fits it to, a blank prefix written as its indent, an outline + * entry titled by its text — is not named.

+ */ +class DocxParagraphReportTest { + + private static final String HEADLINE = "A headline far too long for one line at its size"; + private static final DocumentTextStyle TEN = DocumentTextStyle.DEFAULT.withSize(10); + private static final double CONTENT = 180; + + @Test + void anAutoSizedParagraphsTextIsNamedWhereThePageFitsItToAnotherSize() throws Exception { + Consumer shrunk = page -> page.addParagraph(p -> p.name("Headline").text(HEADLINE) + .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6)); + double fitted = laidOutSize(shrunk); + assertThat(fitted).as("the page fits it smaller").isLessThan(24); + assertThat(paragraphNote(shrunk)) + .isEqualTo("written as a paragraph; its text is written at 24pt, where the page fits it to " + + points(fitted) + "pt"); + // Up to a size above its style's, a line that fits at it is set at it. + assertThat(paragraphNote(page -> page.addParagraph(p -> p.text("Hi").textStyle(TEN).autoSize(24)))) + .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt"); + // Fitted to its own size, it loses nothing. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi") + .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6)))).isEmpty(); + // Not auto-sized, it is not named. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text(HEADLINE) + .textStyle(DocumentTextStyle.DEFAULT.withSize(24))))).isEmpty(); + // Word holds a size to the half point: 10.3 and 10.5 are one size in the file. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Hi") + .textStyle(DocumentTextStyle.DEFAULT.withSize(10.3)).autoSize(10.5)))).isEmpty(); + } + + @Test + void aPrefixThePageSetsInTheFittedSizeIsNamedWhereEveryRunKeepsItsOwn() throws Exception { + // The page sets the prefix in the paragraph's style at the fitted size; the indent it is + // written as is measured at the style's. + Consumer prefixed = page -> page.addParagraph(p -> p + .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).inlineText(HEADLINE, TEN) + .bulletOffset(" ").indentStrategy(DocumentTextIndent.ALL_LINES).autoSize(24, 6)); + double fitted = laidOutSize(prefixed); + assertThat(fitted).as("the prefix, laid out first, at the fitted size").isLessThan(24); + assertThat(paragraphNote(prefixed)) + .isEqualTo("written as a paragraph; its text is written at 24pt, where the page fits it to " + + points(fitted) + "pt"); + } + + @Test + void onlyTheTextThatTakesTheParagraphsStyleIsFitted() throws Exception { + // A run with a style of its own is laid out at its own size, as it is written. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.textStyle(TEN) + .inlineText("Hi ", TEN).inlineText("there", DocumentTextStyle.DEFAULT.withSize(12)) + .autoSize(24)))).isEmpty(); + // Beside one, a run with none takes the size the page fits the paragraph to. + assertThat(paragraphNote(page -> page.addParagraph(p -> p.textStyle(TEN) + .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(12)).inlineText("there") + .autoSize(24)))) + .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt"); + // Fitted to the size a run has of its own, it is that size. + assertThat(paragraphNote(page -> page.addParagraph(p -> p.textStyle(TEN) + .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(24)).inlineText("there") + .autoSize(24)))) + .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt"); + } + + @Test + void withNoLayoutTheSizeThePageFitsTheTextToIsNotMeasured() throws Exception { + DocxExportReport report = DocxExports.reportWithoutLayout(240, 600, 30, page -> page + .addParagraph(p -> p.text("Hi").textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6))); + + assertThat(report.bySubject().get("ParagraphNode")).extracting(DocxExportReport.Note::detail) + .containsExactly("written as a paragraph; its text is written at 24pt — the size the page fits it " + + "to is not measured"); + // Not auto-sized, a paragraph is written at the size the page sets it in, laid out or not. + assertThat(DocxExports.reportWithoutLayout(240, 600, 30, page -> page.addParagraph(p -> p.text("Hi") + .textStyle(DocumentTextStyle.DEFAULT.withSize(24)))).bySubject()).doesNotContainKey("ParagraphNode"); + } + + @Test + void aPrefixsLettersAreNamedWhereThePageSetsThemBeforeTheFirstLine() throws Exception { + String letters = "written as a paragraph; its bulletOffset's letters, \"•\", are not written before its first line"; + assertThat(paragraphNote(prefixed("• ", DocumentTextIndent.FIRST_LINE, "Ship it"))).isEqualTo(letters); + assertThat(paragraphNote(prefixed("• ", DocumentTextIndent.ALL_LINES, "Ship it"))).isEqualTo(letters); + // Before the wrapped lines the page sets the spaces that cover it, which are written. + assertThat(paragraphNotes(prefixed("• ", DocumentTextIndent.FROM_SECOND_LINE, HEADLINE))).isEmpty(); + // A blank prefix is written as the paragraph's indent. + assertThat(paragraphNotes(prefixed(" ", DocumentTextIndent.ALL_LINES, HEADLINE))).isEmpty(); + // A first line ended at once holds nothing to set a prefix before. + assertThat(paragraphNotes(prefixed("• ", DocumentTextIndent.FIRST_LINE, "\nShip it"))).isEmpty(); + assertThat(paragraphNotes(prefixed("• ", DocumentTextIndent.NONE, "Ship it"))).isEmpty(); + // In a paragraph of runs, the first line is the runs' up to their first line break. + assertThat(paragraphNote(page -> page.addParagraph(p -> p.inlineText("Ship ").inlineText("it", TEN) + .bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE)))).isEqualTo(letters); + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.inlineText("\nShip ").inlineText("it", TEN) + .bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE)))).isEmpty(); + } + + @Test + void aPrefixIsNamedWhereThePathThatWritesTheParagraphWritesNone() throws Exception { + String room = "the room its bulletOffset sets its lines in by is not written"; + // One side of an overlay's left-and-right pair. + assertThat(paragraphNote(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") + .bulletOffset(" ").indentStrategy(DocumentTextIndent.FIRST_LINE).build(), "2022")))) + .isEqualTo("written as one side of a line it shares; " + room); + // A prefix for the wrapped lines of a side of one line moves nothing. + assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") + .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE).build(), "2022")))) + .isEmpty(); + // Nor does one before a line set from its other end: the right side's dates end where they end. + assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER").build(), + new ParagraphBuilder().name("Dates").text("2022").align(TextAlign.RIGHT).bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build())))).isEmpty(); + // A prefix's letters are named on any path, beside the room. + assertThat(paragraphNote(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") + .bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE).build(), "2022")))) + .isEqualTo("written as one side of a line it shares; its bulletOffset's letters, \"•\", are not " + + "written before its first line; " + room); + // A text box over the flow. + String boxed = "laid over the flow, which gives it no room: set in a text box where the page sets it"; + assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Monogram").text("L") + .bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE).build())).addParagraph("Masthead"))) + .contains(boxed + "; its bulletOffset's letters, \"•\", are not written before its first line; " + room); + // Right to left, the prefix stands at the right: a line set from the left keeps its text where it is. + assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Monogram").text("L") + .direction(TextDirection.RTL).align(TextAlign.LEFT).bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build())).addParagraph("Masthead"))) + .contains(boxed); + assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Monogram").text("L") + .direction(TextDirection.RTL).align(TextAlign.RIGHT).bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build())).addParagraph("Masthead"))) + .contains(boxed + "; " + room); + // A badge's initials, which a blank prefix moves off its centre. + assertThat(paragraphNote(page -> page.add(new ShapeContainerBuilder().name("Badge").circle(40) + .fillColor(DocumentColor.rgb(30, 50, 90)) + .center(new ParagraphBuilder().text("JR").bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build()) + .build()))) + .isEqualTo("written as its badge's text; " + room); + } + + @Test + void anOutlineEntryIsNamedWhereItsTitleIsNotItsText() throws Exception { + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Results") + .bookmark(new DocumentBookmarkOptions("Results", 1))))).isEmpty(); + // Word lists a heading by its text, a line break or a run of spaces in it one space. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Results of\nthe year") + .bookmark(new DocumentBookmarkOptions("Results of the year", 1))))).isEmpty(); + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Results of the year") + .bookmark(new DocumentBookmarkOptions("Results of the year", 1))))).isEmpty(); + assertThat(paragraphNote(page -> page.addParagraph(p -> p.text("RESULTS") + .bookmark(new DocumentBookmarkOptions("Results", 1))))) + .isEqualTo("written as a paragraph; its outline entry shows \"RESULTS\", not its title \"Results\""); + assertThat(paragraphNote(page -> page.addParagraph(p -> p.inlineText("Results ").inlineText("2026", TEN) + .bookmark(new DocumentBookmarkOptions("Results", 1))))) + .isEqualTo("written as a paragraph; its outline entry shows \"Results 2026\", not its title \"Results\""); + // A paragraph of runs is listed by all of their text. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.inlineText("Results ").inlineText("2026", TEN) + .bookmark(new DocumentBookmarkOptions("Results 2026", 1))))).isEmpty(); + } + + @Test + void anOutlineLevelPastWordsNinthIsNamed() throws Exception { + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Deep") + .bookmark(new DocumentBookmarkOptions("Deep", 8))))).as("Word's ninth").isEmpty(); + assertThat(paragraphNote(page -> page.addParagraph(p -> p.text("Deeper") + .bookmark(new DocumentBookmarkOptions("Deeper", 9))))) + .isEqualTo("written as a paragraph; its outline entry is written at Word's ninth level, where the " + + "page nests it deeper"); + } + + @Test + void aPairsLineIsOneHeadingListedByAllOfItsText() throws Exception { + String shared = "written as one side of a line it shares; "; + // The line is the left side's heading, listed by both sides' text; the right side's entry is lost. + assertThat(paragraphNotes(page -> page.add(pair(heading("ENGINEER", "ENGINEER 2022", 1), + heading("2022", "2022", 2))))) + .containsExactly(shared + "its outline entry is not written"); + assertThat(paragraphNotes(page -> page.add(pair(heading("ENGINEER", "ENGINEER", 1), "2022")))) + .containsExactly(shared + "its outline entry shows \"ENGINEER 2022\", not its title \"ENGINEER\""); + // With none on its left, the line is the right side's heading. + assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") + .build(), heading("2022", "ENGINEER 2022", 2))))).isEmpty(); + assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") + .build(), heading("2022", "2022", 2))))) + .containsExactly(shared + "its outline entry shows \"ENGINEER 2022\", not its title \"2022\""); + } + + /** A paragraph of a pair that declares an outline entry, the right one aligned right. */ + private static DocumentNode heading(String text, String title, int level) { + return new ParagraphBuilder().name(level == 1 ? "Title" : "Dates").text(text) + .align(level == 1 ? TextAlign.LEFT : TextAlign.RIGHT) + .bookmark(new DocumentBookmarkOptions(title, level)).build(); + } + + private static Consumer prefixed(String prefix, DocumentTextIndent strategy, String text) { + return page -> page.addParagraph(p -> p.name("Item").text(text).bulletOffset(prefix).indentStrategy(strategy)); + } + + /** An overlay of two texts on one line, the left one set from the left, the right one from the right. */ + private static DocumentNode pair(DocumentNode left, String right) { + return pair(left, new ParagraphBuilder().name("Dates").text(right).align(TextAlign.RIGHT).build()); + } + + private static DocumentNode pair(DocumentNode left, DocumentNode right) { + return new ShapeContainerBuilder().name("EntryHead").rectangle(CONTENT, 20) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(left, 0, 0, LayerAlign.CENTER_LEFT) + .position(right, 0, 0, LayerAlign.CENTER_RIGHT) + .build(); + } + + /** A sidebar hung into the page's margin, its text laid over the flow on a filled block. */ + private static DocumentNode sidebar(DocumentNode text) { + return new ShapeContainerBuilder().name("Sidebar") + .rectangle(100, 120).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .margin(new DocumentInsets(-30, 0, -90, -30)) + .position(new com.demcha.compose.document.dsl.ShapeBuilder().name("Block").size(100, 120) + .fillColor(DocumentColor.rgb(160, 80, 50)).build(), 0, 0, LayerAlign.TOP_LEFT, 0) + .position(text, 10, 10, LayerAlign.TOP_LEFT, 1) + .build(); + } + + private static String points(double size) { + return BigDecimal.valueOf(Math.round(size * 100) / 100.0).stripTrailingZeros().toPlainString(); + } + + /** The size of the first letters the page lays out. */ + private static double laidOutSize(Consumer content) throws Exception { + try (DocumentSession session = session(content)) { + return session.layoutGraph().fragments().stream() + .map(fragment -> fragment.payload()) + .filter(ParagraphFragmentPayload.class::isInstance) + .map(ParagraphFragmentPayload.class::cast) + .flatMap(paragraph -> paragraph.lines().stream()) + .flatMap(line -> line.spans().stream()) + .filter(ParagraphTextSpan.class::isInstance) + .map(ParagraphTextSpan.class::cast) + .findFirst().orElseThrow().textStyle().size(); + } + } + + private static String paragraphNote(Consumer content) throws Exception { + List notes = paragraphNotes(content); + assertThat(notes).as("the paragraph's note").hasSize(1); + return notes.get(0); + } + + private static List paragraphNotes(Consumer content) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (DocumentSession session = session(content)) { + session.export(new DocxSemanticBackend(captured::set)); + } + assertThat(captured.get()).as("the sink is called once the bytes exist").isNotNull(); + return captured.get().bySubject().getOrDefault("ParagraphNode", List.of()).stream() + .map(DocxExportReport.Note::detail).toList(); + } + + private static DocumentSession session(Consumer content) { + DocumentSession session = GraphCompose.document() + .pageSize(240, 600) + .margin(DocumentInsets.of(30)) + .create(); + session.pageFlow(content::accept); + return session; + } +} From 4e46a021f739c8a854d74482750aca3718c623fa Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 6 Oct 2026 18:06:17 +0100 Subject: [PATCH 2/2] fix(docx): name a paragraph's prefix room by where each path holds its line A pair's left side starts where its line does, prefix and all, and its right side loses the room only where a left tab holds its start; a text box or a badge loses it unless it is one line set from the end away from its prefix. A level past Word's ninth is named only where it shares the ninth with another. The written size is the one Word holds, to the half point; control characters and no-break spaces are read as the page reads them, and an empty later line takes no prefix. --- CHANGELOG.md | 19 ++- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 13 +- render-docx/README.md | 18 +- .../semantic/docx/DocxSemanticBackend.java | 160 ++++++++++++------ .../docx/DocxNodeFieldLedgerTest.java | 7 +- .../docx/DocxParagraphReportTest.java | 89 +++++++--- 7 files changed, 205 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ce2feb7..87a094d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,18 +17,21 @@ follow semantic versioning; release dates are ISO 8601. - Word's outline listed a heading by the text of its Word paragraph, not by its `DocumentBookmarkOptions` title, and at no level past the ninth; - a line an overlay's left-and-right pair shares is one Word paragraph: Word listed both sides' - text under the left one's level, and the right one's entry was lost. + text under one level, the left one's where it has one, and where both have one the right + one's entry was lost. The paragraph's note (`ParagraphNode`) now names: - the size its text is written at and the size the page fits it to, where Word, which holds a - size to the half point, sets them apart. Both are read from the layout's lines; a prefix the - page sets in the paragraph's style counts as its text. Where the lines are not read or do not - tell the size, the note says the fitted size is not measured; + size to the half point, sets them apart. The fitted size is read from the layout's lines; a + prefix the page sets in the paragraph's style counts as its text. Where the lines are not + read or do not tell the size, the note says the fitted size is not measured; - its prefix's letters before its first line. On a path that writes no prefix, it also names - the room the prefix sets lines in by, where that moves a line: not where the line is set from - the end the prefix does not stand at; - - an outline title that is not the text Word lists, a level past Word's ninth, and the right - side's outline entry where the left side holds the line's level. + the room the prefix sets lines in by, where that moves a line: a side of a pair Word holds + by its start, and a text box or a badge, but not one line set from the end away from its + prefix; + - an outline title that is not the text Word lists, a level past Word's ninth that shares it + with another, which the page nests apart, and the right side's outline entry where the left + side holds the line's level. A paragraph set in a text box over the flow carries these on the note it already had. A page zone's paragraphs are not named yet: that stays a gap of the `zones` option. Across diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 9d0937ca0..549d6cc2b 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -63,7 +63,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 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, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; 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 is written at its style's size, not the one the page fits it to; 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 written and the size the page fits the text to, to Word's half point; an outline title that is not the text Word lists, a level past the ninth, 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 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, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; 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 is written at its style's size, not the one the page fits it to; 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 written and the size the page fits the text to, to Word's half point; 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 or a drawn marker, 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, 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, and the rows the page draws as a marker alone for blank items of a flagged list, which are not written | | 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 and the padding above and below the letters 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 this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is 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; 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 63fe6ad3c..769002cf7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,7 +67,7 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; outside a header or footer, the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where they differ. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | +| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; outside a header or footer, the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and outside a header or footer the report names both sizes where Word, to its half point, holds them apart. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | @@ -378,10 +378,11 @@ level))` — also carries Word's own `HeadingN` style, which is what fills the N Pane, the outline view and a generated table of contents. The style states the outline level and no formatting, so the paragraph keeps the look its author gave it and "restyle all headings" in Word still reaches it. Only the levels the document uses are defined, and -a level past Word's nine is clamped. Word's outline lists a heading by its text, so a -`DocumentBookmarkOptions` title that is not the paragraph's text is not in the file; the -report names it, and a level past the ninth. A heading is never inferred from type size: a -large first line claims nothing about structure. +a level past Word's nine is clamped. Word's outline lists a heading by the text of its Word +paragraph, so a `DocumentBookmarkOptions` title that is not that text is not in the file; +outside a header or footer, the report names it, and a level past the ninth that shares it +with another, which the page's outline nests apart. A heading is never inferred from type +size: a large first line claims nothing about structure. ## What a list becomes @@ -725,7 +726,7 @@ tint it was flattened to. That is recorded with the rest. or framed — lifts the table out of the gap above it, as far as that gap goes, and the row's other cells start that much lower inside. Each paragraph keeps its bookmark; the line is the left paragraph's heading, or the right one's where the - left is none, listed in Word's outline by both texts, and the report names a title that + left has none, listed in Word's outline by both texts, and the report names a title that is not that line and a right side's outline entry beside a left one's. Two texts closer than 4pt stay one after the other. Drawing in the band is drawn as shapes where the page draws it. diff --git a/render-docx/README.md b/render-docx/README.md index d94dfa160..d8d7fbad5 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -89,7 +89,8 @@ What maps: the shape. - **Links and navigation.** A link is a `w:hyperlink`, to an address or to one of the document's anchors; an `anchor(...)` is a bookmark; a `bookmark(...)` outline level is - Word's `HeadingN` style, so the paragraph is in the Navigation Pane, listed by its text. + Word's `HeadingN` style, so the paragraph is in the Navigation Pane, listed by the text of its + Word paragraph. - **Horizontal rules.** A horizontal line or an `addDivider` bar is a paragraph border. - **Barcodes and QR codes** are pictures of the same matrix the PDF draws, so they scan. - **Charts** are a table of their data. @@ -120,14 +121,15 @@ What is not written — each one is named in the export report too narrow for its marker — while a flat hanging-indent list keeps the page's column; - a row the page draws as a marker alone, for a blank item. - **What a paragraph's own fields set where Word cannot hold it**, named in the export report on - the paragraph: - - the size an auto-sized paragraph's text is fitted to, where it is not its style's; - - the letters of a `bulletOffset` prefix, and the room a prefix sets lines in by, where it moves - one, in a paragraph written over the flow, as a side of an overlay's left-and-right pair or as - a badge's initials; + the paragraph outside a header or footer (a page zone's paragraphs are not named yet): + - the size an auto-sized paragraph's text is fitted to, where Word, to its half point, holds it + apart from its style's; + - the letters of a `bulletOffset` prefix; and, where it moves a line, the room a prefix sets + lines in by in a paragraph written over the flow, as a side of an overlay's left-and-right + pair or as a badge's initials; - an outline entry's title where it is not the text Word lists the heading by — for a side of - an overlay's pair, the whole line — a level past Word's ninth, and the right side's entry of a - pair whose left side holds the line's level. + an overlay's pair, the whole line — a level past Word's ninth that shares it with another, and + the right side's entry of a pair whose left side holds the line's level. Multi-section documents export through `MultiSectionDocument.toDocxBytes()`, `writeDocx(...)` and `buildDocx(...)` (Experimental): each section becomes a Word section 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 014bcc800..43c244a06 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 @@ -480,6 +480,9 @@ public final class DocxSemanticBackend implements SemanticBackend { // The outline levels this document asks for, so the styles part defines those and no // others. Filled before the styles part is written, which comes before the body. private java.util.Set headingLevels = java.util.Set.of(); + // The outline levels the document declares from Word's ninth on, which Word holds as one: two + // of them are levels the page nests apart and the file does not. + private java.util.Set outlineLevelsFromWordsNinth = java.util.Set.of(); // Anchors this export writes a bookmark for, so a page reference knows it has a target. private java.util.Set bookmarkedAnchors = java.util.Set.of(); // Where the finished report goes, when the caller configured somewhere for it to go. @@ -743,7 +746,10 @@ private byte[] write(List sections, Path outputFile) throws Exc prefixColumns.clear(); report = new DocxExportReport.Builder(); bookmarkNames = new DocxBookmarkNames(); - headingLevels = headingLevelsIn(whole); + java.util.NavigableSet outlineLevels = outlineLevelsIn(whole); + headingLevels = outlineLevels.stream().map(level -> Math.min(level, MAX_HEADING_LEVEL)) + .collect(java.util.stream.Collectors.toCollection(java.util.TreeSet::new)); + outlineLevelsFromWordsNinth = outlineLevels.tailSet(MAX_HEADING_LEVEL, true); bookmarkedAnchors = bookmarkedAnchorsIn(whole); wordFamilies = DocxFontTable.familiesByName(fonts); measuredFamilies = List.copyOf(fonts); @@ -2347,7 +2353,7 @@ private void writeTextOverTheFlow(XWPFDocument document, ParagraphNode node) { paragraphXml(para)))); List lost = new ArrayList<>(); lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it"); - lost.addAll(paragraphLost(node, false, null)); + lost.addAll(paragraphLost(node, roomLostInItsBox(node), null)); report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), String.join("; ", lost)); } @@ -4978,23 +4984,21 @@ private static java.util.Set bookmarkedAnchorsIn(DocumentGraph graph) { return anchors; } - private static java.util.Set headingLevelsIn(DocumentGraph graph) { - java.util.Set levels = new java.util.TreeSet<>(); + /** Every outline level the document's paragraphs declare, as declared: past Word's nine too. */ + private static java.util.NavigableSet outlineLevelsIn(DocumentGraph graph) { + java.util.NavigableSet levels = new java.util.TreeSet<>(); for (DocumentNode root : graph.roots()) { - collectHeadingLevels(root, levels); + collectOutlineLevels(root, levels); } return levels; } - private static void collectHeadingLevels(DocumentNode node, java.util.Set levels) { - if (node instanceof ParagraphNode paragraph) { - Integer level = headingLevelOf(paragraph); - if (level != null) { - levels.add(level); - } + private static void collectOutlineLevels(DocumentNode node, java.util.Set levels) { + if (node instanceof ParagraphNode paragraph && paragraph.bookmarkOptions() != null) { + levels.add(paragraph.bookmarkOptions().level()); } for (DocumentNode child : node.children()) { - collectHeadingLevels(child, levels); + collectOutlineLevels(child, levels); } } @@ -6321,12 +6325,16 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine closeAnchor(para, rightAnchor); // A Word paragraph has one outline level, and its outline lists the whole line by it: the // left side's level where it has one, the right side's where not; the other's is lost. - String line = collapsedWhitespace(outlineTextOf(pair.left()) + " " + outlineTextOf(pair.right())); boolean leftHeads = headingLevelOf(pair.left()) != null; + String line = leftHeads || headingLevelOf(pair.right()) != null + ? collapsedWhitespace(outlineTextOf(pair.left()) + " " + outlineTextOf(pair.right())) : null; + // The left side starts where its line starts, prefix and all; the right side does too + // where a left tab holds its start, and where a right tab holds its end it ends there. reportWrittenWithout(pair.left(), "written as one side of a line it shares", - paragraphLost(pair.left(), false, leftHeads ? line : null)); + paragraphLost(pair.left(), laysOutAPrefix(pair.left()), leftHeads ? line : null)); reportWrittenWithout(pair.right(), "written as one side of a line it shares", - paragraphLost(pair.right(), false, leftHeads ? null : line)); + paragraphLost(pair.right(), pair.fromItsStart() && laysOutAPrefix(pair.right()), + leftHeads ? null : line)); if ((pair.left().keepWithNext() && layout.onOnePage(pair.left())) || (pair.right().keepWithNext() && layout.onOnePage(pair.right()))) { para.setKeepNext(true); @@ -6717,26 +6725,29 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) { closeAnchor(para, anchor); lastWrittenNode = node; lastWrittenParagraph = para; - reportWrittenWithout(node, "written as a paragraph", paragraphLost(node, true, outlineTextOf(node))); + reportWrittenWithout(node, "written as a paragraph", + paragraphLost(node, false, node.bookmarkOptions() == null ? null : outlineTextOf(node))); } /** - * What a paragraph's own fields lose on the way to Word, whichever path wrote it: the size an - * auto-sized paragraph's text is fitted to, its {@code bulletOffset} and its outline entry. + * What a paragraph's own fields lose on the way to Word, on any path that writes it in the + * body: the size an auto-sized paragraph's text is fitted to, its {@code bulletOffset} and its + * outline entry. A page zone's paragraphs are written apart, and these are not named for them. * *

Its text is written at its style's size, not the one the page fits it to. A prefix's * letters are never written. A path that does not write a prefix as a distance - * ({@link #indentAsThePrefixDoes}) leaves out the room it sets lines in by, too, wherever that - * moves a line: not where the line is set from the end the prefix stands off. Word's outline - * lists a heading by the text of its Word paragraph, at no level past the ninth.

- * - * @param node the paragraph written - * @param prefixAsIndent whether the path writes a blank prefix as the paragraph's indent - * @param listedAs the text Word's outline lists the paragraph's heading by, or {@code null} - * where the path writes no outline level for it + * ({@link #indentAsThePrefixDoes}) may leave out the room it sets lines in by, too; which + * path does is the caller's to say. Word's outline lists a heading by the text of its Word + * paragraph, and holds no level past the ninth.

+ * + * @param node the paragraph written + * @param roomLost whether the path leaves out the room the paragraph's prefix sets a line in + * by, where that moves the line + * @param listedAs the text Word's outline lists the paragraph's heading by, or {@code null} + * where the path writes no outline level for it * @return the phrases, empty when nothing of these is lost */ - private List paragraphLost(ParagraphNode node, boolean prefixAsIndent, String listedAs) { + private List paragraphLost(ParagraphNode node, boolean roomLost, String listedAs) { List lost = new ArrayList<>(4); String size = autoSizeLost(node); if (size != null) { @@ -6746,19 +6757,21 @@ private List paragraphLost(ParagraphNode node, boolean prefixAsIndent, S if (setsAPrefixBeforeTheFirstLine(node) && !prefix.isBlank()) { lost.add("its bulletOffset's letters, \"" + prefix.strip() + "\", are not written before its first line"); } - if (!prefixAsIndent && laysOutAPrefix(node) && !setFromTheEndThePrefixStandsOff(node)) { + if (roomLost) { lost.add("the room its bulletOffset sets its lines in by is not written"); } DocumentBookmarkOptions outline = node.bookmarkOptions(); if (outline != null) { if (listedAs == null) { + // Not reached by a text box or a badge, which hold no paragraph with an outline entry. lost.add("its outline entry is not written"); } else { if (!collapsedWhitespace(outline.title()).equals(listedAs)) { lost.add("its outline entry shows \"" + listedAs + "\", not its title \"" + outline.title() + "\""); } - if (outline.level() > MAX_HEADING_LEVEL) { - lost.add("its outline entry is written at Word's ninth level, where the page nests it deeper"); + if (outline.level() > MAX_HEADING_LEVEL && outlineLevelsFromWordsNinth.size() > 1) { + lost.add("its outline entry is written at Word's ninth level, which it shares with a level " + + "the page nests apart from it"); } } } @@ -6766,21 +6779,32 @@ private List paragraphLost(ParagraphNode node, boolean prefixAsIndent, S } /** - * What an auto-sized paragraph's text loses of its size: written at its style's, where the - * page fits it to another Word holds apart, to the half point; or {@code null} when it does - * not, or nothing laid out takes the paragraph's style — a run with a style of its own keeps - * its size on the page. + * Whether a paragraph set in a box of its own, by its alignment, loses the room its prefix + * sets a line in by: where the page lays out a prefix, unless the paragraph is one line set + * from the end away from its prefix, whose text the prefix does not move. A paragraph of more + * lines than one is broken in Word without the prefix's room, and its lines take more words. + */ + private boolean roomLostInItsBox(ParagraphNode node) { + return laysOutAPrefix(node) && !(layout.lineCount(node) == 1 && setFromTheEndAwayFromItsPrefix(node)); + } + + /** + * What an auto-sized paragraph's text loses of its size: the size the file holds it at, to + * Word's half point, and the one the page fits it to, where Word sets the two apart; the + * size alone, saying the fitted one is not measured, where the layout does not tell it; or + * {@code null} when it loses nothing, or nothing laid out takes the paragraph's style — a run + * with a style of its own keeps its size on the page. */ private String autoSizeLost(ParagraphNode node) { if (node.autoSize() == null || !holdsTextInTheParagraphsStyle(node) && !laysOutAPrefix(node)) { return null; } - double written = node.textStyle().size(); + double written = wordsSize(node.textStyle().size()); double fitted = fittedSize(node); if (Double.isNaN(fitted)) { return "its text is written at " + pointsOf(written) + "pt — the size the page fits it to is not measured"; } - return wordsSize(fitted) != wordsSize(written) + return wordsSize(fitted) != written ? "its text is written at " + pointsOf(written) + "pt, where the page fits it to " + pointsOf(fitted) + "pt" : null; } @@ -6801,15 +6825,32 @@ private static boolean holdsTextInTheParagraphsStyle(ParagraphNode node) { /** * Whether the page lays out a paragraph's prefix, in the paragraph's style: before a first line - * that holds something, or before the lines after it where it lays out more than one or its - * lines are not read. + * that holds something, or before a line after it that holds something, or where its lines + * are not read. */ private boolean laysOutAPrefix(ParagraphNode node) { DocumentTextIndent strategy = node.indentStrategy(); return setsAPrefixBeforeTheFirstLine(node) || !node.bulletOffset().isEmpty() && (strategy == DocumentTextIndent.FROM_SECOND_LINE || strategy == DocumentTextIndent.ALL_LINES) - && layout.lineCount(node) != 1; + && holdsALineAfterTheFirst(node); + } + + /** + * Whether a paragraph lays out a line after its first that holds something, or its lines are + * not read: an empty line, the page sets no prefix before. + */ + private boolean holdsALineAfterTheFirst(ParagraphNode node) { + List lines = layout.lines(node); + if (lines.isEmpty()) { + return true; + } + for (int index = 1; index < lines.size(); index++) { + if (!lines.get(index).text().isBlank()) { + return true; + } + } + return false; } /** Whether the page sets a paragraph's prefix before its first line, which holds something. */ @@ -6821,10 +6862,11 @@ private static boolean setsAPrefixBeforeTheFirstLine(ParagraphNode node) { } /** - * Whether a paragraph's lines are set from the end its prefix does not stand at, where the - * prefix moves none of their text: aligned right, or left in a right-to-left paragraph. + * Whether a paragraph's lines are set from the end away from its prefix, where the prefix + * moves none of their text: aligned right, or aligned left in a right-to-left paragraph, + * whose prefix stands at the right. */ - private static boolean setFromTheEndThePrefixStandsOff(ParagraphNode node) { + private static boolean setFromTheEndAwayFromItsPrefix(ParagraphNode node) { return ParagraphDirection.resolve(node) == TextDirection.RTL ? node.align() == TextAlign.LEFT : node.align() == TextAlign.RIGHT; } @@ -6832,8 +6874,8 @@ private static boolean setFromTheEndThePrefixStandsOff(ParagraphNode node) { /** * The size the page sets a paragraph's text in where it takes the paragraph's style, read off * its laid-out lines; {@code NaN} when they are not read, or do not tell it. A run with a style - * of its own is laid out at that style's size, so the paragraph's is a size no such run has - * or, where every size laid out is one, that one. + * of its own is laid out at that style's size, so the paragraph's size is one no such run has; + * where the lines hold a single size, it is that one. */ private double fittedSize(ParagraphNode node) { java.util.Set ownSizes = new java.util.HashSet<>(); @@ -6862,27 +6904,34 @@ private double fittedSize(ParagraphNode node) { /** * Whether a paragraph's first line holds anything, which the page sets a prefix before: an - * empty one, ended by a line break at once, it sets none before. + * empty one, ended by a line break before anything the page keeps, it sets none before. */ private static boolean firstLineHoldsSomething(ParagraphNode node) { if (node.inlineRuns().isEmpty()) { - return startsALine(node.text()); + return !firstLineOf(node.text()).isEmpty(); } for (InlineRun run : node.inlineRuns()) { // A picture, a shape or a chip: the node keeps no chip without text. if (!(run instanceof InlineTextRun text)) { return true; } - if (!text.text().isEmpty()) { - return startsALine(text.text()); + if (!firstLineOf(text.text()).isEmpty()) { + return true; + } + if (text.text().indexOf('\n') >= 0 || text.text().indexOf('\r') >= 0) { + return false; } } return false; } - /** Whether text puts something on the line it starts, rather than ending it at once. */ - private static boolean startsALine(String text) { - return !text.isEmpty() && text.charAt(0) != '\n' && text.charAt(0) != '\r'; + /** Text up to its first line break, without the control characters the page drops from it. */ + private static String firstLineOf(String text) { + int end = 0; + while (end < text.length() && text.charAt(end) != '\n' && text.charAt(end) != '\r') { + end++; + } + return com.demcha.compose.engine.text.TextControlSanitizer.removeExceptFormattingControls(text.substring(0, end)); } /** A paragraph's text as Word's outline lists it: its letters, one space for each run of white space. */ @@ -6900,9 +6949,13 @@ private static String outlineTextOf(ParagraphNode node) { return collapsedWhitespace(text.toString()); } + /** A run of white space, no-break spaces among it, which a heading shows as one space. */ + private static final java.util.regex.Pattern OUTLINE_SPACE = + java.util.regex.Pattern.compile("[\\s\\u00A0\\u2007\\u202F]+"); + /** Text with its ends trimmed and each run of white space in it one space, as Word's outline shows a heading. */ private static String collapsedWhitespace(String text) { - return text.strip().replaceAll("\\s+", " "); + return OUTLINE_SPACE.matcher(text).replaceAll(" ").strip(); } /** A size in points as a note gives it: to the hundredth, with no trailing zeros. */ @@ -8861,7 +8914,8 @@ private void writeTextBadge(XWPFDocument document, ShapeContainerNode badge, Par } finally { badgeText = null; } - reportWrittenWithout(initials, "written as its badge's text", paragraphLost(initials, false, null)); + reportWrittenWithout(initials, "written as its badge's text", + paragraphLost(initials, roomLostInItsBox(initials), null)); } /** A paragraph's text when it is text alone, or {@code null} when it holds anything else. */ 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 7220a37e5..45a7a7a35 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 @@ -167,10 +167,11 @@ private record Entry(Fate fate, String note) { + "overlay's pair or as a badge's text, the room it sets lines in by where that moves one", "indentStrategy:WRITTEN", "linkTarget:WRITTEN", "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; the right side's of a pair whose left holds the level", + + "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:WRITTEN", "margin:WRITTEN", - "autoSize:REPORTED:the size the page fits the text in the paragraph's style to; with no layout, " - + "not measured", + "autoSize:REPORTED:the size the page fits the text in the paragraph's style to, where Word holds " + + "it apart; where the layout does not tell it, not measured", "verticalAlign:WRITTEN", "anchor:WRITTEN", "direction:WRITTEN"); node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN", "fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED", diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java index 6eb81e354..19ef4a833 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; import com.demcha.compose.document.dsl.ShapeContainerBuilder; import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; @@ -45,7 +46,7 @@ class DocxParagraphReportTest { void anAutoSizedParagraphsTextIsNamedWhereThePageFitsItToAnotherSize() throws Exception { Consumer shrunk = page -> page.addParagraph(p -> p.name("Headline").text(HEADLINE) .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).autoSize(24, 6)); - double fitted = laidOutSize(shrunk); + double fitted = firstSpan(shrunk).textStyle().size(); assertThat(fitted).as("the page fits it smaller").isLessThan(24); assertThat(paragraphNote(shrunk)) .isEqualTo("written as a paragraph; its text is written at 24pt, where the page fits it to " @@ -71,8 +72,10 @@ void aPrefixThePageSetsInTheFittedSizeIsNamedWhereEveryRunKeepsItsOwn() throws E Consumer prefixed = page -> page.addParagraph(p -> p .textStyle(DocumentTextStyle.DEFAULT.withSize(24)).inlineText(HEADLINE, TEN) .bulletOffset(" ").indentStrategy(DocumentTextIndent.ALL_LINES).autoSize(24, 6)); - double fitted = laidOutSize(prefixed); - assertThat(fitted).as("the prefix, laid out first, at the fitted size").isLessThan(24); + ParagraphTextSpan prefix = firstSpan(prefixed); + assertThat(prefix.text()).as("the prefix, laid out first").isBlank(); + double fitted = prefix.textStyle().size(); + assertThat(fitted).as("at the fitted size, not the run's").isLessThan(24).isNotEqualTo(10.0); assertThat(paragraphNote(prefixed)) .isEqualTo("written as a paragraph; its text is written at 24pt, where the page fits it to " + points(fitted) + "pt"); @@ -89,6 +92,11 @@ void onlyTheTextThatTakesTheParagraphsStyleIsFitted() throws Exception { .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(12)).inlineText("there") .autoSize(24)))) .isEqualTo("written as a paragraph; its text is written at 10pt, where the page fits it to 24pt"); + // On a side of a pair as in the body. + assertThat(paragraphNote(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") + .textStyle(TEN).autoSize(14).build(), "2022")))) + .isEqualTo("written as one side of a line it shares; its text is written at 10pt, where the page " + + "fits it to 14pt"); // Fitted to the size a run has of its own, it is that size. assertThat(paragraphNote(page -> page.addParagraph(p -> p.textStyle(TEN) .inlineText("Hi ", DocumentTextStyle.DEFAULT.withSize(24)).inlineText("there") @@ -120,6 +128,8 @@ void aPrefixsLettersAreNamedWhereThePageSetsThemBeforeTheFirstLine() throws Exce assertThat(paragraphNotes(prefixed(" ", DocumentTextIndent.ALL_LINES, HEADLINE))).isEmpty(); // A first line ended at once holds nothing to set a prefix before. assertThat(paragraphNotes(prefixed("• ", DocumentTextIndent.FIRST_LINE, "\nShip it"))).isEmpty(); + // Nor one of characters the page drops. + assertThat(paragraphNotes(prefixed("• ", DocumentTextIndent.FIRST_LINE, "⁠\nShip it"))).isEmpty(); assertThat(paragraphNotes(prefixed("• ", DocumentTextIndent.NONE, "Ship it"))).isEmpty(); // In a paragraph of runs, the first line is the runs' up to their first line break. assertThat(paragraphNote(page -> page.addParagraph(p -> p.inlineText("Ship ").inlineText("it", TEN) @@ -139,10 +149,21 @@ void aPrefixIsNamedWhereThePathThatWritesTheParagraphWritesNone() throws Excepti assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE).build(), "2022")))) .isEmpty(); - // Nor does one before a line set from its other end: the right side's dates end where they end. + // Nor does one before a right side a right tab holds by its end, whichever way it is aligned. assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER").build(), new ParagraphBuilder().name("Dates").text("2022").align(TextAlign.RIGHT).bulletOffset(" ") .indentStrategy(DocumentTextIndent.FIRST_LINE).build())))).isEmpty(); + assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER").build(), + new ParagraphBuilder().name("Dates").text("2022").align(TextAlign.CENTER).bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build())))).isEmpty(); + // A right side a left tab holds by its start starts where its prefix does. + assertThat(paragraphNote(page -> page.add(new ShapeContainerBuilder().name("BankRow").rectangle(CONTENT, 14) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(new ParagraphBuilder().name("Label").text("IBAN").build(), 0, 0, LayerAlign.CENTER_LEFT) + .position(new ParagraphBuilder().name("Value").text("GB36 SRLG").bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build(), 80, 0, LayerAlign.CENTER_LEFT) + .build()))) + .isEqualTo("written as one side of a line it shares; " + room); // A prefix's letters are named on any path, beside the room. assertThat(paragraphNote(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") .bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE).build(), "2022")))) @@ -162,6 +183,15 @@ void aPrefixIsNamedWhereThePathThatWritesTheParagraphWritesNone() throws Excepti .direction(TextDirection.RTL).align(TextAlign.RIGHT).bulletOffset(" ") .indentStrategy(DocumentTextIndent.FIRST_LINE).build())).addParagraph("Masthead"))) .contains(boxed + "; " + room); + // An empty line after the first takes no prefix. + assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Monogram").text("L\n") + .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE).build())).addParagraph("Masthead"))) + .contains(boxed); + // Over more lines than one, Word breaks the lines without the prefix's room, however aligned. + assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Tagline") + .text("Design studio of record since the spring").align(TextAlign.RIGHT).bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FROM_SECOND_LINE).build())).addParagraph("Masthead"))) + .contains(boxed + "; " + room); // A badge's initials, which a blank prefix moves off its centre. assertThat(paragraphNote(page -> page.add(new ShapeContainerBuilder().name("Badge").circle(40) .fillColor(DocumentColor.rgb(30, 50, 90)) @@ -180,6 +210,8 @@ void anOutlineEntryIsNamedWhereItsTitleIsNotItsText() throws Exception { .bookmark(new DocumentBookmarkOptions("Results of the year", 1))))).isEmpty(); assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Results of the year") .bookmark(new DocumentBookmarkOptions("Results of the year", 1))))).isEmpty(); + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Results of the year") + .bookmark(new DocumentBookmarkOptions("Results of the year", 1))))).as("a no-break space").isEmpty(); assertThat(paragraphNote(page -> page.addParagraph(p -> p.text("RESULTS") .bookmark(new DocumentBookmarkOptions("Results", 1))))) .isEqualTo("written as a paragraph; its outline entry shows \"RESULTS\", not its title \"Results\""); @@ -192,37 +224,46 @@ void anOutlineEntryIsNamedWhereItsTitleIsNotItsText() throws Exception { } @Test - void anOutlineLevelPastWordsNinthIsNamed() throws Exception { + void anOutlineLevelPastWordsNinthIsNamedWhereItSharesTheNinthWithAnother() throws Exception { + // Word's ninth level holds a deeper one as the page's outline does, one step below the last. + assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Top") + .bookmark(new DocumentBookmarkOptions("Top", 0))) + .addParagraph(p -> p.text("Deeper").bookmark(new DocumentBookmarkOptions("Deeper", 9))))) + .isEmpty(); + // Beside the ninth itself, it is one level with it where the page nests it below. assertThat(paragraphNotes(page -> page.addParagraph(p -> p.text("Deep") - .bookmark(new DocumentBookmarkOptions("Deep", 8))))).as("Word's ninth").isEmpty(); - assertThat(paragraphNote(page -> page.addParagraph(p -> p.text("Deeper") - .bookmark(new DocumentBookmarkOptions("Deeper", 9))))) - .isEqualTo("written as a paragraph; its outline entry is written at Word's ninth level, where the " - + "page nests it deeper"); + .bookmark(new DocumentBookmarkOptions("Deep", 8))) + .addParagraph(p -> p.text("Deeper").bookmark(new DocumentBookmarkOptions("Deeper", 9))))) + .containsExactly("written as a paragraph; its outline entry is written at Word's ninth level, which " + + "it shares with a level the page nests apart from it"); } @Test void aPairsLineIsOneHeadingListedByAllOfItsText() throws Exception { String shared = "written as one side of a line it shares; "; // The line is the left side's heading, listed by both sides' text; the right side's entry is lost. - assertThat(paragraphNotes(page -> page.add(pair(heading("ENGINEER", "ENGINEER 2022", 1), - heading("2022", "2022", 2))))) + assertThat(paragraphNotes(page -> page.add(pair(title("ENGINEER 2022"), dates("2022"))))) .containsExactly(shared + "its outline entry is not written"); - assertThat(paragraphNotes(page -> page.add(pair(heading("ENGINEER", "ENGINEER", 1), "2022")))) + assertThat(paragraphNotes(page -> page.add(pair(title("ENGINEER"), "2022")))) .containsExactly(shared + "its outline entry shows \"ENGINEER 2022\", not its title \"ENGINEER\""); // With none on its left, the line is the right side's heading. assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") - .build(), heading("2022", "ENGINEER 2022", 2))))).isEmpty(); + .build(), dates("ENGINEER 2022"))))).isEmpty(); assertThat(paragraphNotes(page -> page.add(pair(new ParagraphBuilder().name("Title").text("ENGINEER") - .build(), heading("2022", "2022", 2))))) + .build(), dates("2022"))))) .containsExactly(shared + "its outline entry shows \"ENGINEER 2022\", not its title \"2022\""); } - /** A paragraph of a pair that declares an outline entry, the right one aligned right. */ - private static DocumentNode heading(String text, String title, int level) { - return new ParagraphBuilder().name(level == 1 ? "Title" : "Dates").text(text) - .align(level == 1 ? TextAlign.LEFT : TextAlign.RIGHT) - .bookmark(new DocumentBookmarkOptions(title, level)).build(); + /** A pair's left side, "ENGINEER", declaring an outline entry at the first level under a title. */ + private static DocumentNode title(String outlineTitle) { + return new ParagraphBuilder().name("Title").text("ENGINEER") + .bookmark(new DocumentBookmarkOptions(outlineTitle, 1)).build(); + } + + /** A pair's right side, "2022" aligned right, declaring an outline entry at the second level under a title. */ + private static DocumentNode dates(String outlineTitle) { + return new ParagraphBuilder().name("Dates").text("2022").align(TextAlign.RIGHT) + .bookmark(new DocumentBookmarkOptions(outlineTitle, 2)).build(); } private static Consumer prefixed(String prefix, DocumentTextIndent strategy, String text) { @@ -247,7 +288,7 @@ private static DocumentNode sidebar(DocumentNode text) { return new ShapeContainerBuilder().name("Sidebar") .rectangle(100, 120).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) .margin(new DocumentInsets(-30, 0, -90, -30)) - .position(new com.demcha.compose.document.dsl.ShapeBuilder().name("Block").size(100, 120) + .position(new ShapeBuilder().name("Block").size(100, 120) .fillColor(DocumentColor.rgb(160, 80, 50)).build(), 0, 0, LayerAlign.TOP_LEFT, 0) .position(text, 10, 10, LayerAlign.TOP_LEFT, 1) .build(); @@ -257,8 +298,8 @@ private static String points(double size) { return BigDecimal.valueOf(Math.round(size * 100) / 100.0).stripTrailingZeros().toPlainString(); } - /** The size of the first letters the page lays out. */ - private static double laidOutSize(Consumer content) throws Exception { + /** The first text the page lays out. */ + private static ParagraphTextSpan firstSpan(Consumer content) throws Exception { try (DocumentSession session = session(content)) { return session.layoutGraph().fragments().stream() .map(fragment -> fragment.payload()) @@ -268,7 +309,7 @@ private static double laidOutSize(Consumer content) throws Exce .flatMap(line -> line.spans().stream()) .filter(ParagraphTextSpan.class::isInstance) .map(ParagraphTextSpan.class::cast) - .findFirst().orElseThrow().textStyle().size(); + .findFirst().orElseThrow(); } }