diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f3f2964..e7a2d4e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export writes a list composed in a table cell on the lines its table laid out for it, as + it does a paragraph there.** A composed cell's content is laid out under its table's path, so a + list there has no lines at a path of its own, and its items were matched to none: written at no + height at all, each stood at its face's own line in Word and LibreOffice, taller than the page's — + `SlateOrange`'s certifications, a list beside the education column, stood 0.26, 0.48, 0.84 and + 1.18pt low in Word and 0.15 to 1.05pt in LibreOffice. A list composed in a cell now takes the run + of its table's lines that holds its items in turn, opening at a line no paragraph or list before it + took: each a line of the item's text as the page lays it out, the marker and indent the page sets + in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set + level before it in a column of its own; a paragraph before it that the page reads as markdown is + matched first. Its items then take from those lines what a list's in the flow take: + the height the page sets them at, the list's lineSpacing between a wrapped item's lines, a + `hangingIndent` list's marker column, and each item's seat on the page's baseline, an item in a + row Word keeps whole on its page rising into the gap above it, as a paragraph composed there does. + Having no place of its own, a Word list whose marker the page sets in its text keeps its stated + column. A list holding an item of runs, whose text is not read here, one with an item the page + reads as markdown, its marks dropped, or one it runs onto the next page, or a `hangingIndent` list + with a blank item the page draws as a marker alone, is written as before, and the report names its + lineSpacing as not written, whether an item wraps not measured. The certifications now stand + 0.26, 0.00, 0.12 and 0.10pt low in Word, and 0.10pt low to 0.10pt high in LibreOffice on Windows + and 24.2; no other document of the corpus changed. + - **A DOCX export stands a list's items where the page sets them in their exact lines, as it does a paragraph's.** Word and LibreOffice stand an exact line's baseline four fifths of the way down it, the page its face's ascent below the line's top, and a paragraph's line is moved there; a list @@ -28,8 +50,7 @@ follow semantic versioning; release dates are ISO 8601. which LibreOffice does not move by the mark's position — lowered a point by both, `MidnightNavy`'s duties stood 1.15pt under their bullets there — so its line keeps what the space above does not take of it, and `MidnightNavy`'s and `NavySidebar`'s duties stand as high as before. An item the - layout's lines are not matched to, as `SlateOrange`'s certifications in a table's composed cell, is - not seated. + layout's lines are not matched to is not seated. `ClassicInvoice`'s notes moved up 0.24 to 0.36pt in Word and 0.3pt in LibreOffice, to within 0.05pt of the page, `BoxedSections`' and `ModernProfessional`'s skills and projects 0.12 to 0.24pt diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 7a72b8731..9c655a57b 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -63,8 +63,8 @@ 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, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it, in the flow and in cells, not in an overlay, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; what that space cannot give, and a line Word sets high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, and one the layout's lines are not matched to not seated; a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; 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, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named | +| 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, matched at a paragraph's middle line: a line of its own Word sets a tenth of a point or more low is moved up into the space written above it and that space owed below it, in the flow and in cells, not in an overlay, a paragraph the layout moves to a new page or breaks over one, nor the space a panel's top border takes above its first line; a line opening a cell, under half a point low, takes what that space cannot give from the cell's top padding — a panel's, its row held as much taller, and a table's composed cell's in a row whose margins even — and a row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, what follows kept in place (not a row the layout moves to a new page, held to a height, or whose column opens with a panel or a table); a paragraph in a composed table cell of a placed row is raised as anywhere, its row kept on one page; what that space cannot give, and a line Word sets high, is moved in the same position where the two are half a point or more apart — Spectral's, not Lato's — which LibreOffice moves further by the face's height over its em (a list's item seated as a paragraph is — not raised into the space above where the layout opens a page with it or sets its list over two pages in a cell, an item whose marker Word draws not moved by its position, since LibreOffice does not move that marker by its mark's, and one the layout's lines are not matched to not seated (a list composed in a table cell is matched to the run of its table's lines holding its items as the page lays them out, opening at a line no node before it took, and takes their height, lineSpacing, `hangingIndent` marker column and seat as one in the flow does, an item in a row Word keeps whole rising into the gap above it — a Word list whose marker stands in its text keeping its stated column, having no place of its own; one holding an item of runs, an item whose markdown marks the page drops, a flagged list's blank item or an item run onto the next page is not matched); a table text cell's by its row's margins, see Table rows; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair, as a badge's initials or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it; an auto-sized paragraph's text that takes its style is written at the size the page fits it to, read off the laid-out lines, a run with a style of its own keeping it, on every path that writes a paragraph — at its style's size where the lines do not tell the fitted size (no lines read, lines in sizes that do not say which is the paragraph's, or one paragraph added at more than one place); a paragraph a session reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, read through the page's own parser, one run a piece in the face, family, colour, tracking and size the page's laid-out lines hold (an auto-sized one's read at the size the page fits it to, a heading at its multiple of it), its marks dropped, wherever the lines hold the pieces' letters so; where they are not read, or hold other letters or none (text the parser reads into nothing, which the page sets as nothing), as authored, its marks as letters; a `bookmark(...)` is Word's `HeadingN`, which Word's outline lists by the text of its Word paragraph — an overlay's pair's whole line, one level for both sides — at no level past the ninth. Outside a header or footer, the paragraph's report note (`ParagraphNode`) names each of these where it moves or renames something: the prefix's letters, and the room a path that writes no prefix leaves out where it moves a line; the size an auto-sized paragraph's text is written at, where the size the page fits it to is not measured; the marks of a paragraph the page read as markdown and the file holds as letters, where its laid-out lines hold fewer of them than its text, not measured where its lines are not read; a markdown heading written taller than the line the page sets it in, which Word cuts on screen; 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 that its table's lines are not matched to, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, the rows the page draws as a marker alone for blank items of a flagged list, which are not written, and what items the page reads as markdown lose. An item of plain text the page reads as markdown — the default, unless `markdown(false)` — is written as the page sets it, matched to the lines the page laid it out in and read as the page lays it out (a flat item after the marker the page sets before it; a flagged item as its text alone; an item of a list built as a tree of items without the flag after the indent and marker the page reads with it), one run a piece in the face, family, colour, tracking and size those lines hold, Word or the file drawing the marker where it did (Word draws a tree's bullets regular, as the parser sets the marker it reads), the font table shipping the faces the page sets the pieces in; it is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's (no layout, composed in a table cell and not matched to its table's lines, an item run onto the next page, a flagged list with a blank item), where its lines hold other letters, where the parser reads a tree's marker as markdown with the item (`*a*`), and where the parser reads it into nothing, and named where the page drops a mark from it or sets none of its text — not where it changes only its face or letters no mark is made of (`1.`); a markdown heading written taller than its item's line is named | | Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius 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 Word paints underneath it — the paragraph's shading, the cell's, or else the colour the page paints under the paragraph, a page background included — so the chip agrees with the file it is in and shows the colour the PDF shows. It stops being translucent, and that is recorded with the rest | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; 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) | | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space) | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index b10c0210e..9a3055254 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -185,7 +185,7 @@ it cannot work out for itself: | What | Where it lands | |---|---| -| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line keeps what the space above does not take of it. An item the layout's lines are not matched to — `SlateOrange`'s certifications, a list in a table's composed cell — is not seated. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | +| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it, and what it passes the page by — written at its tallest line, a wrapped line after a large first one, which the page sets at its own height — hung below it, as a line held to its icon is: the space below takes it, and what it cannot take is named (`space above`) (not yet a list item's, or text set over the flow; measured: `CompactMono`'s sidebar, whose skills are two lines a paragraph and each written 0.12pt taller than the page, ended a point low in Word). Lines rounded up to the twip, over enough of them, hang the same way; what rounding takes from a paragraph written at its tallest line is owed below it as well (measured: `ModernInvoice`'s address blocks, six lines of 11.475pt written at 11.45, stood the table under them 0.15pt high in Word and LibreOffice). A tenth of a point or less is left either way: Word sets lines on a grid of its own, 0.12pt apart (measured). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph of lines as tall as their text, on one page, opening a table cell takes the gap above its first line from the cell's top padding, no more than steps its lines the page's distance apart — not in a row beside a merged cell or a cell that may open with a table — anything but text, a paragraph, a list, a picture or a spacer: a table, a row of columns, a painted container, a shape — whose margin Word would set the whole row at, nor in a panel. One with no room above it for the gap (opening a column, or a cell padded less) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one an export without a layout writes — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. A paragraph's own line of drawn shapes and no text written at least their reach, which Word grows to the pictures and the transparent frame each keeps around its ink, has that frame taken from the space above the line and above what follows, where it passes the height written: the ink stands where the page draws it, and what follows where the page sets it. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice on the fifteen faces the templates use and JetBrains Mono, 8 to 36pt), and the page sets it the face's ascent below the line's top, matched at a paragraph's middle line. A line Word sets lower than the page by a tenth of a point or more is moved up into the space written above it, written to the twip, and that space is owed below it, so what follows stays where it was; both editors keep a paragraph's space above as written (by its position alone, Helvetica's lines, which the page seats 0.78 of the way down, stood 0.12 to 0.38pt low in Word). A column's later layer, which measures the space to itself from the page, takes back the raise of the line above it. Not a line pair's shared line, which moved would move its other half; not a line in an overlay — a band, a layer stack, a shape container, a canvas, a text box — whose writers measure what follows them from the page; not a line cut to fit, a container's stacked lines or one held to its pictures; not a paragraph the layout moves to a new page, whose space above Word drops there, or breaks over one; and not into the space above a panel's first line that its top border takes. A line opening a cell has no space above it of its own: a panel's padding and a table cell's are the cell's margin, and a row's columns have their space above written above the row's table. One Word sets less than half a point low takes what its space above cannot give from the top padding of the cell it opens: a table's composed cell's, in a row whose margins all even, as the gap above a first line does, the cells beside it taking the margin back above their first lines; and a panel's, which the gap does not take, less what its top border takes, the panel's row then held as much taller, as Word adds the margin left to the height a row is held at. A row of columns' first row is lifted out of the space above its table by the most its cells' first lines call for, each line rising by its own and the space under it taking that back, so what follows stays (measured: `ModernProfessional`'s entries, a title beside its dates in a row of their own, moved up 0.24pt in Word and 0.2pt in LibreOffice, `ConsultingInvoice`'s line items, composed cells, 0.24 to 0.36pt and 0.35pt, everything under them where it was). Not a row the layout moves to a new page, whose space above stays on the page above, a row held to a height, or one whose column opens with a panel or a table — a column with a fill or a border is a panel in its cell — which would rise whole; LibreOffice 24.2 counts a row's margins inside the height it is held at, so a panel or a table row held taller than its text — a drawing set where the page puts it — comes out taller there by the raise. A paragraph in a table's composed cell, in a row the layout placed, is raised into the space above it as anywhere else: the cell has no placement of its own, but its row is kept on one page; one in a table nested in that cell is not yet. What that space cannot give, and a line Word sets higher than the page — moved down, it would take its room out of the space below it, not known when it is written — is raised or lowered to the page's baseline by `w:position` where the whole difference is half a point or more, a face with a deep descent, as Spectral's is. A list's item is seated as a paragraph is: raised into the gap above it, the item under it rising into what it owes, under half a point into its cell's padding or out of the space above its row of columns where it opens one, and what is left moved by its position where the whole difference is half a point or more. Not raised into the gap, though still moved by its position where it takes one: an item the layout opens a page with, the first of a list it starts on a new page in the flow or one it sets on a later page than the item before it, whose space above Word drops there, nor one of a list the layout sets over two pages in a cell, where what an item owed would be the space above the next page's first, inside the cell. An item whose marker Word draws, a Word list's, is not moved by its position: Word sets that marker from the paragraph's mark, which LibreOffice does not move by the mark's position (measured: `MidnightNavy`'s duties, lowered a point by both, stood 1.15pt under their bullets there), so its line keeps what the space above does not take of it. A list composed in a table cell has its lines among its table's, and is matched to the run of them that holds its items in turn, opening at a line no paragraph or list before it in the table's order took — each a line of the item's text as the page lays it out, the marker and indent the page sets in that text before it, and, in a `hangingIndent` list, the line of the item's visible marker set level before it in a column of its own, a paragraph before it that the page reads as markdown matched first; its items take their height, the list's lineSpacing, a `hangingIndent` list's marker column and their seat from those lines as a list's in the flow do, and an item in a row Word keeps whole on its page rises into the gap above it, as a paragraph composed there does (measured: `SlateOrange`'s certifications, written at no height at all, stood 0.3pt lower with every item, 1.18pt low by the fourth in Word; matched, 0.26pt low at most). Having no place of its own, a Word list composed so whose marker the page sets in its text keeps its stated column. An item the layout's lines are not matched to is not seated — among them every item of a list in a table's composed cell holding an item of runs, whose text is not read there, one the page reads as markdown, its marks dropped, or a blank item of a `hangingIndent` list, which the page draws as a marker alone. A table text cell's lines are seated by its row's margins where its row allows (see "Tables"). LibreOffice moves text by `w:position` further than it is raised, by the face's height over its em — Spectral's 1.53 times, Poppins' 1.49, Volkhov's 1.31 (measured) — so a line raised by its position stands where the page sets it in Word only: Spectral's 24pt line, raised 4pt, stood 2.4pt high in LibreOffice. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters | | Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them | | Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin — in a painted row's panel the padding is the panel cell's margins instead, and the columns are without it. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed | @@ -459,7 +459,9 @@ top level of a list that nests any of them. The report names what a list's items lose, on the list (`ListNode`): its items are written flush left where it is centred or right-aligned; its lineSpacing is not written where the layout's items are not its own and one wraps — an item run onto the next page wraps, even split -a line apiece — nor in a list composed in a table cell, whose wrapping is not measured; its +a line apiece — nor in a list composed in a table cell that its table's lines are not matched +to, one holding an item of runs or an item the page reads as markdown, whose wrapping is not +measured; its continuationIndent is not written in a list the page sets it in, a markerless list or a tree of items without `hangingIndent`, where an item wraps or its wrapping is not measured; it counts the items that stand at a stated column, a space past their marker or two spaces a level in, @@ -480,7 +482,8 @@ writes its marker and nesting indent as characters before the pieces, in the fac them in. The font table ships the faces the page sets the pieces in. The report names a heading written taller than its item's line, which Word cuts on screen. An item is written as authored, its marks as letters, where its list's items are not matched one by one to the layout's — with -no layout, composed in a table cell, an item run onto the next page, a `hangingIndent` list with +no layout, composed in a table cell — whose lines a list holding an item of runs, or one the page +reads as markdown, is not matched to — an item run onto the next page, a `hangingIndent` list with a blank item the page draws as a marker alone — where its lines hold other letters than its pieces, as Arabic, or where the parser reads the marker of a tree's item as markdown with it, as `*a*`; the report names it where the page drops a mark from it. An item the parser reads into diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv index 90b89ad0d..5e7ed4d38 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -1477,10 +1477,10 @@ cv-slate_orange 0:84ba6b88f439 -0.25 0.07 certifications&training cv-slate_orange 0:9d7190053be6 0.15 0.07 bachelorofartsincommunications cv-slate_orange 0:f592b4ba6aa4 0.12 0.07 universityoftexasataustin cv-slate_orange 0:3f84d2e470fe 0.12 0.07 austin,tx•graduatedmay2018 -cv-slate_orange 0:99b337b1faf4 0.15 0.07 •googleprojectmanagementcertific -cv-slate_orange 0:4d1b69dc33a1 0.45 0.07 •hubspotcustomerservicecertifica -cv-slate_orange 0:01a1e16912b7 0.75 0.07 •microsoftexcel:advancedformulas -cv-slate_orange 0:07ef0ade6bb2 1.05 0.07 •servsafefoodhandlercertificatio +cv-slate_orange 0:99b337b1faf4 0.10 0.07 •googleprojectmanagementcertific +cv-slate_orange 0:4d1b69dc33a1 -0.10 0.07 •hubspotcustomerservicecertifica +cv-slate_orange 0:01a1e16912b7 -0.10 0.07 •microsoftexcel:advancedformulas +cv-slate_orange 0:07ef0ade6bb2 -0.10 0.07 •servsafefoodhandlercertificatio cv-teal_pulse 0:a5800de7a5df -1.87 0.06 isabellamoore cv-teal_pulse 0:aea71faf1482 -0.38 0.06 registerednurse cv-teal_pulse 0:613c7b77c405 1.08 0.17 manchester,unitedkingdom diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 1414e02d3..482482edc 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv @@ -21,7 +21,7 @@ cv-panel 1 1 79 74 0.26 0.55 0 cv-professional_sidebar 1 1 68 64 0.36 0.58 0 cv-serif_headline 1 1 103 103 0.44 0.88 0 cv-sidebar_portrait 1 1 49 49 0.17 0.31 0 -cv-slate_orange 1 1 87 86 0.32 1.02 0 +cv-slate_orange 1 1 87 86 0.31 0.99 0 cv-teal_pulse 1 1 57 57 0.22 0.63 0 cv-terracotta_rail 1 1 102 102 0.44 0.94 0 cv-timeline_minimal 2 2 89 87 0.45 0.49 0 diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index 12220fe35..f70e4800b 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -1376,10 +1376,10 @@ cv-slate_orange 0:84ba6b88f439 -0.25 0.07 certifications&training cv-slate_orange 0:9d7190053be6 0.15 0.07 bachelorofartsincommunications cv-slate_orange 0:f592b4ba6aa4 0.12 0.07 universityoftexasataustin cv-slate_orange 0:3f84d2e470fe 0.12 0.07 austin,tx•graduatedmay2018 -cv-slate_orange 0:99b337b1faf4 0.15 0.07 •googleprojectmanagementcertific -cv-slate_orange 0:4d1b69dc33a1 0.45 0.07 •hubspotcustomerservicecertifica -cv-slate_orange 0:01a1e16912b7 0.75 0.07 •microsoftexcel:advancedformulas -cv-slate_orange 0:07ef0ade6bb2 1.05 0.07 •servsafefoodhandlercertificatio +cv-slate_orange 0:99b337b1faf4 0.10 0.07 •googleprojectmanagementcertific +cv-slate_orange 0:4d1b69dc33a1 -0.10 0.07 •hubspotcustomerservicecertifica +cv-slate_orange 0:01a1e16912b7 -0.10 0.07 •microsoftexcel:advancedformulas +cv-slate_orange 0:07ef0ade6bb2 -0.10 0.07 •servsafefoodhandlercertificatio cv-teal_pulse 0:a5800de7a5df -1.87 0.06 isabellamoore cv-teal_pulse 0:aea71faf1482 -0.38 0.06 registerednurse cv-teal_pulse 0:613c7b77c405 1.08 0.12 manchester,unitedkingdom diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index b851441fc..4f9c5c0eb 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv @@ -21,7 +21,7 @@ cv-panel 1 1 79 74 0.26 0.55 0 cv-professional_sidebar 1 1 68 64 0.36 0.58 0 cv-serif_headline 1 1 103 103 0.44 0.88 0 cv-sidebar_portrait 1 1 49 49 0.17 0.31 0 -cv-slate_orange 1 1 87 86 0.32 1.02 0 +cv-slate_orange 1 1 87 86 0.31 0.99 0 cv-teal_pulse 1 1 57 57 0.22 0.64 0 cv-terracotta_rail 1 1 102 102 0.44 0.94 0 cv-timeline_minimal 2 2 89 87 0.45 0.49 0 diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv index 5d72dc50d..c5a8ca599 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -1475,9 +1475,9 @@ cv-slate_orange 0:9d7190053be6 0.34 0.06 bachelorofartsincommunications cv-slate_orange 0:f592b4ba6aa4 0.27 0.06 universityoftexasataustin cv-slate_orange 0:3f84d2e470fe 0.26 0.06 austin,tx•graduatedmay2018 cv-slate_orange 0:99b337b1faf4 0.26 0.09 •googleprojectmanagementcertific -cv-slate_orange 0:4d1b69dc33a1 0.48 0.09 •hubspotcustomerservicecertifica -cv-slate_orange 0:01a1e16912b7 0.84 0.09 •microsoftexcel:advancedformulas -cv-slate_orange 0:07ef0ade6bb2 1.18 0.09 •servsafefoodhandlercertificatio +cv-slate_orange 0:4d1b69dc33a1 0.00 0.09 •hubspotcustomerservicecertifica +cv-slate_orange 0:01a1e16912b7 0.12 0.09 •microsoftexcel:advancedformulas +cv-slate_orange 0:07ef0ade6bb2 0.10 0.09 •servsafefoodhandlercertificatio cv-teal_pulse 0:a5800de7a5df -0.06 -0.03 isabellamoore cv-teal_pulse 0:aea71faf1482 0.16 -0.03 registerednurse cv-teal_pulse 0:613c7b77c405 1.20 0.01 manchester,unitedkingdom diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index 52c070114..d5be57e0c 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv @@ -21,7 +21,7 @@ cv-panel 1 1 79 74 0.31 0.63 0 cv-professional_sidebar 1 1 68 64 0.30 0.49 0 cv-serif_headline 1 1 103 103 0.50 1.03 0 cv-sidebar_portrait 1 1 49 49 0.15 0.29 0 -cv-slate_orange 1 1 87 85 0.40 1.17 0 +cv-slate_orange 1 1 87 85 0.40 1.15 0 cv-teal_pulse 1 1 57 57 0.11 0.38 0 cv-terracotta_rail 1 1 102 102 0.36 0.87 0 cv-timeline_minimal 2 2 89 87 0.36 0.47 0 diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 55a7ea488..0eff7597e 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -14,6 +14,8 @@ import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.InlineRun; import com.demcha.compose.document.node.InlineTextRun; +import com.demcha.compose.document.node.ListItem; +import com.demcha.compose.document.node.ListNode; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.TableNode; import com.demcha.compose.document.table.DocumentTableCell; @@ -69,8 +71,8 @@ final class DocxLayoutMetrics { private final Map> placedRows = new IdentityHashMap<>(); // The heights of a table's placed rows, by index, filled on first use — see rowHeightsOf. private final Map> rowHeights = new IdentityHashMap<>(); - // Paragraphs composed in table cells, paired with their fragments on first use. - private Map composedText; + // Paragraphs and lists composed in table cells, paired with their fragments on first use. + private Map> composedText; // The text and pictures of each page, indexed on first use — see textOnPage. private Map> textByPage; // Each page's fragments in paint order, indexed on first use — see clipsOf. @@ -441,7 +443,9 @@ OptionalDouble placedWidth(DocumentNode node) { * column of their own lays the marker out as one more fragment, level with its item's * text and as tall: the two are one item, its text's lines the item's, and the marker's * single line does not make the item a wrapped one. The text is the fragment to the right, - * or the later where the two start together — a marker that draws nothing, with no gap.

+ * or the later where the two start together — a marker that draws nothing, with no gap. A list + * composed in a table cell has its items among its table's fragments, the ones it is matched to + * ({@link #matchComposedText}).

* * @param list a list node * @return every item's text, empty when the list laid out nothing @@ -449,14 +453,11 @@ OptionalDouble placedWidth(DocumentNode node) { List itemLines(DocumentNode list) { List items = new ArrayList<>(); PlacedFragment previous = null; - for (PlacedFragment fragment : fragmentsOf(list)) { + for (PlacedFragment fragment : textFragmentsOf(list)) { if (!(fragment.payload() instanceof ParagraphFragmentPayload paragraph)) { continue; } - boolean sameItem = previous != null - && previous.pageIndex() == fragment.pageIndex() - && Math.abs(previous.y() - fragment.y()) < 0.01 - && Math.abs(previous.height() - fragment.height()) < 0.01; + boolean sameItem = previous != null && level(previous, fragment); ItemText text = new ItemText(paragraph.lines(), fragment.x() + paragraph.padding().left(), fragment.pageIndex()); if (!sameItem) { @@ -857,7 +858,7 @@ OptionalDouble markerToText(DocumentNode list) { * @return the distance in points, or empty when the list laid out no such marker beside its text */ OptionalDouble markerToText(DocumentNode list, String markerText) { - List own = fragmentsOf(list); + List own = textFragmentsOf(list); int index = markerFragment(own, markerText); return index < 0 ? OptionalDouble.empty() : OptionalDouble.of(own.get(index + 1).x() - own.get(index).x()); } @@ -867,7 +868,7 @@ OptionalDouble markerToText(DocumentNode list, String markerText) { * text given (see {@link #markerToText(DocumentNode, String)}). */ OptionalDouble markerWidth(DocumentNode list, String markerText) { - List own = fragmentsOf(list); + List own = textFragmentsOf(list); int index = markerFragment(own, markerText); if (index < 0 || !(own.get(index).payload() instanceof ParagraphFragmentPayload marker)) { return OptionalDouble.empty(); @@ -1138,83 +1139,107 @@ private List fragmentsOf(DocumentNode node) { } /** - * The fragments that hold a node's lines of text: its own, or — for a paragraph composed - * in a table cell, which has no path of its own — the one its table laid out for it. + * The fragments that hold a node's lines of text: its own, or — for a paragraph or a list + * composed in a table cell, which has no path of its own — the ones its table laid out for it. */ private List textFragmentsOf(DocumentNode node) { List own = fragmentsOf(node); - if (!own.isEmpty() || !(node instanceof ParagraphNode) || fragments.isEmpty()) { + if (!own.isEmpty() || !(node instanceof ParagraphNode || node instanceof ListNode) || fragments.isEmpty()) { return own; } if (composedText == null) { composedText = matchComposedText(); } - PlacedFragment matched = composedText.get(node); - return matched == null ? List.of() : List.of(matched); + return composedText.getOrDefault(node, List.of()); } /** - * Pairs every paragraph composed in a table cell with the fragment its table laid out for it. + * Pairs every paragraph and every list composed in a table cell with the fragments its table + * laid out for it. * *

A composed cell's content is laid out under the table's path, so its paragraphs' lines * sit among the table's own fragments and not at a path of their own. Without them a * paragraph in a cell was written at the height Word gives the face — 13.4pt for a 9pt * Gothic A1 line the page sets at 9.1 — and every row of such a table ran taller than the - * page's. Cells are laid out in order, and so is what each holds, so the paragraphs are - * walked in that order and each takes the first fragment not yet taken whose text is - * its own as authored; those left then take the first whose lines set their markdown as the + * page's. Cells are laid out in order, and so is what each holds, so the paragraphs and lists + * are walked in that order. Each paragraph takes the first fragment not yet taken whose text + * is its own as authored; those left then take the first whose lines set their markdown as the * page reads it, marks dropped, in its faces and sizes. A paragraph whose text no fragment - * carries so is left without one, as before.

- */ - private Map matchComposedText() { - Map matched = new IdentityHashMap<>(); + * carries so is left without one, as before. A list takes the run of lines that holds its items + * as the page lays them out, opening at a line no node before it took ({@link #itemFragments}). + * The paragraphs left before a list are matched by their markdown before it is: one the page + * reads as {@code **0.00**} sets its line as {@code 0.00}, which a list's item of that text + * would take first.

+ */ + private Map> matchComposedText() { + Map> matched = new IdentityHashMap<>(); for (DocumentNode table : paths.keySet()) { if (!(table instanceof TableNode tableNode)) { continue; } - List paragraphs = new ArrayList<>(); - collectComposedParagraphs(tableNode, paragraphs); - if (paragraphs.isEmpty()) { + List composed = new ArrayList<>(); + collectComposedText(tableNode, composed); + if (composed.isEmpty()) { continue; } // One queue per text, in layout order: each paragraph takes the first of its own text - // still waiting, so pairing a table is linear in its cells. - Map> byText = new HashMap<>(); + // still waiting, and each list the first run its first item's opens, so pairing a table + // is linear in its cells. + TableLines lines = new TableLines(); for (PlacedFragment fragment : fragmentsOf(table)) { - if (fragment.payload() instanceof ParagraphFragmentPayload paragraph) { - StringBuilder text = new StringBuilder(); - paragraph.lines().forEach(line -> text.append(line.text())); - byText.computeIfAbsent(comparable(text.toString()), key -> new java.util.ArrayDeque<>()) - .add(fragment); + if (fragment.payload() instanceof ParagraphFragmentPayload) { + lines.add(fragment); } } List unmatched = new ArrayList<>(); - for (ParagraphNode paragraph : paragraphs) { - java.util.ArrayDeque waiting = waitingFor(byText, authoredText(paragraph)); + for (DocumentNode node : composed) { + if (node instanceof ListNode list) { + matchByMarkdown(unmatched, lines, matched); + List items = itemFragments(list, lines); + if (!items.isEmpty()) { + items.forEach(lines::take); + matched.put(list, items); + } + continue; + } + ParagraphNode paragraph = (ParagraphNode) node; + java.util.ArrayDeque waiting = waitingFor(lines.byText, authoredText(paragraph)); if (waiting != null) { - matched.put(paragraph, waiting.poll()); + PlacedFragment fragment = waiting.peek(); + lines.take(fragment); + matched.put(paragraph, List.of(fragment)); } else { unmatched.add(paragraph); } } - // Then by its text as the page reads its markdown, where its session does: marks - // dropped, and only a fragment whose lines set the pieces as they are read. A paragraph - // of the same text as authored may have taken this one's own fragment: the one left - // is in another face or size, and taken, its lines would cut this one's letters. - for (ParagraphNode paragraph : unmatched) { - if (DocxMarkdown.mayRead(paragraph) && paragraph.textStyle() != null) { - List pieces = DocxMarkdown.read(paragraph.text(), paragraph.textStyle()); - java.util.ArrayDeque waiting = waitingFor(byText, DocxMarkdown.text(pieces)); - PlacedFragment fragment = waiting == null ? null : waiting.stream() - .filter(candidate -> setsThePieces(paragraph, pieces, candidate)).findFirst().orElse(null); - if (fragment != null) { - waiting.remove(fragment); - matched.put(paragraph, fragment); - } + matchByMarkdown(unmatched, lines, matched); + } + return matched; + } + + /** + * Matches the paragraphs left by their text as the page reads its markdown, where its session + * does: marks dropped, and only a fragment whose lines set the pieces as they are read. A + * paragraph of the same text as authored may have taken this one's own fragment: the one left + * is in another face or size, and taken, its lines would cut this one's letters. + * + * @param unmatched the paragraphs left, in layout order; emptied + */ + private static void matchByMarkdown(List unmatched, TableLines lines, + Map> matched) { + for (ParagraphNode paragraph : unmatched) { + if (DocxMarkdown.mayRead(paragraph) && paragraph.textStyle() != null) { + List pieces = DocxMarkdown.read(paragraph.text(), paragraph.textStyle()); + java.util.ArrayDeque waiting = waitingFor(lines.byText, DocxMarkdown.text(pieces)); + PlacedFragment fragment = waiting == null ? null : waiting.stream() + .filter(candidate -> setsThePieces(paragraph, pieces, candidate)).findFirst().orElse(null); + if (fragment != null) { + lines.take(fragment); + matched.put(paragraph, List.of(fragment)); } } } - return matched; + unmatched.clear(); } /** @@ -1239,27 +1264,186 @@ private static java.util.ArrayDeque waitingFor(Map into) { + /** + * A table's lines of text in layout order, each with its text as {@link #comparable} has it, and + * the ones no paragraph or list composed in its cells has taken yet, a queue per text. + */ + private static final class TableLines { + private final List fragments = new ArrayList<>(); + private final Map index = new IdentityHashMap<>(); + private final List keys = new ArrayList<>(); + private final Map> byText = new HashMap<>(); + + void add(PlacedFragment fragment) { + StringBuilder text = new StringBuilder(); + ((ParagraphFragmentPayload) fragment.payload()).lines().forEach(line -> text.append(line.text())); + String key = comparable(text.toString()); + index.put(fragment, fragments.size()); + fragments.add(fragment); + keys.add(key); + byText.computeIfAbsent(key, ignored -> new java.util.ArrayDeque<>()).add(fragment); + } + + void take(PlacedFragment fragment) { + java.util.ArrayDeque waiting = byText.get(keys.get(index.get(fragment))); + // A paragraph takes the first of its text, and a list's lines are mostly the first of theirs. + if (waiting.peekFirst() == fragment) { + waiting.pollFirst(); + } else { + waiting.removeIf(line -> line == fragment); + } + } + } + + /** + * An item of a list as its table's lines hold it: the text of the line the page sets it in, its + * marker and indent before it where the page sets them in its text, and the marker the page sets + * in a column of its own before it, or {@code null} for none. + */ + private record ItemLine(String text, com.demcha.compose.document.node.ListMarker column) { + } + + /** + * The fragments a list composed in a table cell was laid out in, among its table's lines in + * layout order: the run that holds each of its items in turn, opening at a line of its first + * item's no paragraph or list before it took — a line whose text is the item's as the page lays + * it out, its marker and indent before it where the page sets them in its text + * ({@link DocxMarkdown#items}), and, for a {@code hangingIndent} list, the item's marker before it + * where the page sets one in a column of its own, level with it ({@link #itemLines}). Its lines + * after the opening are the list's own: the table lays a cell's content out whole, in order. + * + *

Empty, the list left without lines as before, where no run of its table's lines holds its + * items so: an item of runs, whose text is not read here; a blank item the page sets a marker + * alone for, as in the flow; an item the page runs onto the next page, laid out as a piece on + * each; one whose text the page reads as markdown, its marks dropped.

+ */ + private static List itemFragments(ListNode list, TableLines lines) { + List items = itemLinesOf(list); + if (items.isEmpty()) { + return List.of(); + } + // A run opens at its first item's line, or at the marker set level before it. + java.util.ArrayDeque openings = lines.byText.get(items.get(0).text()); + for (PlacedFragment opening : openings == null ? List.of() : openings) { + int at = lines.index.get(opening); + boolean marked = at > 0 && isTheMarker(lines, at - 1, items.get(0)); + List run = itemsFrom(lines, marked ? at - 1 : at, items); + if (run != null) { + return run; + } + } + return List.of(); + } + + /** The lines from {@code start} on that hold the items in turn, or {@code null} where they do not. */ + private static List itemsFrom(TableLines lines, int start, List items) { + List run = new ArrayList<>(); + int at = start; + for (ItemLine item : items) { + if (at < lines.fragments.size() && isTheMarker(lines, at, item)) { + run.add(lines.fragments.get(at++)); + } + if (at >= lines.fragments.size() || !lines.keys.get(at).equals(item.text())) { + return null; + } + run.add(lines.fragments.get(at++)); + } + return run; + } + + /** + * Whether the line at {@code at} is an item's marker set in a column of its own: the line the page + * lays out for a visible marker just before its item's text, level with it — only a drawing, for a + * drawn one. A marker that draws nothing lays out no line, so the line level before the item's is + * another node's: a paragraph's in the cell before. + */ + private static boolean isTheMarker(TableLines lines, int at, ItemLine item) { + if (item.column() == null || !item.column().isVisible() || at + 1 >= lines.fragments.size() + || !lines.keys.get(at + 1).equals(item.text()) || !level(lines.fragments.get(at), lines.fragments.get(at + 1))) { + return false; + } + return !item.column().isRich() || drawsOnly((ParagraphFragmentPayload) lines.fragments.get(at).payload()); + } + + /** + * Every item a list writes, as its table's lines would hold it ({@link ItemLine}), in the order + * {@link DocxMarkdown#items} reads them; empty for a list that cannot be matched: one holding an + * item of runs, a blank item of a {@code hangingIndent} list, or an item of no text. + */ + private static List itemLinesOf(ListNode list) { + if (holdsAnItemOfRuns(list.nestedItems())) { + return List.of(); + } + List markers = new ArrayList<>(); + for (String item : list.items()) { + if (!com.demcha.compose.document.node.ListMarker.normalizeItemText(item, list.normalizeMarkers()).isBlank()) { + markers.add(list.marker()); + } else if (list.hangingIndent() && list.nestedItems().isEmpty() && list.marker().isVisible()) { + // The page draws its marker as a row of its own, which the export does not write. + return List.of(); + } + } + addMarkers(list.nestedItems(), 0, markers); + // One reading an item, in the same order: no item of runs, which the readings leave out. + List readings = DocxMarkdown.items(list); + List items = new ArrayList<>(); + for (int index = 0; index < readings.size(); index++) { + DocxMarkdown.ItemReading reading = readings.get(index); + String text = comparable(reading.prefix() + reading.text()); + if (text.isEmpty()) { + return List.of(); + } + items.add(new ItemLine(text, list.hangingIndent() ? markers.get(index) : null)); + } + return items; + } + + /** The marker each item of a tree takes, its own or its depth's, depth first. */ + private static void addMarkers(List items, int depth, + List into) { + for (ListItem item : items) { + into.add(item.marker() != null ? item.marker() : com.demcha.compose.document.node.ListMarker.defaultForDepth(depth)); + addMarkers(item.children(), depth + 1, into); + } + } + + /** Whether two fragments stand level, one line of a list's item: its marker and its text. */ + private static boolean level(PlacedFragment one, PlacedFragment other) { + return one.pageIndex() == other.pageIndex() + && Math.abs(one.y() - other.y()) < 0.01 + && Math.abs(one.height() - other.height()) < 0.01; + } + + private static boolean holdsAnItemOfRuns(List items) { + for (ListItem item : items) { + if (item.isRich() || holdsAnItemOfRuns(item.children())) { + return true; + } + } + return false; + } + + /** The paragraphs and lists a table's composed cells hold, nested tables' included, in layout order. */ + private static void collectComposedText(TableNode table, List into) { for (List row : table.rows()) { for (DocumentTableCell cell : row) { if (cell != null && cell.content() != null) { - collectParagraphs(cell.content(), into); + collectText(cell.content(), into); } } } } - private static void collectParagraphs(DocumentNode node, List into) { - if (node instanceof ParagraphNode paragraph) { - into.add(paragraph); + private static void collectText(DocumentNode node, List into) { + if (node instanceof ParagraphNode || node instanceof ListNode) { + into.add(node); return; } if (node instanceof TableNode nested) { - collectComposedParagraphs(nested, into); + collectComposedText(nested, into); } for (DocumentNode child : node.children()) { - collectParagraphs(child, into); + collectText(child, into); } } 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 da44d6a46..01c6c0a54 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 @@ -4341,7 +4341,8 @@ private void writeList(XWPFDocument document, *

Its alignment: every item is written flush left. Its lineSpacing, where the layout's * items are not matched to the list's and one of them wraps — an item run onto the next page * is laid out as a piece on each, and wraps whatever its pieces hold — or where the list has - * no lines of its own to read, composed in a table cell: the gap is put between an item's lines + * no lines to read, composed in a table cell whose lines it is not matched to, as one holding an + * item of runs ({@code DocxLayoutMetrics.itemFragments}): the gap is put between an item's lines * only by its laid-out lines (see {@link #applyItemLineGap}). With no layout at all, the * section's own note says the space between lines is the editor's. Its continuationIndent, * which the page sets before every wrapped line of a list whose markers are not drawn before @@ -4451,7 +4452,8 @@ private static long lettersIn(String text) { /** * What a list's items lose where the page reads them as markdown, its items not matched to the - * layout's — with no layout, composed in a table cell, one run onto the next page, or a blank + * layout's — with no layout, composed in a table cell whose lines they are not matched to, one + * run onto the next page, or a blank * item of a {@code hangingIndent} list the page draws as a marker alone — and so each written * as authored, as a paragraph's text is ({@link #markdownLost}): an item of plain text holding a * mark of emphasis or code is set as markdown, its marks dropped. Its text is read as the page @@ -4852,10 +4854,12 @@ private void writeListLine(XWPFDocument document, DocumentTextStyle style, *

Not raised into the space above, though still moved by its position where it takes one: an * item the layout opens a page with — the first of a list it starts on a new page in the flow, or * one on a later page than the item before it. Word drops its space above at the top of a page, - * and what its line owed would stand the rest lower. Nor an item of a list the layout sets over - * two pages in a cell, the cell going on on the next: what an item owed there would be the space - * above the one opening that page, inside the cell, which Word is not known to drop. An item the - * layout's lines are not matched to is not seated at all.

+ * and what its line owed would stand the rest lower. Nor an item in a cell whose list the layout + * does not set on one page — over two, the cell going on on the next, or composed in a table's + * cell, which gives it no place of its own — unless Word keeps the cell's row whole on its page + * ({@link #inARowKeptWhole}), as it does a paragraph composed there: what an item owed would be + * the space above the one opening the next page, inside the cell, which Word is not known to + * drop. An item the layout's lines are not matched to is not seated at all.

* * @param laidOut how the layout set the item, or {@code null} where its items are * not matched @@ -4867,7 +4871,7 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut, } boolean opensItsPage = itemPage < 0 ? listOpensItsPage : laidOut.page() > itemPage; itemPage = laidOut.page(); - boolean staysOnItsPage = !opensItsPage && (currentCell == null || listOnOnePage); + boolean staysOnItsPage = !opensItsPage && (currentCell == null || listOnOnePage || inARowKeptWhole()); double shift = shiftToThePagesBaseline(para, laidOut.lines(), listLineGap, 0, false, true, staysOnItsPage); if (!wordDrawsTheMarker) { raiseRunsFrom(para, 0, Math.round(shift * HALF_POINTS_PER_POINT)); @@ -8614,8 +8618,8 @@ private String markdownLost(ParagraphNode node, List @@ -10013,7 +10018,8 @@ private double shiftToThePagesBaseline(XWPFParagraph para, * @param para the Word paragraph * @param staysOnItsPage whether what the line takes from the space above stays above it on its * page: not where the layout opens a page with the line or breaks it over - * one, nor sets the list it is an item of over two pages in a cell + * one, nor, in a row Word may break, sets the list it is an item of in a + * cell other than on one page * @param spacing the paragraph's spacing, its space above already written * @param raise how far Word sets the line below the page's, in points * @param intoThePadding whether the cell's padding may give what the space above cannot diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java new file mode 100644 index 000000000..5969ce49b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedListTest.java @@ -0,0 +1,324 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.font.FontName; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; + +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A list composed in a table's cell is written on the lines its table laid out for it, as a + * paragraph composed there is, and as a list in the flow is on its own. + * + *

A composed cell's content is laid out under its table's path, so a list there has no lines at + * a path of its own. Matched to none, its items were written at no height at all, and Word set each + * at its face's own line: {@code SlateOrange}'s certifications, a list beside the education column, + * stood 0.3pt lower with every item in Word and LibreOffice, the fourth 1.2pt low in Word.

+ */ +class DocxComposedListTest { + + @Test + void aListInACellIsWrittenAtTheHeightThePageSetsItsLines() throws Exception { + try (XWPFDocument flow = DocxExports.withLayout(400, 600, 20, page -> page.add(list()) + .addParagraph(p -> p.text("After"))); + XWPFDocument cell = inACellBeside(list())) { + for (String item : List.of("Setup", "Training", "Support")) { + XWPFParagraph composed = inACell(cell, item); + + assertThat(spacing(composed).getLineRule()).as(item).hasToString("exact"); + assertThat(line(composed)).as(item + ", as in the flow").isEqualTo(line(inTheBody(flow, item))); + } + } + } + + @Test + void aWrappedItemInACellTakesTheListsLineSpacingBetweenItsLines() throws Exception { + // As in the flow: the gap goes into the item's line and comes off the space above it. + String wrapped = "Training that runs long enough to wrap onto a second line of the cell"; + try (XWPFDocument document = inACellBeside(new ListBuilder().name("Items").bullet().itemSpacing(3).lineSpacing(2) + .textStyle(size(9)).items("Setup", wrapped, "Support").build())) { + XWPFParagraph one = inACell(document, "Setup"); + XWPFParagraph two = inACell(document, wrapped); + XWPFParagraph three = inACell(document, "Support"); + + assertThat(DocxTwips.of(spacing(two).getLine()) - DocxTwips.of(spacing(one).getLine())) + .as("2pt between its lines").isEqualTo(40); + assertThat(before(two)).as("3pt above it, less the gap").isEqualTo(60 - 40); + assertThat(before(three)).isEqualTo(60); + } + } + + @Test + void aListAndAParagraphOfOneTextInATableEachTakeTheirOwnLines() throws Exception { + // Matched in the order the table laid them out: neither takes the other's line. + try (XWPFDocument document = inACellBeside( + new ListBuilder().name("Amounts").noMarker().textStyle(size(16)).items("0.00").build(), + new ParagraphBuilder().text("0.00").textStyle(size(8)).build())) { + List amounts = cellParagraphs(document, "0.00"); + + assertThat(amounts).hasSize(2); + assertThat(line(amounts.get(0))).as("the list's, at 16pt").isEqualTo(2 * line(amounts.get(1))); + } + try (XWPFDocument document = inACellBeside( + new ParagraphBuilder().text("0.00").textStyle(size(8)).build(), + new ListBuilder().name("Amounts").noMarker().textStyle(size(16)).items("0.00").build())) { + List amounts = cellParagraphs(document, "0.00"); + + assertThat(amounts).hasSize(2); + assertThat(line(amounts.get(1))).as("the list's, at 16pt").isEqualTo(2 * line(amounts.get(0))); + } + } + + @Test + void aListsMarkerColumnInACellIsTheOneThePageSets() throws Exception { + // Its marker set in a column of its own, the list's lines say how far its text stands past it. + java.util.concurrent.atomic.AtomicReference report = new java.util.concurrent.atomic.AtomicReference<>(); + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) { + // One item: its marker is the only one the column is measured by. + session.pageFlow(beside(new ListBuilder().name("Steps").bullet().hangingIndent(true).markerGap(8) + .items("Setup").build())::accept); + session.export(new DocxSemanticBackend(report::set)); + } + + assertThat(report.get().bySubject().getOrDefault("ListNode", List.of())).isEmpty(); + try (XWPFDocument flow = DocxExports.withLayout(400, 600, 20, page -> page.add(hanging())); + XWPFDocument cell = inACellBeside(hanging())) { + XWPFParagraph composed = inACell(cell, "Setup"); + + assertThat(composed.getNumID()).as("the premise: a Word list's item").isNotNull(); + assertThat(hanging(cell, composed)).as("its level's column, as in the flow") + .isEqualTo(hanging(flow, inTheBody(flow, "Setup"))); + } + } + + private static DocumentNode hanging() { + return new ListBuilder().name("Steps").bullet().hangingIndent(true).markerGap(8).textStyle(size(9)) + .items("Setup", "Support").build(); + } + + /** The hanging indent of the numbering level an item is written at, in twips. */ + private static long hanging(XWPFDocument document, XWPFParagraph item) { + var numbering = document.getNumbering(); + var abstractId = numbering.getNum(item.getNumID()).getCTNum().getAbstractNumId().getVal(); + return DocxTwips.of(numbering.getAbstractNum(abstractId).getCTAbstractNum().getLvlArray(0).getPPr().getInd() + .getHanging()); + } + + @Test + void aMarkdownParagraphLevelWithAListKeepsItsOwnLine() throws Exception { + // The page reads the paragraph's markdown, so its line waits for the markdown pass after + // the list is matched. A hanging list whose marker draws nothing lays out no line for it: + // the line level before its item's, the paragraph's in the cell before, is not its marker. + try (XWPFDocument document = inACellBeside( + new ParagraphBuilder().text("**Languages**").textStyle(size(9)).build(), + new ListBuilder().name("Items").noMarker().hangingIndent(true).textStyle(size(9)) + .items("Python").build())) { + assertThat(spacing(inACell(document, "Languages")).getLineRule()).hasToString("exact"); + assertThat(spacing(inACell(document, "Python")).getLineRule()).hasToString("exact"); + } + } + + @Test + void aListDoesNotTakeTheLineOfAParagraphWhoseTextEndsWithItsItem() throws Exception { + // The paragraph is read as markdown and matched last; the list's item is its line's whole text. + try (XWPFDocument document = inACellBeside( + new ParagraphBuilder().text("**Paid** in full").textStyle(size(8)).build(), + new ListBuilder().name("Terms").noMarker().textStyle(size(16)).items("in full").build())) { + XWPFParagraph paragraph = cellParagraphs(document, "Paid in full").get(0); + XWPFParagraph item = inACell(document, "in full"); + + assertThat(line(item)).as("the list's own line, at 16pt").isEqualTo(2 * line(paragraph)); + } + } + + @Test + void aListWhoseItemsNoRunOfItsTablesLinesHoldsTakesNoneOfThem() throws Exception { + // The page reads the second item as markdown, its marks dropped: the list's own lines hold its + // first item as written and not its second, and no part of the run is taken. + try (XWPFDocument document = inACellBeside( + new ListBuilder().name("Items").bullet().textStyle(size(16)).items("Setup", "**Support**").build())) { + CTSpacing item = spacing(inACell(document, "Setup")); + + assertThat(spacing(inACell(document, "Mon")).getLineRule()).as("the premise: a layout behind the export") + .hasToString("exact"); + assertThat(item == null || !item.isSetLineRule()).as("the list's, matched to none").isTrue(); + } + } + + @Test + void aMarkdownParagraphBeforeAListOfItsTextKeepsItsLine() throws Exception { + // The page sets the paragraph's line as 0.00, its marks dropped: matched by its markdown before + // the list is matched, it keeps its line, and the list's item takes its own. + try (XWPFDocument document = inACellBeside( + new ParagraphBuilder().text("**0.00**").textStyle(size(8)).build(), + new ListBuilder().name("Amounts").noMarker().textStyle(size(16)).items("0.00").build())) { + List amounts = cellParagraphs(document, "0.00"); + + assertThat(amounts).hasSize(2); + assertThat(spacing(amounts.get(0)).getLineRule()).as("the paragraph's").hasToString("exact"); + assertThat(line(amounts.get(1))).as("the list's, at 16pt").isEqualTo(2 * line(amounts.get(0))); + } + } + + @Test + void aListWhoseMarkerIsDrawnInItsOwnColumnInACellIsWrittenOnItsLines() throws Exception { + // A drawn marker's line holds only its drawing, level with its item's text. + try (XWPFDocument flow = DocxExports.withLayout(400, 600, 20, page -> page.add(dotted())); + XWPFDocument cell = inACellBeside(dotted())) { + XWPFParagraph composed = cell.getTables().get(0).getRow(0).getCell(1).getParagraphs().stream() + .filter(paragraph -> paragraph.getText().contains("Setup")).findFirst().orElseThrow(); + XWPFParagraph inTheFlow = flow.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().contains("Setup")).findFirst().orElseThrow(); + + assertThat(spacing(composed).getLineRule()).hasToString("exact"); + assertThat(line(composed)).as("as in the flow").isEqualTo(line(inTheFlow)); + } + } + + @Test + void aTreeOfItemsInACellIsWrittenOnItsLines() throws Exception { + // The page lays a tree of items out flattened, each item after its depth's indent and marker. + try (XWPFDocument document = inACellBeside(new ListBuilder().name("Plan").textStyle(size(9)) + .addItem("Design", child -> child.addItem("Draft")).build())) { + List items = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().stream() + .filter(paragraph -> !paragraph.getText().isBlank()).toList(); + + assertThat(items).as("the premise: both items written").hasSize(2); + for (XWPFParagraph item : items) { + assertThat(spacing(item).getLineRule()).as(item.getText()).hasToString("exact"); + } + } + // With hangingIndent, each item's marker in a column of its own, its depth's. + try (XWPFDocument document = inACellBeside(new ListBuilder().name("Plan").hangingIndent(true).textStyle(size(9)) + .addItem("Design", child -> child.addItem("Draft")).build())) { + List items = document.getTables().get(0).getRow(0).getCell(1).getParagraphs().stream() + .filter(paragraph -> !paragraph.getText().isBlank()).toList(); + + assertThat(items).as("the premise: both items written").hasSize(2); + for (XWPFParagraph item : items) { + assertThat(spacing(item).getLineRule()).as(item.getText()).hasToString("exact"); + } + } + } + + private static DocumentNode dotted() { + return new ListBuilder().name("Steps").hangingIndent(true).markerGap(6).textStyle(size(9)) + .marker(m -> m.dot(4, com.demcha.compose.document.style.DocumentColor.rgb(0, 128, 128))) + .items("Setup", "Support").build(); + } + + @Test + void aHangingListWithABlankItemInACellIsLeftAsWordSetsIt() throws Exception { + // The page draws a blank item's marker as a row of its own, which the export does not write: + // as in the flow, the list's items are not matched to its lines. + try (XWPFDocument document = inACellBeside(new ListBuilder().name("Items").bullet().hangingIndent(true) + .textStyle(size(9)).items("Setup", "Support", " ").build())) { + CTSpacing spacing = spacing(inACell(document, "Setup")); + + assertThat(spacing(inACell(document, "Mon")).getLineRule()).as("the premise: a layout behind the export") + .hasToString("exact"); + assertThat(spacing == null || !spacing.isSetLineRule()).isTrue(); + } + } + + @Test + void aListHoldingAnItemOfRunsInACellIsLeftAsWordSetsIt() throws Exception { + // An item of runs is not read here: no run of the table's lines is known to be the list's. + try (XWPFDocument document = inACellBeside(new ListBuilder().name("Items").noMarker().hangingIndent(true) + .textStyle(size(9)).items("Setup") + .addItem(text -> text.style("Support", size(9))) + .build())) { + CTSpacing spacing = spacing(inACell(document, "Setup")); + + assertThat(spacing(inACell(document, "Mon")).getLineRule()).as("the premise: a layout behind the export") + .hasToString("exact"); + assertThat(spacing == null || !spacing.isSetLineRule()).isTrue(); + } + } + + private static DocumentNode list() { + return new ListBuilder().name("Items").bullet().itemSpacing(3).textStyle(size(9)) + .items("Setup", "Training", "Support").build(); + } + + private static DocumentTextStyle size(double size) { + return DocumentTextStyle.builder().fontName(FontName.LATO).size(size).build(); + } + + /** A table of one row: a text cell, then a cell composed of each node. */ + private static XWPFDocument inACellBeside(DocumentNode... composed) throws Exception { + return DocxExports.withLayout(400, 600, 20, beside(composed)); + } + + private static Consumer beside(DocumentNode... composed) { + return page -> page.addTable(table -> { + DocumentTableColumn[] columns = new DocumentTableColumn[composed.length + 1]; + java.util.Arrays.fill(columns, DocumentTableColumn.fixed(160)); + columns[0] = DocumentTableColumn.fixed(40); + List cells = new java.util.ArrayList<>(); + cells.add(DocumentTableCell.text("Mon")); + for (DocumentNode node : composed) { + cells.add(DocumentTableCell.node(node)); + } + table.columns(columns).rowCells(cells); + }); + } + + private static XWPFParagraph inTheBody(XWPFDocument document, String text) { + return document.getParagraphs().stream() + .filter(paragraph -> paragraph.getText().equals(text)).findFirst().orElseThrow(); + } + + private static XWPFParagraph inACell(XWPFDocument document, String text) { + List found = cellParagraphs(document, text); + assertThat(found).as("a cell holding " + text).isNotEmpty(); + return found.get(0); + } + + /** Every paragraph of a table's cell, at any depth, holding exactly {@code text}, in order. */ + private static List cellParagraphs(XWPFDocument document, String text) { + List found = new java.util.ArrayList<>(); + java.util.ArrayDeque tables = new java.util.ArrayDeque<>(document.getTables()); + while (!tables.isEmpty()) { + for (var row : tables.pop().getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + for (XWPFParagraph paragraph : cell.getParagraphs()) { + if (paragraph.getText().equals(text)) { + found.add(paragraph); + } + } + tables.addAll(cell.getTables()); + } + } + } + return found; + } + + private static CTSpacing spacing(XWPFParagraph paragraph) { + return paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getSpacing(); + } + + private static double line(XWPFParagraph paragraph) { + return DocxTwips.of(spacing(paragraph).getLine()) / 20.0; + } + + private static long before(XWPFParagraph paragraph) { + CTSpacing spacing = spacing(paragraph); + return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore()); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java index 38c4a9ca1..dcb5b5237 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java @@ -150,19 +150,24 @@ void anItemInALayerStackKeepsTheSpaceAboveIt() throws Exception { } @Test - void anItemOfAListInATablesComposedCellIsLeftAsWordSetsIt() throws Exception { - // A list in a table's composed cell lays out no lines of its own: its items are matched to - // none, and are written at no exact height of the page's to seat them in. + void anItemOfAListInATablesComposedCellRisesIntoTheGapAboveItAsAParagraphThereDoes() throws Exception { + // A list in a table's composed cell is seated on the lines its table laid out for it. Word + // keeps the row whole on its page, so the second item rises into the gap above it and owes + // as much below; the first, under no space of its own, is moved by its position. try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addTable(t -> t .columns(DocumentTableColumn.fixed(200)) - .rowCells(DocumentTableCell.node(new ListBuilder().noMarker().textStyle(spectral(30)) - .items("Inside").build()))))) { - XWPFParagraph inside = inACell(document, "Inside"); - CTSpacing spacing = spacing(inside); + .rowCells(DocumentTableCell.node(new ListBuilder().noMarker().textStyle(spectral(30)).itemSpacing(10) + .items("One", "Two").build()))))) { + XWPFParagraph one = inACell(document, "One"); + XWPFParagraph two = inACell(document, "Two"); + double raise = 0.8 * line(two) - SPECTRAL_ASCENT * 30; - assertThat(spacing == null || !spacing.isSetLineRule()).as("the premise: no line the page measured").isTrue(); - assertThat(position(inside)).isZero(); - assertThat(before(inside)).isZero(); + assertThat(line(one)).as("the premise: the page's line").isCloseTo(SPECTRAL_LINE * 30, within(0.05)); + assertThat(raise).as("the premise: Word's baseline is the lower, by less than the gap").isBetween(1.0, 10.0); + assertThat(before(two)).isCloseTo(Math.round((10 - raise) * 20), within(1L)); + assertThat(after(two)).as("what it owes, under the cell's last item").isCloseTo(Math.round(raise * 20), within(1L)); + assertThat(position(two)).isZero(); + assertThat(position(one)).isEqualTo(Math.round(raise * 2)); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java index edf01912f..85c284d27 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java @@ -63,13 +63,20 @@ void aListsLineSpacingIsNamedWhereTheLayoutsItemsAreNotItsOwn() throws Exception .addList(list -> list.name("Highlights").lineSpacing(4).textStyle(DocumentTextStyle.DEFAULT.withSize(10)) .items(TWO_LINES, "Short")))) .isEqualTo("written as a Word list; its lineSpacing is not written between a wrapped item's lines"); - // Composed in a table cell, it has no lines of its own to match or to read, nor a column. + // Composed in a table cell, it is matched to the lines its table laid out for it, and its + // wrapped item is given the gap; it has no placement of its own to measure a column by. assertThat(listNote(page -> page.addTable(table -> table.name("Facts") .columns(DocumentTableColumn.fixed(160)) .rowCells(DocumentTableCell.node(new ListBuilder().name("Highlights").lineSpacing(4) .items(LONG).build()))))) - .isEqualTo("written as a Word list; its lineSpacing is not written between a wrapped item's lines" - + " — whether an item wraps is not measured; 1 of its 1 items" + STATED); + .isEqualTo("written as a Word list; 1 of its 1 items" + STATED); + // One holding an item of runs is matched to none of them. + assertThat(listNote(page -> page.addTable(table -> table.name("Facts") + .columns(DocumentTableColumn.fixed(160)) + .rowCells(DocumentTableCell.node(new ListBuilder().name("Highlights").lineSpacing(4).hangingIndent(true) + .items(LONG).addItem(text -> text.plain("Rich")).build()))))) + .startsWith("written as a paragraph per item; its lineSpacing is not written between a wrapped item's" + + " lines — whether an item wraps is not measured"); // On one page, every item's own lines are given the gap. assertThat(listNotes(page -> page.addList(list -> list.name("Highlights").lineSpacing(4)