From 99e7a97253a98fe6972cf8627535ed3bdad76a88 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 4 Oct 2026 01:12:53 +0100
Subject: [PATCH 1/2] fix(docx): give a hanging list's top level the page's
marker column, text markers too
---
CHANGELOG.md | 16 ++
.../compose/document/dsl/ListBuilder.java | 34 ++--
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 59 +++---
docs/recipes/lists.md | 17 +-
.../libreoffice-windows-lines.tsv | 12 +-
.../docx-fidelity/word-windows-lines.tsv | 12 +-
.../semantic/docx/DocxLayoutMetrics.java | 46 ++++-
.../semantic/docx/DocxSemanticBackend.java | 187 +++++++++++++-----
...st.java => DocxListHangingIndentTest.java} | 92 ++++-----
.../semantic/docx/DocxListMarkerGapTest.java | 92 ++++++++-
.../docx/DocxSemanticBackendTest.java | 32 +--
12 files changed, 421 insertions(+), 180 deletions(-)
rename render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/{DocxHangingIndentIsIgnoredTest.java => DocxListHangingIndentTest.java} (74%)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ad16e5c4..10bf4a0c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,22 @@ 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, in the page's face — embedded, or
+ a standard face Word sets in the same widths — and the column is used only where the gap
+ leaves half a point past that; a list that nests items, a marker the gap does not clear,
+ and a list without the flag keep the stated columns. The test that
+ held the flag to changing nothing, `DocxHangingIndentIsIgnoredTest`, is now
+ `DocxListHangingIndentTest` and holds it to the column. `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 from 102 to 96 in LibreOffice.
- **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
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..a14f57529 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,19 @@ 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 in the page's
+ * face — embedded, or a standard one Word sets in the same widths — at its
+ * size to the half point, half a point clear. A list that nests items, and a
+ * marker the gap does not clear, keep stated columns.
*
* @param hangingIndent whether items use marker/content geometry
* @return this builder
@@ -446,11 +446,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..e62161aef 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -63,7 +63,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `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) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 32e4c5507..f4a60abd8 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -368,18 +368,26 @@ 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 the marker in the page's
+face — embedded, or a standard one Word sets in the same widths — at its size to the half point, so that is a few hundredths at most; the gap
+must leave half a point past it. 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 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 +711,22 @@ 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..4c9d78698 100644
--- a/docs/recipes/lists.md
+++ b/docs/recipes/lists.md
@@ -94,15 +94,14 @@ 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 is a real Word list either way — same levels, same markers, same
+> paragraphs, 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-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.Optional firstLine(DocumentNode node) {
* @return the distance in points, or empty when the list laid out no marker beside its text
*/
OptionalDouble markerToText(DocumentNode list) {
+ return markerToText(list, null);
+ }
+
+ /**
+ * How far right of its marker a list's first item's text starts, the marker drawn or, when
+ * {@code markerText} is given, a marker of that text laid out as a fragment of its own.
+ *
+ * @param list the list
+ * @param markerText the marker's letters, or {@code null} for a drawn marker
+ * @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);
+ int index = markerFragment(own, markerText);
+ return index < 0 ? OptionalDouble.empty() : OptionalDouble.of(own.get(index + 1).x() - own.get(index).x());
+ }
+
+ /**
+ * How wide a list's first item's marker is as the page sets it, the marker drawn or of the
+ * text given (see {@link #markerToText(DocumentNode, String)}).
+ */
+ OptionalDouble markerWidth(DocumentNode list, String markerText) {
List own = fragmentsOf(list);
+ int index = markerFragment(own, markerText);
+ if (index < 0 || !(own.get(index).payload() instanceof ParagraphFragmentPayload marker)) {
+ return OptionalDouble.empty();
+ }
+ return OptionalDouble.of(marker.lines().stream().mapToDouble(ParagraphLine::width).max().orElse(0));
+ }
+
+ /** Where among a list's fragments its first marker beside its text is, or -1. */
+ private static int markerFragment(List own, String markerText) {
for (int index = 0; index + 1 < own.size(); index++) {
if (own.get(index).payload() instanceof ParagraphFragmentPayload marker
&& own.get(index + 1).payload() instanceof ParagraphFragmentPayload text
- && drawsOnly(marker) && !drawsOnly(text)) {
- return OptionalDouble.of(own.get(index + 1).x() - own.get(index).x());
+ && isMarker(marker, markerText) && !isMarker(text, markerText) && !drawsOnly(text)) {
+ return index;
}
}
- return OptionalDouble.empty();
+ return -1;
+ }
+
+ private static boolean isMarker(ParagraphFragmentPayload paragraph, String markerText) {
+ if (markerText == null) {
+ return drawsOnly(paragraph);
+ }
+ String letters = markerText.strip();
+ return !letters.isEmpty() && paragraph.lines().size() == 1
+ && paragraph.lines().get(0).text().strip().equals(letters);
}
/** Whether a paragraph fragment holds nothing but drawings — a list's drawn marker. */
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 70b46271c..b59e5200c 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
@@ -332,6 +332,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
// two lists reading the same are still two lists.
private final java.util.Map
listNumbering = new java.util.IdentityHashMap<>();
+ // The marker column, in twips, of the list definitions whose top level takes the page's.
+ private final java.util.Map measuredColumns = new java.util.HashMap<>();
// What the engine already measured for the nodes being written: line heights and
// resolved column widths. Empty when the export was handed no layout.
private DocxLayoutMetrics layout = DocxLayoutMetrics.EMPTY;
@@ -700,6 +702,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
tablesCells.clear();
picturesDrawnBeside.clear();
listNumbering.clear();
+ measuredColumns.clear();
report = new DocxExportReport.Builder();
bookmarkNames = new DocxBookmarkNames();
headingLevels = headingLevelsIn(whole);
@@ -2649,10 +2652,9 @@ private void warnDroppedInlineRuns(List runs, String path) {
}
/**
- * Word''s marker column, in twips. Chosen to sit close to the single space the text
- * path used rather than to Word''s much wider default, and stated as the convention it
- * is: measuring the marker would need a font runtime, which is the same thing
- * {@code hangingIndent} is missing and the reason its gap is unrepresentable here.
+ * Word''s marker column, in twips, where the layout gives none ({@link #measuredColumn}).
+ * Chosen to sit close to the single space the text path used rather than to Word''s much
+ * wider default, and stated as the convention it is.
*/
private static final int LIST_HANGING_TWIPS = 180;
@@ -2677,11 +2679,10 @@ private void warnDroppedInlineRuns(List runs, String path) {
* failure this repairs. Attaching {@code w:numPr} makes Word own the marker, so the
* list continues, renumbers and demotes the way a reader expects.
*
- * This 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,40 @@ 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));
}
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 +2930,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 +2968,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 +3001,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 +3016,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 +3040,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 +3065,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
int depth,
java.util.OptionalDouble lineHeight,
java.util.Optional line,
- String path, java.util.OptionalDouble markerToText) {
+ String path, com.demcha.compose.document.node.ListNode measured) {
warnDroppedInlineRuns(marker.runs(), path);
warnDroppedInlineRuns(item.runs(), path);
line = line.map(listLine -> itemLine(listLine, item.runs()));
@@ -3040,7 +3075,22 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
applyItemLineGap(para);
XWPFRun leading = para.createRun();
applyStyle(leading, style);
- leading.setText(" ".repeat(depth) + (marker.isRich() ? "" : marker.prefix()));
+ // A marker of text the layout set in a column of its own is followed by a tab to where
+ // it starts the text, and the item's lines hang there, as the page sets them — where the
+ // gap after the marker covers what Word may set it wider: its size to the half point,
+ // the face the page's, embedded or one Word sets in the same widths. Panel's skills,
+ // wrapped back under their bullets, stood 7.8pt left of the page's on their second line.
+ boolean textTab = measured != null && depth == 0 && setInAColumn(measured, marker);
+ String letters = marker.isRich() ? "" : marker.prefix().stripTrailing();
+ leading.setText(" ".repeat(depth) + (marker.isRich() ? "" : textTab ? letters : marker.prefix()));
+ if (textTab) {
+ XWPFRun gap = para.createRun();
+ applyStyle(gap, style);
+ gap.addTab();
+ hangFrom(para, layout.markerToText(measured, letters).getAsDouble());
+ }
+ java.util.OptionalDouble markerToText = measured != null && depth == 0 && !nests(measured)
+ ? layout.markerToText(measured) : java.util.OptionalDouble.empty();
PictureReach pictures = PictureReach.NONE;
if (marker.isRich()) {
pictures = writeInlineTextRuns(para, style, marker.runs(), path, line);
@@ -3064,7 +3114,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
// space is 2.5. Not where the picture, its edges included, reaches the stop: the tab
// would run on to Word's next default stop, half an inch on.
gap.addTab();
- tabTo(para, markerToText.getAsDouble());
+ hangFrom(para, markerToText.getAsDouble());
} else {
gap.setText(" ");
}
@@ -3100,25 +3150,72 @@ private static double drawnWidth(XWPFParagraph para) {
}
/**
- * Sets a left tab stop a distance right of where a paragraph's first line starts: past its
- * left indent and its first-line indent, as Word measures a tab stop from the column's edge.
+ * Whether a list's marker of text stands in a column the layout set, which its item's text
+ * is tabbed to (see {@link #writeRichListLine}).
+ */
+ private boolean setInAColumn(com.demcha.compose.document.node.ListNode list,
+ com.demcha.compose.document.node.ListMarker marker) {
+ if (marker.isRich() || nests(list)) {
+ return false;
+ }
+ String letters = marker.prefix().stripTrailing();
+ java.util.OptionalDouble toText = letters.isEmpty()
+ ? java.util.OptionalDouble.empty() : layout.markerToText(list, letters);
+ return toText.isPresent()
+ && toText.getAsDouble() > wordsMarkerWidth(list, letters, list.textStyle()) + MARKER_TEXT_CLEARANCE;
+ }
+
+ /**
+ * Whether a list nests items under its top level. Only its top level's column is measured,
+ * from its first marker: a nested level kept at its stated column could then stand left of
+ * its parent's text, at a markerGap past ten points, so a nesting list keeps the stated
+ * columns throughout.
+ */
+ private static boolean nests(com.demcha.compose.document.node.ListNode list) {
+ return list.nestedItems().stream().anyMatch(item -> !item.children().isEmpty());
+ }
+
+ /** How much more than a marker of text's width, as Word may set it, its gap must leave, in points. */
+ private static final double MARKER_TEXT_CLEARANCE = 0.5;
+
+ /**
+ * How wide Word may set a list's marker of text: the page's width grown to the size Word
+ * sets it in, to the half point; the face is the page's, embedded or a standard one Word
+ * sets in the same widths.
+ */
+ private double wordsMarkerWidth(com.demcha.compose.document.node.ListNode list, String letters,
+ DocumentTextStyle style) {
+ double width = layout.markerWidth(list, letters).orElse(Double.POSITIVE_INFINITY);
+ double size = style != null && style.size() > 0 ? style.size() : Double.NaN;
+ return Double.isFinite(size) ? width * Math.max(1, wordsSize(size) / size) : width;
+ }
+
+ /**
+ * Hangs a list item's lines a distance right of where its first line starts, past its left
+ * indent and its first-line indent, with a left tab stop there for the tab after its marker:
+ * its first line starts where it did, its marker there, and its text and every line after
+ * it at the stop, as the page sets an item in a marker column. Word measures a tab stop from
+ * the column's edge, as it does an indent.
*
* @param points how far right of the first line's start, in points
*/
- private static void tabTo(XWPFParagraph para, double points) {
+ private static void hangFrom(XWPFParagraph para, double points) {
CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
- long start = 0;
- if (properties.isSetInd()) {
- CTInd indent = properties.getInd();
- start = twipsOf(indent.isSetLeft() ? indent.getLeft() : null)
- + twipsOf(indent.isSetFirstLine() ? indent.getFirstLine() : null)
- - twipsOf(indent.isSetHanging() ? indent.getHanging() : null);
- }
+ CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
+ long start = twipsOf(indent.isSetLeft() ? indent.getLeft() : null)
+ + twipsOf(indent.isSetFirstLine() ? indent.getFirstLine() : null)
+ - twipsOf(indent.isSetHanging() ? indent.getHanging() : null);
+ long hang = Math.round(points * POINT_TO_TWIP);
+ if (indent.isSetFirstLine()) {
+ indent.unsetFirstLine();
+ }
+ indent.setLeft(BigInteger.valueOf(start + hang));
+ indent.setHanging(BigInteger.valueOf(hang));
org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTabs tabs =
properties.isSetTabs() ? properties.getTabs() : properties.addNewTabs();
CTTabStop stop = tabs.addNewTab();
stop.setVal(STTabJc.LEFT);
- stop.setPos(BigInteger.valueOf(start + Math.round(points * POINT_TO_TWIP)));
+ stop.setPos(BigInteger.valueOf(start + hang));
}
/**
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java
similarity index 74%
rename from render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java
rename to render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java
index b7b649318..8b45f731a 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java
@@ -18,37 +18,25 @@
import static org.assertj.core.api.Assertions.assertThat;
/**
- * {@code hangingIndent} is fixed-layout geometry, and the semantic DOCX export does not
- * lay text out — so it exports a list of text markers exactly the same way whether the
- * flag is set or not. (A top-level marker that is a picture alone is the exception, its
- * first line's text tabbed to where the layout puts it: {@code DocxListMarkerGapTest}.)
+ * What {@code hangingIndent} changes in the semantic DOCX export: the column a list's top
+ * level sets its marker in, taken from the layout, and nothing else — the same paragraphs,
+ * the same text, the same nesting, the same Word list.
*
- * That 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 the export embeds the page's face: 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 +61,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 zeroText = listTexts(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha"));
- List zeroIndent = levelIndents(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha"));
- for (double gap : List.of(4.0, 8.0, 16.0)) {
+ void theGapSetsTheLevelsColumnWhereItCoversTheMarkerAndNeverTheText() throws Exception {
+ // No gap leaves nothing to cover Word setting the bullet a little wider: the stated
+ // column stays. Every gap that does sets the column the bullet's width and itself
+ // past the item's start, and the text stays the item's.
+ assertThat(levelIndents(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha")))
+ .containsExactly(180, 180);
+ List four = levelIndents(l -> l.bullet().hangingIndent(true).markerGap(4).items("Alpha"));
+ assertThat(four.get(0)).as("as wide as it hangs").isEqualTo(four.get(1));
+ for (double gap : List.of(8.0, 16.0)) {
assertThat(listTexts(l -> l.bullet().hangingIndent(true).markerGap(gap).items("Alpha")))
- .as("text at gap %s", gap)
- .isEqualTo(zeroText);
- assertThat(levelIndents(l -> l.bullet().hangingIndent(true).markerGap(gap).items("Alpha")))
- .as("level indent at gap %s", gap)
- .isEqualTo(zeroIndent);
+ .as("text at gap %s", gap).containsExactly("Alpha");
+ assertThat(levelIndents(l -> l.bullet().hangingIndent(true).markerGap(gap).items("Alpha")).get(0))
+ .as("level indent at gap %s, the gap's points past gap 4's", gap)
+ .isCloseTo((int) (four.get(0) + Math.round((gap - 4) * 20)), org.assertj.core.data.Offset.offset(1));
}
- assertThat(zeroText).containsExactly("Alpha");
}
@Test
@@ -113,6 +101,18 @@ void nestedListsKeepTheirCascadeWhicheverWayTheFlagIsSet() throws Exception {
.containsExactly("•", "◦", "▪");
}
+ @Test
+ void aListThatNestsKeepsTheStatedColumnsAtEveryLevel() throws Exception {
+ // Only the top level's column is measured: kept stated, a nested level would stand
+ // left of its parent's text at a 12pt gap.
+ Consumer shape = list -> list.name("Outline")
+ .addItem("alpha", l1 -> l1.addItem("beta", l2 -> l2.addItem("gamma")));
+
+ assertThat(levelIndents(shape.andThen(l -> l.hangingIndent(true).markerGap(12))))
+ .isEqualTo(levelIndents(shape))
+ .startsWith(180, 180, 300, 180);
+ }
+
@Test
void aMarkerlessListAndAMarkerOnlyRowBothSurviveUnchanged() throws Exception {
// A list that asked for no marker gains nothing from numbering and would gain an
@@ -149,7 +149,7 @@ void aMarkerlessListAndAMarkerOnlyRowBothSurviveUnchanged() throws Exception {
}
@Test
- void theSameNumberingIsWrittenEitherWayAndNothingIsApproximatedOnTheParagraph() throws Exception {
+ void theFlagChangesTheLevelsColumnAndNothingOnTheParagraph() throws Exception {
try (XWPFDocument withFlag = export(flow -> flow.addList(list -> list
.name("Flat").bullet().hangingIndent(true).markerGap(16)
.items("Alpha", "Beta")));
@@ -163,15 +163,15 @@ void theSameNumberingIsWrittenEitherWayAndNothingIsApproximatedOnTheParagraph()
CTPPr properties = paragraph.getCTP().getPPr();
assertThat(properties.isSetNumPr()).as("the item belongs to a list").isTrue();
assertThat(properties.isSetInd())
- .as("no w:ind on the paragraph — the geometry is the level's, "
- + "and it is not approximated from the gap")
+ .as("no w:ind on the paragraph — the geometry is the level's")
.isFalse();
assertThat(properties.isSetTabs()).as("no tab stops").isFalse();
assertThat(paragraph.getRuns()).as("one run, as before").hasSize(1);
}
- assertThat(indentsOf(withFlag))
- .as("the flag changes no part of the list definition")
- .isEqualTo(indentsOf(without));
+ assertThat(indentsOf(without)).as("the stated column").containsExactly(180, 180);
+ List measured = indentsOf(withFlag);
+ assertThat(measured.get(0)).as("the column the layout set the bullet in, the 16pt gap and the bullet")
+ .isEqualTo(measured.get(1)).isGreaterThan(16 * 20);
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java
index 63a0c7fc7..035607889 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java
@@ -36,14 +36,83 @@ void aDrawnMarkersItemStandsTheMarkerAndItsGapPastTheItemsStart() throws Excepti
assertThat(paragraph.getText()).isEqualTo("\t" + item);
assertThat(tabStop(paragraph)).as("past the column's 30pt indent, the dot and its gap")
- .isEqualTo(leftIndent(paragraph) + Math.round((DOT + GAP) * 20));
- assertThat(leftIndent(paragraph)).as("the item starts at the column's indent").isEqualTo(600);
+ .isEqualTo(firstLineStart(paragraph) + Math.round((DOT + GAP) * 20));
+ assertThat(firstLineStart(paragraph)).as("the item starts at the column's indent").isEqualTo(600);
+ assertThat(leftIndent(paragraph)).as("and its lines hang at its text").isEqualTo(tabStop(paragraph));
}
}
}
@Test
- void aMarkerOfTextKeepsItsSpace() throws Exception {
+ void aWordListOfATextMarkerTakesTheColumnTheLayoutSetsIt() throws Exception {
+ // MidnightNavy's and Panel's bullets: the level's marker column was a stated 9pt.
+ try (XWPFDocument wide = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker("•").markerGap(GAP).hangingIndent(true).items("Alpha", "Beta")));
+ XWPFDocument stated = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker("•").markerGap(GAP).items("Alpha", "Beta")))) {
+ long[] column = levelZero(wide);
+
+ assertThat(column[0]).as("the column, as wide as it hangs").isEqualTo(column[1]);
+ assertThat(column[0]).as("the bullet's width and the gap").isGreaterThan(Math.round(GAP * 20));
+ assertThat(levelZero(stated)).as("a list the layout set no column for keeps the stated one")
+ .containsExactly(180, 180);
+ }
+ }
+
+ @Test
+ void aWordListInAPaddedColumnTakesItsInsetAndTheColumn() throws Exception {
+ // The column's inset written on the paragraph replaces the level's indent, so the
+ // level's column is added back on top of it.
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addSection("Column", column -> column.padding(new DocumentInsets(0, 0, 0, 30))
+ .addList(list -> list.marker("•").markerGap(GAP).hangingIndent(true).items("Alpha"))))) {
+ long[] column = levelZero(document);
+ XWPFParagraph paragraph = item(document, "Alpha");
+
+ assertThat(leftIndent(paragraph)).isEqualTo(600 + column[0]);
+ assertThat(firstLineStart(paragraph)).as("its marker at the column's inset").isEqualTo(600);
+ }
+ }
+
+ @Test
+ void aTextMarkersRichItemWithTooLittleGapKeepsItsSpace() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker("•").markerGap(0).hangingIndent(true)
+ .addItem(rich -> rich.bold("Label:").plain(" description"))))) {
+ XWPFParagraph paragraph = item(document, "description");
+
+ assertThat(paragraph.getText()).isEqualTo("• Label: description");
+ assertThat(paragraph.getCTP().getPPr().isSetTabs()).isFalse();
+ }
+ }
+
+ @Test
+ void aTextMarkerWithTooLittleGapKeepsTheStatedColumn() throws Exception {
+ // Word may set the bullet a few hundredths wider; with no gap to cover that, a column
+ // at the page's would leave it past its tab.
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker("•").markerGap(0).hangingIndent(true).items("Alpha")))) {
+ assertThat(levelZero(document)).containsExactly(180, 180);
+ }
+ }
+
+ @Test
+ void aTextMarkersRichItemHangsAtTheColumnTheLayoutSetsIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker("•").markerGap(GAP).hangingIndent(true)
+ .addItem(rich -> rich.bold("Label:").plain(" a description long enough to run onto a "
+ + "second line in the column it is set in, so its lines hang"))))) {
+ XWPFParagraph paragraph = item(document, "lines hang");
+
+ assertThat(paragraph.getText()).startsWith("•\tLabel:");
+ assertThat(firstLineStart(paragraph)).isZero();
+ assertThat(leftIndent(paragraph)).as("every line after the first at the text").isEqualTo(tabStop(paragraph))
+ .isGreaterThan(Math.round(GAP * 20));
+ }
+ }
+
+ @Test
+ void aRichMarkerOfTextKeepsItsSpace() throws Exception {
// Word sets a text marker in its own widths: a stop past it could fall short of it.
try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
.addList(list -> list.marker(m -> m.color("•", DocumentColor.rgb(0, 128, 128)))
@@ -97,7 +166,7 @@ void aTopLevelItemOfAListWithRichItemsStandsWhereThePageSetsItToo() throws Excep
assertThat(paragraph.getText()).startsWith("\t");
assertThat(tabStop(paragraph)).as("in a cell, from the cell's text edge")
- .isEqualTo(leftIndent(paragraph) + Math.round((DOT + GAP) * 20));
+ .isEqualTo(firstLineStart(paragraph) + Math.round((DOT + GAP) * 20));
}
}
@@ -137,6 +206,21 @@ private static long tabStop(XWPFParagraph paragraph) {
return DocxTwips.of(stops[0].getPos());
}
+ /** The left and hanging indents of a document's first list definition's top level. */
+ private static long[] levelZero(XWPFDocument document) {
+ var indent = document.getNumbering().getAbstractNum(java.math.BigInteger.ZERO).getAbstractNum()
+ .getLvlArray(0).getPPr().getInd();
+ return new long[] {DocxTwips.of(indent.getLeft()), DocxTwips.of(indent.getHanging())};
+ }
+
+ /** Where an item's first line starts: its left indent less what it hangs by. */
+ private static long firstLineStart(XWPFParagraph paragraph) {
+ var properties = paragraph.getCTP().getPPr();
+ long hanging = properties == null || !properties.isSetInd() || !properties.getInd().isSetHanging()
+ ? 0 : DocxTwips.of(properties.getInd().getHanging());
+ return leftIndent(paragraph) - hanging;
+ }
+
private static long leftIndent(XWPFParagraph paragraph) {
var properties = paragraph.getCTP().getPPr();
return properties == null || !properties.isSetInd() || !properties.getInd().isSetLeft()
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
index 98722fbb1..90e35f9ac 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
@@ -157,28 +157,32 @@ void aRichListItemKeepsOneWordRunPerAuthoredRun() throws Exception {
assertThat(paragraphs).hasSize(2);
// The marker still reads the same down the whole list, and the
- // item's text is all there. The geometry hangingIndent(true) asks
- // for has no Word analogue; which piece of an item is bold does.
- assertThat(paragraphs.get(0).getText()).isEqualTo("- Plain item");
+ // item's text is all there, a tab after the marker to the column the
+ // layout set it in; which piece of an item is bold is kept as well.
+ assertThat(paragraphs.get(0).getText()).isEqualTo("-\tPlain item");
XWPFParagraph rich = paragraphs.get(1);
- assertThat(rich.getText()).isEqualTo("- Status: pending");
+ assertThat(rich.getText()).isEqualTo("-\tStatus: pending");
assertThat(rich.getRuns().stream().map(XWPFRun::text).toList())
- .as("the marker leads in the list's own style, then one run each")
- .containsExactly("- ", "Status: ", "pending");
+ .as("the marker leads in the list's own style, a tab, then one run each")
+ .containsExactly("-", "\t", "Status: ", "pending");
assertThat(rich.getRuns().stream().map(XWPFRun::isBold).toList())
.as("and only the bold run is bold")
- .containsExactly(false, true, false);
+ .containsExactly(false, false, true, false);
}
}
@Test
- void aRichListItemExportsTheSameWhetherOrNotTheListOptedIntoMarkerGeometry() throws Exception {
- // hangingIndent is fixed-layout geometry, and the semantic export lays
- // nothing out — so unlike the PDF path, which needs the opt-in before it
- // can carry runs at all, this one is indifferent to it. Word owns the
- // layout here and can hold a run's style either way.
- assertThat(runStructure(richListDocx(false)))
- .isEqualTo(runStructure(richListDocx(true)));
+ void aRichListItemOptedIntoMarkerGeometryTabsToItsColumnAndKeepsItsRunsOtherwise() throws Exception {
+ // With the opt-in the layout sets the marker in a column of its own, and the item
+ // follows it with a tab to that column; without it, with the space it has always had.
+ // Which runs the item is made of, and their styles, are the same either way.
+ List plain = runStructure(richListDocx(false));
+ List opted = runStructure(richListDocx(true));
+ assertThat(plain).contains("- Plain item|bold=false", "- |bold=false");
+ assertThat(opted).contains("Plain item|bold=false", "-|bold=false", "\t|bold=false")
+ .doesNotContain("- Plain item|bold=false", "- |bold=false");
+ assertThat(opted.stream().filter(run -> run.startsWith("Status") || run.startsWith("pending")).toList())
+ .isEqualTo(plain.stream().filter(run -> run.startsWith("Status") || run.startsWith("pending")).toList());
}
@Test
From ae300ce62f4fe32945d7faac127b8b7f3e31eeed Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 4 Oct 2026 07:17:04 +0100
Subject: [PATCH 2/2] fix(docx): draw a measured list level's marker in the
list's own face and size
Word draws a level's marker in the paragraph mark's style, and an item at
an exact line leaves its mark the document's: a list set smaller than the
body would get a bullet wider than the column measured for it. The level
now names the list's face and size.
A nesting list keeps its space at the top level on the paragraph path too,
for a drawn marker and for rich items; tests pin both. The Linux
LibreOffice baseline is this branch's CI measure, and the docs state the
embedding condition the column relies on.
---
CHANGELOG.md | 24 +++++++-----
.../compose/document/dsl/ListBuilder.java | 9 +++--
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 26 +++++++------
docs/recipes/lists.md | 5 ++-
.../docx-fidelity/libreoffice-linux-lines.tsv | 12 +++---
.../semantic/docx/DocxSemanticBackend.java | 14 +++++--
.../docx/DocxListHangingIndentTest.java | 7 ++--
.../semantic/docx/DocxListMarkerGapTest.java | 39 +++++++++++++++++++
9 files changed, 97 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10bf4a0c5..44f87b3e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,14 +16,17 @@ follow semantic versioning; release dates are ISO 8601.
`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, in the page's face — embedded, or
- a standard face Word sets in the same widths — and the column is used only where the gap
- leaves half a point past that; a list that nests items, a marker the gap does not clear,
- and a list without the flag keep the stated columns. The test that
- held the flag to changing nothing, `DocxHangingIndentIsIgnoredTest`, is now
- `DocxListHangingIndentTest` and holds it to the column. `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 from 102 to 96 in LibreOffice.
+ 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
@@ -31,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 a14f57529..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
@@ -422,10 +422,11 @@ public ListBuilder continuationIndent(String continuationIndent) {
* 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 in the page's
- * face — embedded, or a standard one Word sets in the same widths — at its
- * size to the half point, half a point clear. A list that nests items, and a
- * marker the gap does not clear, keep stated columns.
+ * 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
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index e62161aef..44b971670 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -66,7 +66,7 @@ Payload records live in `core` under
| 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 f4a60abd8..a0791178c 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -378,16 +378,17 @@ changing what was asked for:
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 the marker in the page's
-face — embedded, or a standard one Word sets in the same widths — at its size to the half point, so that is a few hundredths at most; the gap
-must leave half a point past it. 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 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.
+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
@@ -725,8 +726,9 @@ tint it was flattened to. Recorded, like the other two.
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.
+ 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 4c9d78698..4a5e72c6c 100644
--- a/docs/recipes/lists.md
+++ b/docs/recipes/lists.md
@@ -95,8 +95,9 @@ inside the content column and leave the marker where it is.
It is off by default and stays off; existing lists are untouched.
> **In DOCX, the top level's column.** PDF and PPTX honour this in full. A
-> DOCX list is a real Word list either way — same levels, same markers, same
-> paragraphs, text and nesting. Its top level takes the column the layout set
+> 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
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/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 b59e5200c..97427eb4d 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
@@ -2715,6 +2715,12 @@ private BigInteger numberingFor(XWPFDocument document,
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));
@@ -3078,7 +3084,8 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
// A marker of text the layout set in a column of its own is followed by a tab to where
// it starts the text, and the item's lines hang there, as the page sets them — where the
// gap after the marker covers what Word may set it wider: its size to the half point,
- // the face the page's, embedded or one Word sets in the same widths. Panel's skills,
+ // in the page's face where the export embeds it or names a standard one set in the same
+ // widths, and the half point of clearance for the rest. Panel's skills,
// wrapped back under their bullets, stood 7.8pt left of the page's on their second line.
boolean textTab = measured != null && depth == 0 && setInAColumn(measured, marker);
String letters = marker.isRich() ? "" : marker.prefix().stripTrailing();
@@ -3180,8 +3187,9 @@ private static boolean nests(com.demcha.compose.document.node.ListNode list) {
/**
* How wide Word may set a list's marker of text: the page's width grown to the size Word
- * sets it in, to the half point; the face is the page's, embedded or a standard one Word
- * sets in the same widths.
+ * sets it in, to the half point. It holds where the export embeds the page's face or names a
+ * standard one set in the same widths; a face it may not embed is Word's to substitute, and
+ * only {@link #MARKER_TEXT_CLEARANCE} covers that.
*/
private double wordsMarkerWidth(com.demcha.compose.document.node.ListNode list, String letters,
DocumentTextStyle style) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java
index 8b45f731a..8ff5ab046 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListHangingIndentTest.java
@@ -28,9 +28,10 @@
* 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 the export embeds the page's face: 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});
+ * 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.
*
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java
index 035607889..0caab904e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListMarkerGapTest.java
@@ -86,6 +86,25 @@ void aTextMarkersRichItemWithTooLittleGapKeepsItsSpace() throws Exception {
}
}
+ @Test
+ void aWordListSetSmallerThanTheDocumentDrawsItsMarkerInItsOwnSize() throws Exception {
+ // Word draws a level's marker in the item's mark, which an exact line leaves the
+ // document's 11pt: an 8pt list's bullet would come out wider than the one measured.
+ var body = com.demcha.compose.document.style.DocumentTextStyle.DEFAULT.withSize(11);
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addParagraph(p -> p.text("The body of the page, set in the document's own size.").textStyle(body))
+ .addParagraph(p -> p.text("And more of it, so that eleven points is its size.").textStyle(body))
+ .addList(list -> list.marker("•").markerGap(1).hangingIndent(true)
+ .textStyle(body.withSize(8)).items("Alpha", "Beta")))) {
+ var level = document.getNumbering().getAbstractNum(java.math.BigInteger.ZERO).getAbstractNum()
+ .getLvlArray(0);
+
+ assertThat(levelZero(document)[0]).as("the column is measured").isNotEqualTo(180);
+ assertThat(level.isSetRPr()).as("the level names the list's style").isTrue();
+ assertThat(level.getRPr().getSzArray(0).getVal()).isEqualTo(java.math.BigInteger.valueOf(16));
+ }
+ }
+
@Test
void aTextMarkerWithTooLittleGapKeepsTheStatedColumn() throws Exception {
// Word may set the bullet a few hundredths wider; with no gap to cover that, a column
@@ -170,6 +189,26 @@ void aTopLevelItemOfAListWithRichItemsStandsWhereThePageSetsItToo() throws Excep
}
}
+ @Test
+ void aListThatNestsKeepsItsSpaceAtTheTopLevelToo() throws Exception {
+ // Its nested levels are not measured: a top level at the page's column could stand
+ // right of the text of the items nested under it.
+ try (XWPFDocument drawn = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker(m -> m.dot(DOT, DocumentColor.rgb(0, 128, 128)))
+ .markerGap(GAP).hangingIndent(true)
+ .addItem("Parent", parent -> parent.addItem("Child"))));
+ XWPFDocument text = DocxExports.withLayout(400, 300, 20, page -> page
+ .addList(list -> list.marker("•").markerGap(GAP).hangingIndent(true)
+ .addItem(rich -> rich.bold("Label:").plain(" parent"),
+ parent -> parent.addItem("Child"))))) {
+ for (XWPFParagraph parent : java.util.List.of(item(drawn, "Parent"), item(text, "parent"))) {
+ assertThat(parent.getText()).doesNotContain("\t");
+ assertThat(parent.getCTP().getPPr() == null || !parent.getCTP().getPPr().isSetTabs()).isTrue();
+ }
+ assertThat(item(text, "parent").getText()).isEqualTo("• Label: parent");
+ }
+ }
+
@Test
void aNestedItemsDrawnMarkerKeepsItsSpace() throws Exception {
// A nested item stands after its depth's indent, which the list's measure of its first