diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad16e5c4..44f87b3e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A `hangingIndent(true)` list's top level takes the page's marker column in DOCX, text + markers too.** The export gave a Word list's levels a stated 9pt column and a list of + paragraphs a space after its marker, and wrapped every item's lines back under its marker: + `Panel`'s skills stood 7.8pt left of the page's on their second lines. The top level of a + list the layout set in a marker column now takes that column, the marker's width and + `markerGap`, as its indent and hanging — a Word list's level, or a tab and a hanging indent + on each item of a list of paragraphs, plain items included — so its text and wrapped lines + stand where the page sets them, a picture marker's included. A marker of text counts as + wide as the page sets it grown to Word's half-point size, and the column is used only where + the gap leaves half a point past that: Word sets it in the page's face where the export + embeds it, or in a standard face set in the same widths; a face the export may not embed is + the reader's editor's to substitute, and may set wider. Such a level names the list's face + and size, which Word draws its marker in: an item at an exact line left it the document's, + and a list set smaller than the body would have had a bullet wider than its column. A list + that nests items, a marker made of runs, a marker the gap does not clear, and a list without + the flag keep the stated columns and the space. `Panel`'s and `SerifHeadline`'s six such + lines now stand within 0.2pt across in both editors; lines more than 2pt off across fall + from 136 to 130 in Word, and in LibreOffice from 102 to 96 on Windows and 388 to 382 on + Linux. - **A list item whose marker is a picture starts its text where the page does in DOCX.** The picture — a dot, an icon — was followed by a space, a little over two points where the page leaves the `markerGap` asked for: `TealPulse`'s skills, highlights and certifications, a @@ -15,8 +34,9 @@ follow semantic versioning; release dates are ISO 8601. `OrangeOps`' 6pt in Word. A top-level item of a `hangingIndent(true)` list whose marker is the list's and a picture alone is now followed by a tab to a stop the layout's measure past the item's start — the picture's width and the gap — where the picture, its edges included, - clears that stop, as Word draws a picture at the size it is written; a marker of text, which - Word sets in its own widths, keeps its space. Those lines now stand within 0.1pt across. + clears that stop, as Word draws a picture at the size it is written, and its lines hang + there (the entry above, which also takes a list that nests items out). Those lines now + stand within 0.1pt across. Lines more than 2pt off across fall from 175 to 136 in Word, from 130 to 102 in LibreOffice on Windows and from 427 to 388 on Linux. `ListBuilder.hangingIndent` and `markerGap` say what DOCX now keeps. diff --git a/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java index 939d9efd1..47d1dc805 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java @@ -413,19 +413,20 @@ public ListBuilder continuationIndent(String continuationIndent) { * no marker — contributes no row, so its children hang at the level it would * have occupied rather than one deeper.
* - *Fixed-layout, but for a drawn marker's gap. This is geometry, and - * it applies to the backends that do their own layout — PDF and PPTX. The semantic DOCX - * export writes a Word paragraph per item and lets Word lay it out, so it - * keeps the marker in the item's text: the same paragraphs, the same text, - * the same nesting. Word positions content at absolute indents and has no - * way to be told "start the text one marker width plus a gap from here", and - * sets a text marker in its own widths. Only a top-level item whose marker is - * the list's and a picture alone — a dot, an icon — has its first line's text - * placed where the layout puts it, a tab to the marker's width and the gap past - * the item's start, where the picture with its edges clears that stop: Word - * draws a picture at the size it is written. A document exported - * both ways is otherwise identical, and differs in how its wrapped lines line - * up.
+ *In DOCX, the top level's column. This is geometry, and the + * backends that do their own layout — PDF and PPTX — apply it in full. The + * semantic DOCX export writes a Word paragraph per item and lets Word lay it + * out: the same paragraphs, the same text, the same nesting. Word positions + * content at absolute indents and has no way to be told "start the text one + * marker width plus a gap from here", so the export gives the top level the + * column the layout set its marker in — the marker's width and the gap — as + * the list's indent and hanging, or as a tab and a hanging indent where the + * list is not a Word list. It does so where the gap covers what Word may set + * the marker wider: a picture, its edges included, or text at its size to the + * half point, half a point clear — Word setting it in the page's face where + * the export embeds it, or in a standard face set in the same widths. A list + * that nests items, a marker made of runs, and a marker the gap does not + * clear keep stated columns.
* * @param hangingIndent whether items use marker/content geometry * @return this builder @@ -446,11 +447,9 @@ public ListBuilder hangingIndent(boolean hangingIndent) { *Real geometry, never spaces. A markerless item takes no gap at all, * rather than an unexplained inset.
* - *In the semantic DOCX export, for the reason given on - * {@link #hangingIndent(boolean)}, it places only the first line's text of a - * top-level item whose marker is a picture alone; after a marker of text it is - * a space, rather than an approximation that would render as a different - * number than the one asked for.
+ *In the semantic DOCX export it sets the top level's marker column, as + * {@link #hangingIndent(boolean)} describes, where it covers what Word may set + * the marker wider; it never becomes characters in the text.
* * @param markerGap gap in points; {@code 0} is allowed * @return this builder diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 009c90174..44b971670 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -63,10 +63,10 @@ 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 `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it | -| 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 | ⚠️ ignored, but for a drawn marker: a top-level item of a `hangingIndent(true)` list whose marker is a picture alone (a dot, an icon) has a tab to a stop where the layout starts its text, the picture's width and `markerGap` past the item's start, where the picture with its edges clears that stop, as Word draws a picture at the size it is written; its wrapped lines still start at the item's start. Otherwise `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used | +| 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 list that nests items, a marker the gap does not clear, and a list without the flag keep the stated column (180 twips, plus 120 per nesting level) | | 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 that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) | -| 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, followed by a tab to where the layout starts the item's text when the picture clears that stop, else by a space) | +| 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) | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) | | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | | Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them. A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack, and a `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a DrawingML shape anchored to the page behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, each side border a line along its edge; a gradient paint and a transform are not carried, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in a body paragraph on its page (a table cell's only on a page with no other, which Word may print it clipped to, reported), stays where it is when the text is edited — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 32e4c5507..a0791178c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -368,18 +368,27 @@ changing what was asked for: - **A drawn marker** — one made of runs, an icon or a disc. It has no Word list analogue, so the item keeps the run path it already used. A top-level item of a `hangingIndent(true)` list whose marker is a picture alone is followed by a tab to a stop - where the layout starts its text — the picture's width and `markerGap` past the item's - start, where the picture, its edges included, clears that stop — Word draws a picture at - the size it is written; any other marker by a space. + where the layout starts its text, its lines hanging there — the picture's width and + `markerGap` past the item's start, where the picture, its edges included, clears that stop, + as Word draws a picture at the size it is written; any other drawn marker by a space. - **A list whose siblings at one depth carry different markers.** A Word list definition names one marker per level, and silently replacing one of them with the other would be worse than writing both as text. - **Rich items**, whose runs the numbered path does not write. -The marker column is a stated constant — 180 twips, plus 120 for each nesting level — -chosen near the single space the old text form left. It is a convention, not a -measurement: measuring the marker needs a font runtime this backend does not have, which -is the same reason `markerGap` is unrepresentable here. +The marker column of a list's top level is the page's when the layout set the marker in a +column of its own — a `hangingIndent(true)` list — and the gap after it covers what Word may +set the marker wider: the marker's width and `markerGap`. Word sets a marker of text at its +size to the half point, in the page's face where the export embeds it or in a standard face +set in the same widths, so that is a few hundredths at most; the gap must leave half a point +past it. (A face the export may not embed is the reader's editor's to substitute, and may set +the marker wider.) A Word list's top level then takes that column as its indent and hanging; +a list that is not one — rich items, a drawn marker — writes the marker, a tab to a stop +there, and hangs the item's lines there, so its wrapped lines stand under its text as on the +page. A marker made of runs keeps its space. A list that nests items keeps stated columns +throughout — its nested levels are not measured, and one kept stated could stand left of its +parent's text. Otherwise the column is a stated constant, 180 twips plus 120 for each nesting +level, chosen near the single space the old text form left. ## What a panel keeps and loses @@ -703,27 +712,23 @@ tint it was flattened to. Recorded, like the other two. running past a shape container's foot takes its overhang from the gap below. An outline no shape shows is reported as dropped. -- **`hangingIndent(true)` → the ordinary list form.** A list that opts - into marker/content geometry exports as one that did not — the same Word - list, the same levels, the same markers, the same items, text and - nesting — but for one thing: after a top-level marker that is the list's and - a picture alone (see "What a list becomes"), the first line's text stands - where the layout puts it. Otherwise the marker column is the level's own and - `markerGap` has no effect here. - - That one exception rests on what the rest lacks: a picture is drawn at the - size it is written, and the layout has placed its text, so a tab stop there - is the page's distance. For a marker of text it is not: Word places content at - absolute indents and has no way to be told "start the text one marker - width plus a gap from here", so every mechanism that looks like it - would — a hanging indent, a hanging indent with a tab stop, real Word - numbering — leaves a distance beside the marker equal to the column - minus the marker's own width, a number only Word knows. Honouring the - gap would mean knowing the marker's width as the reader's editor sets - it, in whatever font it substitutes — which the export cannot know. The - approximations were built and rendered through Word before being - rejected — a reserved column renders a gap that is not the one - configured, and a marker wider than the column misaligns outright. +- **`hangingIndent(true)` → the ordinary list form, in the page's column.** A + list that opts into marker/content geometry exports as one that did not — the + same Word list, the same levels, the same markers, the same items, text and + nesting — but its top level's marker column is the page's, where the gap + covers what Word may set the marker wider (see "What a list becomes"): its + text and its wrapped lines stand where the layout puts them. A list that nests + items, and a marker the gap does not clear, keep the stated columns. + + Word places content at absolute indents and has no way to be told "start the + text one marker width plus a gap from here", so the distance beside a marker + is the column less the marker's width as Word sets it. Without the layout's + measure that was a number only Word knew, and the approximations built and + rendered through Word were rejected — a reserved column renders a gap that is + not the one configured, and a marker wider than the column misaligns outright. + The layout's column, the page's face (embedded, or a standard one set in the + same widths), and the gap kept clear of the marker's half-point growth are + what make it the page's distance. ## What is skipped diff --git a/docs/recipes/lists.md b/docs/recipes/lists.md index 0967edf62..4a5e72c6c 100644 --- a/docs/recipes/lists.md +++ b/docs/recipes/lists.md @@ -94,15 +94,15 @@ inside the content column and leave the marker where it is. It is off by default and stays off; existing lists are untouched. -> **Fixed-layout, but for a drawn marker's gap.** PDF and PPTX honour this. -> A DOCX list is a real Word list either way — same levels, same markers — -> but the marker column is the level's own rather than the gap configured -> here. The semantic DOCX export writes one Word paragraph per item and lets -> Word lay it out, so it keeps the marker in the text — same paragraphs, same -> text, same nesting. One exception: a top-level item whose marker is the -> list's and a picture alone (a dot, an icon) has its first line's text tabbed -> to where the layout puts it, the picture's width and the gap past the -> item's start. See [DOCX export](docx-export.md). +> **In DOCX, the top level's column.** PDF and PPTX honour this in full. A +> DOCX list exports the same either way — the same Word list, or the same +> paragraphs for rich items and drawn markers, with the same text and nesting. +> Its top level takes the column the layout set +> the marker in, the marker's width and the gap — its text and wrapped lines +> stand where the page puts them — where the gap covers what Word may set the +> marker wider, by half a point past it — the default 4pt gap does after a +> bullet. A list that nests items, and a marker the gap does not clear, keep a stated +> column. See [DOCX export](docx-export.md). ## Nested lists 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 71583eeb9..99369ed43 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -1103,13 +1103,13 @@ cv-panel 0:3ff1933dba9a -0.19 7.95 typed,snapshot-testedlibrariesth cv-panel 0:32c1cf49a2fe 0.49 0.15 skills cv-panel 0:eea537278f83 0.27 0.15 •languages:java21,kotlin,groovy, cv-panel 0:7e941e729572 0.09 0.15 •document&print:pdfbox,apachepoi -cv-panel 0:9e3166ce2413 0.11 -7.65 itext,postscript,icccolourprofil +cv-panel 0:9e3166ce2413 0.11 0.15 itext,postscript,icccolourprofil cv-panel 0:ebc70ed9b71b 0.13 0.15 •layoutengines:customdsldesign,s -cv-panel 0:dd4f24d85605 0.15 -7.65 pagination,snapshottesting,visua +cv-panel 0:dd4f24d85605 0.15 0.15 pagination,snapshottesting,visua cv-panel 0:f5f76d479ec8 0.17 0.15 •build&infrastructure:maven,grad -cv-panel 0:656a2a3e57e0 0.19 -7.65 jitpack,docker,jmhbenchmarking +cv-panel 0:656a2a3e57e0 0.19 0.15 jitpack,docker,jmhbenchmarking cv-panel 0:9a6921f79b7d 0.25 0.15 •distribution:mavencentral,sonat -cv-panel 0:77a74982066f 0.27 -7.65 jitpack,semanticversioningdiscip +cv-panel 0:77a74982066f 0.27 0.15 jitpack,semanticversioningdiscip cv-panel 0:68bf0109c6f8 0.50 0.15 education cv-panel 0:871923f4f0f2 0.28 0.15 msccomputerscience-2019-2021 cv-panel 0:47ce0218bbea 0.30 0.15 universityofmanchester @@ -1245,10 +1245,10 @@ cv-serif_headline 0:b37c6a2d5d11 0.04 0.10 seniorsoftwareengineer cv-serif_headline 0:e7227bbe0c40 0.04 0.76 jan2022–present cv-serif_headline 0:f4e5af202d87 0.10 0.10 techsolutionsinc.|newyork,ny cv-serif_headline 0:e6f39d8143bc -0.15 0.10 •designedanddevelopedmicroservic -cv-serif_headline 0:f41ff924454c -0.15 2.35 over1mactiveusers. +cv-serif_headline 0:f41ff924454c -0.15 0.10 over1mactiveusers. cv-serif_headline 0:4b16c2d8cb52 0.10 0.10 •implementedrestfulapisandintegr cv-serif_headline 0:5fd82b15227c -0.14 0.10 •optimizeddatabasequeriesandappl -cv-serif_headline 0:38a7ed6f25a8 -0.15 2.35 35%. +cv-serif_headline 0:38a7ed6f25a8 -0.15 0.10 35%. cv-serif_headline 0:df1ebc2b61e1 0.11 0.10 •ledateamof4engineersandmentored cv-serif_headline 0:ee96c91a5683 0.20 0.80 jun2019–dec2021 cv-serif_headline 0:01bb3d9e4f47 0.26 0.10 innovatechltd.|newyork,ny 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 05f39ac88..1daa380ee 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -1009,13 +1009,13 @@ cv-panel 0:3ff1933dba9a -0.19 -0.10 typed,snapshot-testedlibrariesth cv-panel 0:32c1cf49a2fe 0.49 0.15 skills cv-panel 0:eea537278f83 0.27 0.15 •languages:java21,kotlin,groovy, cv-panel 0:7e941e729572 0.09 0.15 •document&print:pdfbox,apachepoi -cv-panel 0:9e3166ce2413 0.11 -7.65 itext,postscript,icccolourprofil +cv-panel 0:9e3166ce2413 0.11 0.15 itext,postscript,icccolourprofil cv-panel 0:ebc70ed9b71b 0.13 0.15 •layoutengines:customdsldesign,s -cv-panel 0:dd4f24d85605 0.15 -7.65 pagination,snapshottesting,visua +cv-panel 0:dd4f24d85605 0.15 0.15 pagination,snapshottesting,visua cv-panel 0:f5f76d479ec8 0.17 0.15 •build&infrastructure:maven,grad -cv-panel 0:656a2a3e57e0 0.19 -7.65 jitpack,docker,jmhbenchmarking +cv-panel 0:656a2a3e57e0 0.19 0.15 jitpack,docker,jmhbenchmarking cv-panel 0:9a6921f79b7d 0.25 0.15 •distribution:mavencentral,sonat -cv-panel 0:77a74982066f 0.27 -7.65 jitpack,semanticversioningdiscip +cv-panel 0:77a74982066f 0.27 0.15 jitpack,semanticversioningdiscip cv-panel 0:68bf0109c6f8 0.50 0.15 education cv-panel 0:871923f4f0f2 0.28 0.15 msccomputerscience-2019-2021 cv-panel 0:47ce0218bbea 0.30 0.15 universityofmanchester @@ -1151,10 +1151,10 @@ cv-serif_headline 0:b37c6a2d5d11 0.04 0.10 seniorsoftwareengineer cv-serif_headline 0:e7227bbe0c40 0.04 0.76 jan2022–present cv-serif_headline 0:f4e5af202d87 0.10 0.10 techsolutionsinc.|newyork,ny cv-serif_headline 0:e6f39d8143bc -0.15 0.10 •designedanddevelopedmicroservic -cv-serif_headline 0:f41ff924454c -0.15 2.35 over1mactiveusers. +cv-serif_headline 0:f41ff924454c -0.15 0.10 over1mactiveusers. cv-serif_headline 0:4b16c2d8cb52 0.10 0.10 •implementedrestfulapisandintegr cv-serif_headline 0:5fd82b15227c -0.14 0.10 •optimizeddatabasequeriesandappl -cv-serif_headline 0:38a7ed6f25a8 -0.15 2.35 35%. +cv-serif_headline 0:38a7ed6f25a8 -0.15 0.10 35%. cv-serif_headline 0:df1ebc2b61e1 0.11 0.10 •ledateamof4engineersandmentored cv-serif_headline 0:ee96c91a5683 0.20 0.85 jun2019–dec2021 cv-serif_headline 0:01bb3d9e4f47 0.26 0.10 innovatechltd.|newyork,ny 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 5e9cde8de..493c4c711 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -1103,13 +1103,13 @@ cv-panel 0:3ff1933dba9a -0.26 -0.20 typed,snapshot-testedlibrariesth cv-panel 0:32c1cf49a2fe 0.82 0.04 skills cv-panel 0:eea537278f83 0.30 0.04 •languages:java21,kotlin,groovy, cv-panel 0:7e941e729572 0.17 0.04 •document&print:pdfbox,apachepoi -cv-panel 0:9e3166ce2413 0.17 -7.76 itext,postscript,icccolourprofil +cv-panel 0:9e3166ce2413 0.17 0.04 itext,postscript,icccolourprofil cv-panel 0:ebc70ed9b71b 0.13 0.04 •layoutengines:customdsldesign,s -cv-panel 0:dd4f24d85605 0.13 -7.76 pagination,snapshottesting,visua +cv-panel 0:dd4f24d85605 0.13 0.04 pagination,snapshottesting,visua cv-panel 0:f5f76d479ec8 0.21 0.04 •build&infrastructure:maven,grad -cv-panel 0:656a2a3e57e0 0.21 -7.76 jitpack,docker,jmhbenchmarking +cv-panel 0:656a2a3e57e0 0.21 0.04 jitpack,docker,jmhbenchmarking cv-panel 0:9a6921f79b7d 0.25 0.04 •distribution:mavencentral,sonat -cv-panel 0:77a74982066f 0.25 -7.76 jitpack,semanticversioningdiscip +cv-panel 0:77a74982066f 0.25 0.04 jitpack,semanticversioningdiscip cv-panel 0:68bf0109c6f8 0.85 0.04 education cv-panel 0:871923f4f0f2 0.39 0.04 msccomputerscience-2019-2021 cv-panel 0:47ce0218bbea 0.43 0.04 universityofmanchester @@ -1245,10 +1245,10 @@ cv-serif_headline 0:b37c6a2d5d11 0.07 0.00 seniorsoftwareengineer cv-serif_headline 0:e7227bbe0c40 0.14 0.64 jan2022–present cv-serif_headline 0:f4e5af202d87 0.12 0.00 techsolutionsinc.|newyork,ny cv-serif_headline 0:e6f39d8143bc -0.07 0.00 •designedanddevelopedmicroservic -cv-serif_headline 0:f41ff924454c -0.05 2.25 over1mactiveusers. +cv-serif_headline 0:f41ff924454c -0.05 -0.03 over1mactiveusers. cv-serif_headline 0:4b16c2d8cb52 0.19 0.00 •implementedrestfulapisandintegr cv-serif_headline 0:5fd82b15227c -0.07 0.00 •optimizeddatabasequeriesandappl -cv-serif_headline 0:38a7ed6f25a8 -0.06 2.25 35%. +cv-serif_headline 0:38a7ed6f25a8 -0.06 -0.03 35%. cv-serif_headline 0:df1ebc2b61e1 0.16 0.00 •ledateamof4engineersandmentored cv-serif_headline 0:ee96c91a5683 0.40 0.70 jun2019–dec2021 cv-serif_headline 0:01bb3d9e4f47 0.39 0.00 innovatechltd.|newyork,ny 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 7e6b80e6b..be900926f 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 @@ -700,15 +700,55 @@ java.util.OptionalThis does not fix {@code markerGap}, and does not claim to. Word places content - * at an absolute indent and cannot be told "one marker width plus a gap from here"; - * real numbering was measured against that requirement and rejected for it, and it is - * still rejected. What it buys is behaviour, and it costs geometry: the marker column - * is a stated constant rather than the measured gap.
+ *Word places content at an absolute indent and cannot be told "one marker width plus a + * gap from here". The top level's column is the layout's where it set the marker in one + * and the gap covers what Word may set the marker wider ({@link #measuredColumn}); every + * other level's, and every level of a list without that measure, is a stated constant.
* * @return the list definition to attach, or {@code null} when the list has to stay * marker-prefixed text @@ -2696,6 +2697,7 @@ private BigInteger numberingFor(XWPFDocument document, if (levels == null) { return null; } + long column = measuredColumn(list, levels.get(0)); CTAbstractNum abstractNum = CTAbstractNum.Factory.newInstance(); abstractNum.setAbstractNumId(BigInteger.valueOf(listNumbering.size())); for (int depth = 0; depth < levels.size(); depth++) { @@ -2709,17 +2711,46 @@ private BigInteger numberingFor(XWPFDocument document, level.addNewLvlText().setVal(levels.get(depth)); level.addNewLvlJc().setVal(STJc.LEFT); CTInd indent = level.addNewPPr().addNewInd(); - indent.setLeft(BigInteger.valueOf( - (long) LIST_HANGING_TWIPS + (long) LIST_NESTING_STEP_TWIPS * depth)); - indent.setHanging(BigInteger.valueOf(LIST_HANGING_TWIPS)); + boolean measured = depth == 0 && column > 0; + indent.setLeft(BigInteger.valueOf(measured + ? column : (long) LIST_HANGING_TWIPS + (long) LIST_NESTING_STEP_TWIPS * depth)); + indent.setHanging(BigInteger.valueOf(measured ? column : LIST_HANGING_TWIPS)); + if (measured && list.textStyle() != null) { + // Word draws the marker in the paragraph mark's style, and an item at an exact + // line leaves its mark the document's: a list set smaller, or in another face, + // would get a marker wider than the one the column was measured against. + applyDefaultRunProperties(level.addNewRPr(), list.textStyle()); + } } BigInteger abstractId = document.createNumbering() .addAbstractNum(new org.apache.poi.xwpf.usermodel.XWPFAbstractNum(abstractNum)); BigInteger numId = document.getNumbering().addNum(abstractId); listNumbering.put(list, numId); + if (column > 0) { + measuredColumns.put(numId, column); + } return numId; } + /** + * The marker column a list's top level takes as the page sets it, in twips, or 0 where it + * keeps the stated one: the marker's width and markerGap, where the layout set the marker + * in a column of its own and the gap covers what Word may set the marker wider, and the + * list does not nest ({@link #nests}). The stated column is 9pt, whatever the marker and the + * gap the page leaves after it. + */ + private long measuredColumn(com.demcha.compose.document.node.ListNode list, String letters) { + if (nests(list)) { + return 0; + } + java.util.OptionalDouble toText = layout.markerToText(list, letters); + if (toText.isEmpty() + || !(toText.getAsDouble() > wordsMarkerWidth(list, letters, list.textStyle()) + MARKER_TEXT_CLEARANCE)) { + return 0; + } + return Math.round(toText.getAsDouble() * POINT_TO_TWIP); + } + /** * The one marker each nesting depth uses, or {@code null} when the list cannot be a * Word list. @@ -2905,10 +2936,16 @@ private void writeListItems(XWPFDocument document, // any row with runs in it is; its item is still just a label. writeRichListLine(document, list.textStyle(), list.marker(), com.demcha.compose.document.node.ListItem.of(normalized), 0, lineHeight, layout.firstLine(list), - layout.pathOf(list), layout.markerToText(list)); + layout.pathOf(list), list); } else if (numId != null) { // Word draws the marker, so the text is the item and nothing else. writeListLine(document, list.textStyle(), normalized, 0, numId, lineHeight); + } else if (setInAColumn(list, list.marker())) { + // A list that is not a Word list, its marker in a column the layout set: the item + // is written as a rich one is, its text tabbed to that column. + writeRichListLine(document, list.textStyle(), list.marker(), + com.demcha.compose.document.node.ListItem.of(normalized), 0, lineHeight, layout.firstLine(list), + layout.pathOf(list), list); } else { writeListLine(document, list.textStyle(), list.marker().prefix() + normalized, 0, null, lineHeight); @@ -2937,12 +2974,13 @@ private void writeNestedItem(XWPFDocument document, // A nested item stands after its depth's indent, and an item may carry a marker of // its own: the list's measure of its first item's marker is only a top-level item's // that carries the list's. - java.util.OptionalDouble markerToText = depth == 0 && marker.equals(list.marker()) - ? layout.markerToText(list) : java.util.OptionalDouble.empty(); writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight, layout.firstLine(list), - layout.pathOf(list), markerToText); + layout.pathOf(list), depth == 0 && marker.equals(list.marker()) ? list : null); } else if (numId != null) { writeListLine(document, list.textStyle(), item.label(), depth, numId, lineHeight); + } else if (depth == 0 && marker.equals(list.marker()) && setInAColumn(list, marker)) { + writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight, layout.firstLine(list), + layout.pathOf(list), list); } else { writeListLine(document, list.textStyle(), marker.prefix() + item.label(), depth, null, lineHeight); @@ -2969,7 +3007,7 @@ private void writeListLine(XWPFDocument document, DocumentTextStyle style, if (numId != null) { para.setNumID(numId); para.setNumILvl(BigInteger.valueOf(depth)); - indentListItemInside(para, depth); + indentListItemInside(para, depth, numId); } XWPFRun run = para.createRun(); applyStyle(run, style); @@ -2984,18 +3022,21 @@ private void writeListLine(XWPFDocument document, DocumentTextStyle style, * inset written on it would drop the level's hanging indent and the marker would run into * the text. The level's indent is added back on top of the inset. */ - private void indentListItemInside(XWPFParagraph para, int depth) { + private void indentListItemInside(XWPFParagraph para, int depth, BigInteger numId) { CTPPr properties = para.getCTP().getPPr(); if (properties == null) { return; } if (properties.isSetInd()) { - long levelLeft = (long) LIST_HANGING_TWIPS + (long) LIST_NESTING_STEP_TWIPS * depth; + Long measured = depth == 0 ? measuredColumns.get(numId) : null; + long hanging = measured != null ? measured : LIST_HANGING_TWIPS; + long levelLeft = measured != null ? measured + : (long) LIST_HANGING_TWIPS + (long) LIST_NESTING_STEP_TWIPS * depth; CTInd indent = properties.getInd(); // As applyInset writes it: a list in a container hanging left hangs with it, and in a // cell of a row hanging left it moves left by the hang, as a paragraph beside it does. indent.setLeft(BigInteger.valueOf(leftIndentTwips(insetLeft + cellTextShift) + levelLeft)); - indent.setHanging(BigInteger.valueOf(LIST_HANGING_TWIPS)); + indent.setHanging(BigInteger.valueOf(hanging)); } } @@ -3005,24 +3046,24 @@ private void indentListItemInside(XWPFParagraph para, int depth) { * paragraph. * *This is the part of the opt-in marker/content list the semantic export - * can reproduce, and it reproduces it exactly. The geometry — the measured - * marker column, the shared content origin — is not written, for the reason - * {@code hangingIndent} documents; the gap after a marker that is a picture - * alone is, as a tab to where the layout starts the text. Which piece of an item is bold - * is not geometry: Word carries a style per run inside a paragraph, so - * writing the item's plain reading in one face would be dropping something - * Word can hold.
+ * can reproduce, and it reproduces it exactly. A top-level item of a list that + * does not nest, whose marker the layout set in a column of its own, writes its + * marker, a tab, and hangs its lines at that column, where the gap covers what + * Word may set the marker wider ({@link #setInAColumn}, {@link #drawnWidth}). + * Which piece of an item is bold is not geometry: Word carries a style per run + * inside a paragraph, so writing the item's plain reading in one face would be + * dropping something Word can hold. * *The same is true of a marker. A marker written as text keeps the colour * and face it was given, because those are run properties Word has. A marker * that draws a disc or an icon is written as the picture it draws, the way a * shape or an icon in a line is, rather than as a glyph the author did not ask - * for — and is followed by that tab, or, where the layout measured no gap or the + * for — and is followed by that tab, or, where the layout measured no column or the * picture reaches the stop, by the same space as a text marker.
* - * @param markerToText how far right of its marker the layout starts the item's text, or - * empty when that is not this item's — a nested item, or one with a - * marker of its own + * @param measured the list the item's marker column is measured from, or {@code null} + * when it is not this item's — a nested item, or one with a marker of + * its own */ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style, com.demcha.compose.document.node.ListMarker marker, @@ -3030,7 +3071,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style, int depth, java.util.OptionalDouble lineHeight, java.util.OptionalThat is a decision rather than an omission, and it was made against measurements. - * Word places content at absolute indents and has no way to be told "start the text one - * marker width plus a gap from here"; every mechanism that looks like it would (a hanging - * indent, a hanging indent with a tab stop, real Word numbering) positions content - * absolutely, so the distance left beside the marker is always the column minus the - * marker's own width — a number only Word knows. Reproducing the geometry would mean - * measuring the marker, and the semantic backend has no font runtime to measure with, by - * design: its only dependencies are the core model and POI.
- * - *The approximations were built and rendered through Word before being rejected. A - * reserved-column approximation renders a gap that is not the configured one — a 0pt gap - * came out as 5.72pt, a 4pt gap as 9.68pt — and a marker wider than the column misaligns - * outright. Shipping that would mean {@code markerGap(8)} rendering as something other - * than 8.
- * - *A list is real Word numbering now, which the old wording of this test read - * as ruling out. It does not: numbering was rejected as a way to honour {@code markerGap}, - * and it still does not honour it. What it buys is behaviour — Enter continues the list — - * at the price of a marker column that is a stated constant rather than the configured - * gap. So the decision this test pins is unchanged and its subject is narrower than it - * was: not "no numbering", but "the flag and the gap change nothing about the output" of a - * list of text markers.
+ *Word places content at absolute indents and has no way to be told "start the text one + * marker width plus a gap from here", so the distance it leaves beside a marker is the + * column less the marker's own width as Word sets it. Without the layout's measure that was + * a number only Word knew: a reserved column rendered a gap other than the one configured — + * a 0pt gap came out as 5.72pt, a 4pt gap as 9.68pt — and a marker wider than the column + * misaligned outright, so the export kept a stated column. The layout now sets the marker + * in a column of its own, and where the export embeds the page's face or names a standard + * one set in the same widths, Word sets the marker in the same widths, a few hundredths + * wider at most for its size to the half point. Where the gap covers that, the top level's + * column is the page's ({@code DocxListMarkerGapTest}); + * where it does not, or the list is not laid out so, the stated column stays, and the gap + * never becomes characters in the text.
* * @author Artem Demchyshyn */ -class DocxHangingIndentIsIgnoredTest { +class DocxListHangingIndentTest { @Test void aFlatListExportsIdenticallyWithAndWithoutHangingIndent() throws Exception { @@ -73,21 +62,21 @@ void aMultiCharacterMarkerExportsIdenticallyAndBecomesTheLevelText() throws Exce } @Test - void theGapIsNotRepresentedAtAllSoEveryValueExportsTheSame() throws Exception { - // If the gap ever leaked into DOCX — as spaces, as indentation, or as the - // level's own indent — these would stop agreeing, which is the failure this - // test exists to catch. - List