From f2d9e8963555df7fcd00dc9b8fb5eb7f61dc489a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Tue, 6 Oct 2026 14:35:27 +0100
Subject: [PATCH 1/2] fix(docx): name in the report what a list's items lose
A list's note now names its alignment (items written flush left), its
lineSpacing where the layout's items are not its own and one wraps, its
continuationIndent where an item of a list the page sets it in wraps, and
how many items stand at a stated column, a space past their marker or two
spaces a level in rather than where the page sets them. With no layout the
section's note names the space between lines. The DOCX bytes do not change.
---
CHANGELOG.md | 21 ++
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 9 +
.../semantic/docx/DocxSemanticBackend.java | 119 +++++++++-
.../semantic/docx/DocxListReportTest.java | 206 ++++++++++++++++++
.../docx/DocxNodeFieldLedgerTest.java | 14 +-
6 files changed, 353 insertions(+), 18 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f1dc888e..11cbb6227 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,27 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export's report names what a list's items lose.** A centred or right-aligned list was
+ written flush left, a list's lineSpacing went unwritten wherever the layout's items were not
+ its own, its continuationIndent was never written before a wrapped line, and an item at the
+ stated column — a list that nests with `hangingIndent`, a gap its marker does not clear, a tree
+ of items — stood off the page's column; the report named none of them. The list's note
+ (`ListNode`) now names:
+ - its alignment: its items are written flush left;
+ - its lineSpacing, where the layout's items are not the list's own and one wraps — an item run
+ onto the next page wraps, even split a line apiece — and in a list composed in a table cell,
+ which has no lines of its own to read, saying whether an item wraps is not measured;
+ - its continuationIndent, where an item of a list the page sets it in wraps: a markerless list,
+ or a tree of items, without `hangingIndent`;
+ - how many of its items stand at a stated column, 9pt in and 6pt more a level, or a space past
+ their marker or two spaces a level in, not where the page sets them.
+
+ With no layout behind the export, the section's `measured geometry` note says the space
+ between lines is the editor's too. Across the DOCX fidelity corpus the report names one list:
+ `SlateOrange`'s certifications, composed in a table cell, at the stated column. None of this
+ changes what is written: the 62 documents of the corpus export to the same bytes. In
+ `DocxNodeFieldLedgerTest` a list's `align`, `lineSpacing`, `continuationIndent`,
+ `hangingIndent` and `markerGap` move from a gap to `REPORTED`; 7 node-field gaps remain.
- **A DOCX export's report names a clip where it cuts something, and a turned container's
transform where no outline names it.** A Word file has no clip a container can set round its
layers: a sidebar's ornament set past its side, a square tile's corners in a disc, a label
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 24c58db66..e397cc72a 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -64,7 +64,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it |
-| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too |
+| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell, its wrapping not measured), and a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
| Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 0caf321b6..e0ca8c295 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -445,6 +445,15 @@ in than the list's edge, and every item of a list the layout did not place or wh
does not report one by one — an item split across pages — keep the spaces, and so does the
top level of a list that nests any of them.
+The report names what a list's items lose, on the list (`ListNode`): its items are written
+flush left where it is centred or right-aligned; its lineSpacing is not written where the
+layout's items are not its own and one wraps — an item run onto the next page wraps, even split
+a line apiece — nor in a list composed in a table cell, whose wrapping is not measured; its
+continuationIndent is not written where an item wraps in a list the page sets it in, a
+markerless list or a tree of items without `hangingIndent`; and it counts the items that stand
+at a stated column, a space past their marker or two spaces a level in, rather than where the
+page sets them.
+
## What a panel keeps and loses
A container that paints — a fill, per-side borders, a uniform stroke — exports as a table of
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 3122cd30b..ee514fdb4 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
@@ -419,6 +419,8 @@ public final class DocxSemanticBackend implements SemanticBackend {
private double listTextLeft = Double.NaN;
/** Whether the layout's items are matched to the list's, one by one: each then has its place. */
private boolean listItemsMatched;
+ /** How many items of the list being written do not stand where the page sets them (itemSet). */
+ private int listItemsOffTheirColumn;
/**
* How the layout set each item of the list being written still to come, first item first:
* its lines and where its text starts; empty when they cannot be told apart, and then no
@@ -838,12 +840,13 @@ private void beginSection(SemanticSection section, int index) {
layout = DocxLayoutMetrics.of(section.graph(), context.layoutGraph());
if (layout.isEmpty()) {
// Said once per section, naming it when there are several: without measurements
- // the line height is Word's and so is every auto column, and a caller comparing
- // this file against the rendered page deserves to know that before they look.
+ // the line height is Word's, a lineSpacing is put between no lines, and every auto
+ // column is Word's, and a caller comparing this file against the rendered page
+ // deserves to know that before they look.
report.add(DocxExportReport.Severity.APPROXIMATED, "measured geometry",
sectioned ? "section " + (index + 1) : null,
(sectioned ? "this section" : "this document")
- + " could not be laid out, so line heights and auto column "
+ + " could not be laid out, so line heights, the space between lines and auto column "
+ "widths are the editor's rather than the engine's");
}
carriedSpacingBefore = 0;
@@ -3381,6 +3384,9 @@ private void writeList(XWPFDocument document,
: new java.util.ArrayDeque<>();
boolean previousMatched = listItemsMatched;
listItemsMatched = !laidOut.isEmpty() && laidOut.size() == itemCount(list);
+ boolean matched = listItemsMatched;
+ int previousOffTheirColumn = listItemsOffTheirColumn;
+ listItemsOffTheirColumn = 0;
// Where the list's items start on the page, past its margin and padding: an item's text
// stands its own distance past it (see indentAsTheItemIs).
double previousTextLeft = listTextLeft;
@@ -3396,9 +3402,11 @@ private void writeList(XWPFDocument document,
double spare = currentCell != null ? EDITOR_SLACK_POINTS : 0;
insetLeft += Math.max(0, (list == leftMarginInCell ? 0 : list.margin().left()) + list.padding().left());
insetRight += Math.max(0, list.margin().right() + list.padding().right() - spare);
+ int offTheirColumn;
try {
writeListItems(document, list, numId);
} finally {
+ offTheirColumn = listItemsOffTheirColumn;
insetLeft = outerLeft;
insetRight = outerRight;
pendingItemSpacing = previousItemSpacing;
@@ -3407,8 +3415,85 @@ private void writeList(XWPFDocument document,
listItemLines = previousItemLines;
listTextLeft = previousTextLeft;
listItemsMatched = previousMatched;
+ listItemsOffTheirColumn = previousOffTheirColumn;
}
owePendingSpacingAfter(list.margin().bottom() + list.padding().bottom());
+ reportWrittenWithout(list, numId != null ? "written as a Word list" : "written as a paragraph per item",
+ listLost(list, laidOut, matched, offTheirColumn));
+ }
+
+ /**
+ * What a list's items lose, as the phrases its note lists.
+ *
+ * Its alignment: every item is written flush left. Its lineSpacing, where the layout's
+ * items are not matched to the list's and one of them wraps — an item run onto the next page
+ * is laid out as a piece on each, and wraps whatever its pieces hold — or, composed in a table
+ * cell, where the list has no lines of its own to read: the gap is put between an item's lines
+ * only by its laid-out lines (see {@link #applyItemLineGap}). With no layout at all, the
+ * section's own note says the space between lines is the editor's. Its continuationIndent,
+ * which the page sets before every wrapped line of a list whose markers are not drawn before
+ * them (a hidden marker, or a tree flattened into its labels, without {@code hangingIndent}),
+ * where an item wraps. And how many of its items do not stand where the page sets them
+ * ({@link #itemSet}): with {@code hangingIndent}, its marker column and markerGap. A list
+ * that writes no item loses none of it.
+ *
+ * @param laidOut the items as the layout laid them out
+ * @param matched whether those are the list's own, one for one
+ * @param offTheirColumn how many of its items do not stand where the page sets them
+ */
+ private List listLost(com.demcha.compose.document.node.ListNode list,
+ List laidOut, boolean matched, int offTheirColumn) {
+ List lost = new ArrayList<>();
+ int items = itemCount(list);
+ if (items == 0) {
+ // It writes no paragraph, so nothing of it is lost.
+ return lost;
+ }
+ if (list.align() != TextAlign.LEFT) {
+ lost.add("its items are written flush left, where the page sets them "
+ + (list.align() == TextAlign.CENTER ? "centred" : "right-aligned"));
+ }
+ // Whether an item wraps, by the layout's own lines; with none of its own to read, not known.
+ // An item run onto the next page is laid out as a piece on each, maybe of one line apiece,
+ // and has more than one line whatever its pieces hold.
+ boolean wraps = laidOut.size() > items || laidOut.stream().anyMatch(item -> item.lines().size() > 1);
+ String unmeasured = layout.isEmpty() || laidOut.isEmpty() ? " — whether an item wraps is not measured" : "";
+ if (list.lineSpacing() > 0 && !layout.isEmpty() && !matched && (laidOut.isEmpty() || wraps)) {
+ lost.add("its lineSpacing is not written between a wrapped item's lines" + unmeasured);
+ }
+ boolean continued = !list.marker().isVisible() || !list.nestedItems().isEmpty();
+ if (!list.hangingIndent() && continued && !list.continuationIndent().isEmpty()
+ && (!unmeasured.isEmpty() || wraps)) {
+ lost.add("its continuationIndent is not written before a wrapped item's lines" + unmeasured);
+ }
+ if (offTheirColumn > 0) {
+ lost.add(offTheirColumn + " of its " + items + " items stand at a stated column — 9pt in, "
+ + "6pt more a level — or a space past their marker or two spaces a level in, not where "
+ + "the page sets them");
+ }
+ return lost;
+ }
+
+ /**
+ * Counts an item of the list being written that does not stand where the page sets it: a
+ * Word list's item at the stated column of its level (see {@link #numberingFor}), and an
+ * item of a {@code hangingIndent} list a space past its marker or two spaces a level in. A
+ * stated column is not measured against the page's, so one the page's happens to equal counts.
+ */
+ private void itemSet(boolean whereThePageSetsIt) {
+ if (!whereThePageSetsIt) {
+ listItemsOffTheirColumn++;
+ }
+ }
+
+ /**
+ * Whether an item written as its marker's letters, two spaces a level ahead of them, stands
+ * where the page sets it: without {@code hangingIndent} the page sets those very letters (its
+ * tree flattened into them); with it, a column past the marker, or at the edge with none.
+ */
+ private static boolean asItsLetters(com.demcha.compose.document.node.ListNode list, int depth,
+ com.demcha.compose.document.node.ListMarker marker) {
+ return !list.hangingIndent() || depth == 0 && marker.prefix().isBlank();
}
/**
@@ -3497,21 +3582,23 @@ private void writeListItems(XWPFDocument document,
if (list.marker().isRich()) {
// A drawn marker's pieces are runs, so the row is written the way
// any row with runs in it is; its item is still just a label.
- writeRichListLine(document, list.textStyle(), list.marker(),
+ itemSet(writeRichListLine(document, list.textStyle(), list.marker(),
com.demcha.compose.document.node.ListItem.of(normalized), 0, lineHeight, layout.firstLine(list),
- layout.pathOf(list), 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);
+ itemSet(measuredColumns.containsKey(numId));
} 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(),
+ itemSet(writeRichListLine(document, list.textStyle(), list.marker(),
com.demcha.compose.document.node.ListItem.of(normalized), 0, lineHeight, layout.firstLine(list),
- layout.pathOf(list), list);
+ layout.pathOf(list), list));
} else {
writeListLine(document, list.textStyle(),
list.marker().prefix() + normalized, 0, null, lineHeight);
+ itemSet(asItsLetters(list, 0, list.marker()));
}
}
for (com.demcha.compose.document.node.ListItem item : list.nestedItems()) {
@@ -3537,16 +3624,18 @@ 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.
- writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight, layout.firstLine(list),
- layout.pathOf(list), depth == 0 && marker.equals(list.marker()) ? list : null);
+ itemSet(writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight,
+ layout.firstLine(list), layout.pathOf(list), depth == 0 && marker.equals(list.marker()) ? list : null));
} else if (numId != null) {
writeListLine(document, list.textStyle(), item.label(), depth, numId, lineHeight);
+ itemSet(depth == 0 && measuredColumns.containsKey(numId));
} 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);
+ itemSet(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);
+ itemSet(asItsLetters(list, depth, marker));
}
for (com.demcha.compose.document.node.ListItem child : item.children()) {
writeNestedItem(document, list, child, depth + 1, numId);
@@ -3678,8 +3767,10 @@ private void indentListItemInside(XWPFParagraph para, int depth, BigInteger numI
* @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
+ * @return whether its text stands where the page sets it: tabbed to the marker column the
+ * layout set, at the layout's own place for it, or at the edge with no marker before it
*/
- private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
+ private boolean writeRichListLine(XWPFDocument document, DocumentTextStyle style,
com.demcha.compose.document.node.ListMarker marker,
com.demcha.compose.document.node.ListItem item,
int depth,
@@ -3717,6 +3808,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
}
java.util.OptionalDouble markerToText = measured != null && depth == 0 && !nestsBesideItsText(measured)
? layout.markerToText(measured) : java.util.OptionalDouble.empty();
+ boolean pictureTab = false;
PictureReach pictures = PictureReach.NONE;
if (marker.isRich()) {
pictures = writeInlineTextRuns(para, style, marker.runs(), path, line);
@@ -3741,6 +3833,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
// would run on to Word's next default stop, half an inch on.
gap.addTab();
hangFrom(para, markerToText.getAsDouble());
+ pictureTab = true;
} else {
gap.setText(" ");
}
@@ -3764,6 +3857,8 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style,
}
makeRoomForPictures(para, pictures);
styleTheMark(para, markStyle);
+ return atItsText || textTab || pictureTab
+ || depth == 0 && !marker.isRich() && marker.prefix().isBlank();
}
/** How far past a list marker's picture its tab stop must stand for the tab to reach it, in points. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java
new file mode 100644
index 000000000..d2a5d5973
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java
@@ -0,0 +1,206 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ListBuilder;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.node.ListItem;
+import com.demcha.compose.document.node.ListMarker;
+import com.demcha.compose.document.node.ListNode;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What a list's items lose in the Word file is in the report: its alignment, its lineSpacing
+ * where the layout's items are not its own, its continuationIndent, and the column of every
+ * item that does not stand where the page sets it.
+ *
+ * Each was written by the page and left out of the file without a note. A list that keeps
+ * them — its lineSpacing put between its laid-out lines, its items tabbed to the page's column
+ * or set where the layout set them — is not named.
+ */
+class DocxListReportTest {
+
+ private static final String LONG = "A highlight long enough to run past the end of its line, "
+ + "and on past the end of the next one, in a narrow page";
+ private static final String TWO_LINES = "One highlight that wraps onto exactly two lines here";
+ private static final String STATED =" stand at a stated column — 9pt in, 6pt more a level — or a space past "
+ + "their marker or two spaces a level in, not where the page sets them";
+
+ @Test
+ void aCentredOrARightAlignedListIsNamed() throws Exception {
+ assertThat(listNote(page -> page.addList(list -> list.name("Steps").items("One", "Two")
+ .align(TextAlign.CENTER))))
+ .isEqualTo("written as a Word list; its items are written flush left, where the page sets them centred");
+ assertThat(listNote(page -> page.addList(list -> list.name("Steps").items("One", "Two")
+ .align(TextAlign.RIGHT))))
+ .isEqualTo("written as a Word list; its items are written flush left, where the page sets them right-aligned");
+ assertThat(listNotes(page -> page.addList(list -> list.name("Steps").items("One", "Two")
+ .align(TextAlign.LEFT)))).isEmpty();
+ }
+
+ @Test
+ void aListsLineSpacingIsNamedWhereTheLayoutsItemsAreNotItsOwn() throws Exception {
+ // An item run onto the next page is laid out as two: no item is matched to the list's,
+ // and none is given the gap between its lines.
+ assertThat(listNote(200, page -> page.addList(list -> list.name("Highlights").lineSpacing(4)
+ .items(LONG, String.join(" ", LONG, LONG, LONG, LONG, LONG)))))
+ .isEqualTo("written as a Word list; its lineSpacing is not written between a wrapped item's lines");
+ // Split a line apiece, an item still wraps: one line at the foot of the page, one on the next.
+ assertThat(listNote(200, page -> page
+ .addSpacer(spacer -> spacer.height(125))
+ .addList(list -> list.name("Highlights").lineSpacing(4).textStyle(DocumentTextStyle.DEFAULT.withSize(10))
+ .items(TWO_LINES, "Short"))))
+ .isEqualTo("written as a Word list; its lineSpacing is not written between a wrapped item's lines");
+ // Composed in a table cell, it has no lines of its own to match or to read, nor a column.
+ assertThat(listNote(page -> page.addTable(table -> table.name("Facts")
+ .columns(DocumentTableColumn.fixed(160))
+ .rowCells(DocumentTableCell.node(new ListBuilder().name("Highlights").lineSpacing(4)
+ .items(LONG).build())))))
+ .isEqualTo("written as a Word list; its lineSpacing is not written between a wrapped item's lines"
+ + " — whether an item wraps is not measured; 1 of its 1 items" + STATED);
+
+ // On one page, every item's own lines are given the gap.
+ assertThat(listNotes(page -> page.addList(list -> list.name("Highlights").lineSpacing(4)
+ .items(LONG, LONG)))).isEmpty();
+ }
+
+ @Test
+ void aContinuationIndentIsNamedWhereAnItemWraps() throws Exception {
+ // With no marker drawn before them, the page sets a wrapped item's lines after it.
+ assertThat(listNote(page -> page.addList(list -> list.name("Notes").noMarker()
+ .continuationIndent(" ").items(LONG))))
+ .isEqualTo("written as a paragraph per item; its continuationIndent is not written before a "
+ + "wrapped item's lines");
+
+ assertThat(listNotes(page -> page.addList(list -> list.name("Notes").noMarker()
+ .continuationIndent(" ").items("Short", "Lines"))))
+ .as("no item wraps").isEmpty();
+ assertThat(listNotes(page -> page.addList(list -> list.name("Notes").bullet()
+ .continuationIndent(" ").items(LONG))))
+ .as("a marker the page sets before the wrapped lines instead").isEmpty();
+ assertThat(listNotes(page -> page.addList(list -> list.name("Notes").noMarker().hangingIndent(true)
+ .continuationIndent(" ").items(LONG))))
+ .as("its items set in a column of their own, which the page applies no prefix to").isEmpty();
+
+ // A tree of items is laid out flattened into its labels, with no marker before a wrapped line.
+ assertThat(listNote(page -> page.addList(list -> list.name("Notes").continuationIndent(" ")
+ .addItem(LONG, child -> { }))))
+ .isEqualTo("written as a Word list; its continuationIndent is not written before a wrapped "
+ + "item's lines; 1 of its 1 items" + STATED);
+ }
+
+ @Test
+ void aListThatWritesNoItemIsNotNamed() throws Exception {
+ assertThat(listNotes(page -> page.addList(list -> list.name("Empty").items("", " ")
+ .align(TextAlign.CENTER).noMarker().continuationIndent(" ")))).isEmpty();
+ }
+
+ @Test
+ void anItemThatDoesNotStandWhereThePageSetsItIsCounted() throws Exception {
+ // A hangingIndent list that nests keeps the stated columns: its levels are not measured.
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills").hangingIndent(true)
+ .addItem("Languages", child -> child.addItem("Java").addItem("Kotlin")))))
+ .isEqualTo("written as a Word list; 3 of its 3 items" + STATED);
+ // A gap too narrow to clear what Word may set the marker wider in.
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills").hangingIndent(true).markerGap(0)
+ .items("Java", "Kotlin"))))
+ .isEqualTo("written as a Word list; 2 of its 2 items" + STATED);
+ // A tree of items without hangingIndent is laid out flattened, its markers in its text.
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills")
+ .addItem("Java", child -> { }).addItem("Kotlin", child -> { }))))
+ .isEqualTo("written as a Word list; 2 of its 2 items" + STATED);
+
+ assertThat(listNotes(page -> page.addList(list -> list.name("Skills").hangingIndent(true)
+ .items("Java", "Kotlin")))).as("its marker column the page's").isEmpty();
+ assertThat(listNotes(page -> page.addList(list -> list.name("Skills").items("Java", "Kotlin"))))
+ .as("its column the spaces the page sets its wrapped lines after").isEmpty();
+ // A name and the description set under it, a tab and the layout's own place.
+ assertThat(listNotes(page -> page.addList(list -> list.name("Projects").marker("•")
+ .markerFor(1, ListMarker.none()).hangingIndent(true)
+ .addItem(rich -> rich.bold("Magazine"), child -> child.addItem(rich -> rich.plain(LONG))))))
+ .isEmpty();
+ }
+
+ @Test
+ void anItemWrittenAsTextOrInRunsIsCountedWhereItLeavesThePagesColumn() throws Exception {
+ // Siblings that disagree on the marker stay text, the marker and a space before the item.
+ // Without hangingIndent those are the letters the page sets; with it, a column past the marker.
+ List mixed = List.of(new ListItem("alpha", ListMarker.custom("✓"), List.of()),
+ new ListItem("beta", ListMarker.custom("✗"), List.of()));
+ assertThat(listNotes(page -> page.add(new ListNode("Mixed", List.of(), mixed, ListMarker.bullet(),
+ DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0, 0, "", true, DocumentInsets.zero(),
+ DocumentInsets.zero())))).isEmpty();
+ assertThat(listNote(page -> page.add(new ListNode("Mixed", List.of(), mixed, ListMarker.bullet(),
+ DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0, 0, "", true, DocumentInsets.zero(),
+ DocumentInsets.zero(), true, ListNode.DEFAULT_MARKER_GAP))))
+ .isEqualTo("written as a paragraph per item; 2 of its 2 items" + STATED);
+
+ // Rich items nesting one with a marker: it keeps its spaces a level in, and the top level
+ // a space after its marker.
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills").hangingIndent(true)
+ .addItem(rich -> rich.bold("Java"), child -> child.addItem(rich -> rich.plain("Spring"))))))
+ .isEqualTo("written as a paragraph per item; 2 of its 2 items" + STATED);
+ assertThat(listNotes(page -> page.addList(list -> list.name("Skills").noMarker().hangingIndent(true)
+ .addItem(rich -> rich.bold("Java"))))).as("no marker before it").isEmpty();
+
+ // A marker that draws a disc alone: a tab to the page's column where its gap clears the
+ // picture, a space where it does not.
+ assertThat(listNotes(page -> page.addList(list -> list.name("Skills").hangingIndent(true)
+ .marker(marker -> marker.dot(5, DocumentColor.rgb(0, 128, 128))).items("Java", "Kotlin"))))
+ .isEmpty();
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills").hangingIndent(true).markerGap(0)
+ .marker(marker -> marker.dot(5, DocumentColor.rgb(0, 128, 128))).items("Java", "Kotlin"))))
+ .isEqualTo("written as a paragraph per item; 2 of its 2 items" + STATED);
+ }
+
+ @Test
+ void withNoLayoutAListAtTheStatedColumnIsNamedAndTheSectionsNoteNamesTheSpaceBetweenLines() throws Exception {
+ DocxExportReport report = DocxExports.reportWithoutLayout(400, 600, 40, page -> page
+ .addList(list -> list.name("Skills").hangingIndent(true).lineSpacing(4).items("Java", "Kotlin")));
+
+ assertThat(report.bySubject().get("ListNode")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("written as a Word list; 2 of its 2 items" + STATED);
+ assertThat(report.bySubject().get("measured geometry")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("this document could not be laid out, so line heights, the space between lines "
+ + "and auto column widths are the editor's rather than the engine's");
+ }
+
+ private static String listNote(Consumer content) throws Exception {
+ return listNote(600, content);
+ }
+
+ private static String listNote(double pageHeight, Consumer content) throws Exception {
+ List notes = listNotes(pageHeight, content);
+ assertThat(notes).as("the list's note").hasSize(1);
+ return notes.get(0);
+ }
+
+ private static List listNotes(Consumer content) throws Exception {
+ return listNotes(600, content);
+ }
+
+ private static List listNotes(double pageHeight, Consumer content) throws Exception {
+ AtomicReference captured = new AtomicReference<>();
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(240, pageHeight)
+ .margin(DocumentInsets.of(30))
+ .create()) {
+ session.pageFlow(content::accept);
+ session.export(new DocxSemanticBackend(captured::set));
+ }
+ return captured.get().bySubject().getOrDefault("ListNode", List.of()).stream()
+ .map(DocxExportReport.Note::detail).toList();
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index eb6384ecf..3ec558374 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -138,12 +138,16 @@ private record Entry(Fate fate, String note) {
"anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a cap other than butt; whether an editor ends a drawn butt line flat is not measured",
"fillWidth:WRITTEN", "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in");
node(ListNode.class, "name:INERT", "items:WRITTEN", "nestedItems:WRITTEN", "marker:WRITTEN",
- "textStyle:WRITTEN", "align:GAP:a centred or right-aligned list is written flush left",
- "lineSpacing:GAP:the gap between an item's lines where the layout's item count differs",
- "itemSpacing:WRITTEN", "continuationIndent:GAP:the indent of an item's wrapped lines",
+ "textStyle:WRITTEN", "align:REPORTED",
+ "lineSpacing:REPORTED:where the layout's items are not its own and one wraps, an item run "
+ + "onto the next page among them; composed in a table cell, its wrapping not measured; "
+ + "with no layout, in the section's note",
+ "itemSpacing:WRITTEN",
+ "continuationIndent:REPORTED:where an item wraps in a markerless list or a tree of items "
+ + "without hangingIndent, the only lists the page sets it in",
"normalizeMarkers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN",
- "hangingIndent:GAP:the marker column of a nesting list's deeper levels",
- "markerGap:GAP:the gap of a nesting list's deeper levels");
+ "hangingIndent:REPORTED:where an item stands at a stated column or a space past its marker",
+ "markerGap:REPORTED:where an item stands at a stated column or a space past its marker");
node(PageBreakNode.class, "name:INERT", "margin:INERT");
node(PageFieldNode.class, "name:INERT", "kind:WRITTEN", "textStyle:WRITTEN",
"align:GAP:its alignment in a page zone", "padding:GAP:its sides in a page zone",
From e23ad40263c3de8f8e03f8b08f01076644769c5a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Tue, 6 Oct 2026 15:08:06 +0100
Subject: [PATCH 2/2] fix(docx): count a list's items at their column failing
closed, and name a marker drawn alone
The writers return how many items stand where the page sets them, each
branch assigning it, so an item a path forgets counts as off its column.
A hangingIndent list's blank item, which the page draws as its marker
alone and the export does not write, is named, and is no longer taken
for an item run onto the next page. A continuationIndent whose lines
cannot be read is named as unmeasured. The README and the recipe's
lineSpacing paragraph say what the list note names.
---
CHANGELOG.md | 15 +-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 13 +-
render-docx/README.md | 8 +-
.../semantic/docx/DocxSemanticBackend.java | 182 +++++++++++-------
.../semantic/docx/DocxListReportTest.java | 30 ++-
.../docx/DocxNodeFieldLedgerTest.java | 14 +-
7 files changed, 177 insertions(+), 87 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11cbb6227..775f9ec0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,23 +12,26 @@ follow semantic versioning; release dates are ISO 8601.
written flush left, a list's lineSpacing went unwritten wherever the layout's items were not
its own, its continuationIndent was never written before a wrapped line, and an item at the
stated column — a list that nests with `hangingIndent`, a gap its marker does not clear, a tree
- of items — stood off the page's column; the report named none of them. The list's note
- (`ListNode`) now names:
+ of items — stood off the page's column, and a blank item a `hangingIndent` list draws as its
+ marker alone was not written; the report named none of them. The list's note (`ListNode`) now
+ names:
- its alignment: its items are written flush left;
- its lineSpacing, where the layout's items are not the list's own and one wraps — an item run
onto the next page wraps, even split a line apiece — and in a list composed in a table cell,
which has no lines of its own to read, saying whether an item wraps is not measured;
- - its continuationIndent, where an item of a list the page sets it in wraps: a markerless list,
- or a tree of items, without `hangingIndent`;
+ - its continuationIndent, in a list the page sets it in — a markerless list, or a tree of
+ items, without `hangingIndent` — where an item wraps or, its lines unread, saying so;
- how many of its items stand at a stated column, 9pt in and 6pt more a level, or a space past
- their marker or two spaces a level in, not where the page sets them.
+ their marker or two spaces a level in, not where the page sets them;
+ - the rows the page draws as a marker alone, for blank items.
With no layout behind the export, the section's `measured geometry` note says the space
between lines is the editor's too. Across the DOCX fidelity corpus the report names one list:
`SlateOrange`'s certifications, composed in a table cell, at the stated column. None of this
changes what is written: the 62 documents of the corpus export to the same bytes. In
`DocxNodeFieldLedgerTest` a list's `align`, `lineSpacing`, `continuationIndent`,
- `hangingIndent` and `markerGap` move from a gap to `REPORTED`; 7 node-field gaps remain.
+ `hangingIndent` and `markerGap` move from a gap to `REPORTED`, and its `items` from `WRITTEN`
+ to `REPORTED` for a blank item drawn as a marker alone; 7 node-field gaps remain.
- **A DOCX export's report names a clip where it cuts something, and a turned container's
transform where no outline names it.** A Word file has no clip a container can set round its
layers: a sidebar's ornament set past its side, a square tile's corners in a disc, a label
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index e397cc72a..2af9ef112 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -64,7 +64,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it |
-| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell, its wrapping not measured), and a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps |
+| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too. The report counts, on the list, the items that stand at a stated column, a space past their marker or two spaces a level in, and names a centred or right-aligned list written flush left, a lineSpacing not written where the layout's items are not the list's own and one wraps (in a list composed in a table cell, its wrapping not measured), a continuationIndent not written where an item of a markerless list or a tree of items without the flag wraps or its wrapping is not measured, and the rows the page draws as a marker alone for blank items of a flagged list, which are not written |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
| Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index e0ca8c295..69b1a7195 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -246,7 +246,9 @@ goes into the line: Word has one line height for a paragraph and no gap between
so a paragraph that wraps is written with its lines taller. The page has one gap fewer than
lines; what the space above the paragraph can spare of that one comes off it, and the rest is
shared out over the lines, so the paragraph keeps its height on the page. Each list item holds
-the gap only when it wraps. A paragraph on one line has no gap to hold.
+the gap only when it wraps, and only where the layout's items are the list's own, one for one;
+where they are not, the report names the gap (see "What a list becomes"). A paragraph on one
+line has no gap to hold.
A gap is written **once, above**. The space a block holds below itself waits for the next
paragraph and is written there as `w:before`, together with whatever that paragraph asks
@@ -449,10 +451,11 @@ The report names what a list's items lose, on the list (`ListNode`): its items a
flush left where it is centred or right-aligned; its lineSpacing is not written where the
layout's items are not its own and one wraps — an item run onto the next page wraps, even split
a line apiece — nor in a list composed in a table cell, whose wrapping is not measured; its
-continuationIndent is not written where an item wraps in a list the page sets it in, a
-markerless list or a tree of items without `hangingIndent`; and it counts the items that stand
-at a stated column, a space past their marker or two spaces a level in, rather than where the
-page sets them.
+continuationIndent is not written in a list the page sets it in, a markerless list or a tree of
+items without `hangingIndent`, where an item wraps or its wrapping is not measured; it counts
+the items that stand at a stated column, a space past their marker or two spaces a level in,
+rather than where the page sets them; and it names the rows a `hangingIndent` list draws as a
+marker alone for blank items, which the export does not write.
## What a panel keeps and loses
diff --git a/render-docx/README.md b/render-docx/README.md
index 196dae6cb..7f2213245 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -112,7 +112,13 @@ What is not written — each one is named in the export report
- **A row's own fill, outline and side borders**, named in the export report as `row paint`.
- **In a page zone, anything but paragraphs, page fields and spacers** — a logo, a barcode,
a rule — named in the export report as `page zone content`.
-- **`markerGap`** on a hanging-indent list: Word places the item text at its own indent.
+- **A list's own geometry where Word cannot hold it**, named in the export report on the list:
+ - a centred or right-aligned list's alignment;
+ - its `lineSpacing` where the layout's items are not its own;
+ - its `continuationIndent`;
+ - the marker column and `markerGap` of an item at a stated column — a list that nests, or a gap
+ too narrow for its marker — while a flat hanging-indent list keeps the page's column;
+ - a row the page draws as a marker alone, for a blank item.
Multi-section documents export through `MultiSectionDocument.toDocxBytes()`,
`writeDocx(...)` and `buildDocx(...)` (Experimental): each section becomes a Word section
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index ee514fdb4..7c9b22d07 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
@@ -419,8 +419,6 @@ public final class DocxSemanticBackend implements SemanticBackend {
private double listTextLeft = Double.NaN;
/** Whether the layout's items are matched to the list's, one by one: each then has its place. */
private boolean listItemsMatched;
- /** How many items of the list being written do not stand where the page sets them (itemSet). */
- private int listItemsOffTheirColumn;
/**
* How the layout set each item of the list being written still to come, first item first:
* its lines and where its text starts; empty when they cannot be told apart, and then no
@@ -3384,9 +3382,6 @@ private void writeList(XWPFDocument document,
: new java.util.ArrayDeque<>();
boolean previousMatched = listItemsMatched;
listItemsMatched = !laidOut.isEmpty() && laidOut.size() == itemCount(list);
- boolean matched = listItemsMatched;
- int previousOffTheirColumn = listItemsOffTheirColumn;
- listItemsOffTheirColumn = 0;
// Where the list's items start on the page, past its margin and padding: an item's text
// stands its own distance past it (see indentAsTheItemIs).
double previousTextLeft = listTextLeft;
@@ -3402,11 +3397,10 @@ private void writeList(XWPFDocument document,
double spare = currentCell != null ? EDITOR_SLACK_POINTS : 0;
insetLeft += Math.max(0, (list == leftMarginInCell ? 0 : list.margin().left()) + list.padding().left());
insetRight += Math.max(0, list.margin().right() + list.padding().right() - spare);
- int offTheirColumn;
+ int atTheirColumn;
try {
- writeListItems(document, list, numId);
+ atTheirColumn = writeListItems(document, list, numId);
} finally {
- offTheirColumn = listItemsOffTheirColumn;
insetLeft = outerLeft;
insetRight = outerRight;
pendingItemSpacing = previousItemSpacing;
@@ -3415,11 +3409,11 @@ private void writeList(XWPFDocument document,
listItemLines = previousItemLines;
listTextLeft = previousTextLeft;
listItemsMatched = previousMatched;
- listItemsOffTheirColumn = previousOffTheirColumn;
}
owePendingSpacingAfter(list.margin().bottom() + list.padding().bottom());
- reportWrittenWithout(list, numId != null ? "written as a Word list" : "written as a paragraph per item",
- listLost(list, laidOut, matched, offTheirColumn));
+ reportWrittenWithout(list, itemCount(list) == 0 ? "writes no paragraph"
+ : numId != null ? "written as a Word list" : "written as a paragraph per item",
+ listLost(list, laidOut, atTheirColumn));
}
/**
@@ -3427,47 +3421,63 @@ private void writeList(XWPFDocument document,
*
* Its alignment: every item is written flush left. Its lineSpacing, where the layout's
* items are not matched to the list's and one of them wraps — an item run onto the next page
- * is laid out as a piece on each, and wraps whatever its pieces hold — or, composed in a table
- * cell, where the list has no lines of its own to read: the gap is put between an item's lines
+ * is laid out as a piece on each, and wraps whatever its pieces hold — or where the list has
+ * no lines of its own to read, composed in a table cell: the gap is put between an item's lines
* only by its laid-out lines (see {@link #applyItemLineGap}). With no layout at all, the
* section's own note says the space between lines is the editor's. Its continuationIndent,
* which the page sets before every wrapped line of a list whose markers are not drawn before
* them (a hidden marker, or a tree flattened into its labels, without {@code hangingIndent}),
- * where an item wraps. And how many of its items do not stand where the page sets them
- * ({@link #itemSet}): with {@code hangingIndent}, its marker column and markerGap. A list
- * that writes no item loses none of it.
+ * where an item wraps or its lines cannot be read. How many of its items do not stand where
+ * the page sets them: with {@code hangingIndent}, its marker column and markerGap. A stated
+ * column is not measured against the page's, so one the page's happens to equal counts too.
+ * And the rows the page draws as a marker alone, which the export does not write
+ * ({@link #markerOnlyRows}).
*
- * @param laidOut the items as the layout laid them out
- * @param matched whether those are the list's own, one for one
- * @param offTheirColumn how many of its items do not stand where the page sets them
+ * @param laidOut the items as the layout laid them out
+ * @param atTheirColumn how many of its items stand where the page sets them
*/
private List listLost(com.demcha.compose.document.node.ListNode list,
- List laidOut, boolean matched, int offTheirColumn) {
+ List laidOut, int atTheirColumn) {
List lost = new ArrayList<>();
int items = itemCount(list);
- if (items == 0) {
- // It writes no paragraph, so nothing of it is lost.
- return lost;
+ int markerRows = markerOnlyRows(list);
+ if (items > 0) {
+ lost.addAll(itemsLost(list, laidOut, items, markerRows, atTheirColumn));
}
+ if (markerRows > 0) {
+ lost.add(markerRows + (markerRows == 1 ? " row the page draws as a marker alone, for a blank item, is"
+ : " rows the page draws as a marker alone, for blank items, are") + " not written");
+ }
+ return lost;
+ }
+
+ /** What the items a list writes lose (see {@link #listLost}). */
+ private List itemsLost(com.demcha.compose.document.node.ListNode list,
+ List laidOut, int items, int markerRows,
+ int atTheirColumn) {
+ List lost = new ArrayList<>();
if (list.align() != TextAlign.LEFT) {
lost.add("its items are written flush left, where the page sets them "
+ (list.align() == TextAlign.CENTER ? "centred" : "right-aligned"));
}
// Whether an item wraps, by the layout's own lines; with none of its own to read, not known.
- // An item run onto the next page is laid out as a piece on each, maybe of one line apiece,
- // and has more than one line whatever its pieces hold.
- boolean wraps = laidOut.size() > items || laidOut.stream().anyMatch(item -> item.lines().size() > 1);
- String unmeasured = layout.isEmpty() || laidOut.isEmpty() ? " — whether an item wraps is not measured" : "";
- if (list.lineSpacing() > 0 && !layout.isEmpty() && !matched && (laidOut.isEmpty() || wraps)) {
+ // The page lays out a row for each item the export writes and each marker it draws alone;
+ // more pieces than that are an item run onto the next page, maybe a line apiece, which
+ // has more than one line whatever its pieces hold.
+ boolean unread = laidOut.isEmpty();
+ boolean matched = !unread && laidOut.size() == items;
+ boolean mayWrap = unread || laidOut.size() > items + markerRows
+ || laidOut.stream().anyMatch(item -> item.lines().size() > 1);
+ String unmeasured = unread ? " — whether an item wraps is not measured" : "";
+ if (list.lineSpacing() > 0 && !layout.isEmpty() && !matched && mayWrap) {
lost.add("its lineSpacing is not written between a wrapped item's lines" + unmeasured);
}
boolean continued = !list.marker().isVisible() || !list.nestedItems().isEmpty();
- if (!list.hangingIndent() && continued && !list.continuationIndent().isEmpty()
- && (!unmeasured.isEmpty() || wraps)) {
+ if (!list.hangingIndent() && continued && !list.continuationIndent().isEmpty() && mayWrap) {
lost.add("its continuationIndent is not written before a wrapped item's lines" + unmeasured);
}
- if (offTheirColumn > 0) {
- lost.add(offTheirColumn + " of its " + items + " items stand at a stated column — 9pt in, "
+ if (atTheirColumn < items) {
+ lost.add((items - atTheirColumn) + " of its " + items + " items stand at a stated column — 9pt in, "
+ "6pt more a level — or a space past their marker or two spaces a level in, not where "
+ "the page sets them");
}
@@ -3475,15 +3485,33 @@ private List listLost(com.demcha.compose.document.node.ListNode list,
}
/**
- * Counts an item of the list being written that does not stand where the page sets it: a
- * Word list's item at the stated column of its level (see {@link #numberingFor}), and an
- * item of a {@code hangingIndent} list a space past its marker or two spaces a level in. A
- * stated column is not measured against the page's, so one the page's happens to equal counts.
+ * How many rows a list's page draws as a marker alone, which the export writes none of: a
+ * {@code hangingIndent} list keeps a blank item whose marker is visible as a row of its own
+ * ({@code ListItemNormalizer}), and the export skips a blank item. Only a flat list's: a nested
+ * blank item is written, its marker before nothing, and a list of nested items lays out none
+ * of its flat ones.
*/
- private void itemSet(boolean whereThePageSetsIt) {
- if (!whereThePageSetsIt) {
- listItemsOffTheirColumn++;
+ private static int markerOnlyRows(com.demcha.compose.document.node.ListNode list) {
+ if (!list.hangingIndent() || !list.nestedItems().isEmpty() || !list.marker().isVisible()) {
+ return 0;
}
+ int rows = 0;
+ for (String item : list.items()) {
+ if (com.demcha.compose.document.node.ListMarker.normalizeItemText(item, list.normalizeMarkers()).isBlank()) {
+ rows++;
+ }
+ }
+ return rows;
+ }
+
+ /** 1 for an item written where the page sets it, 0 for one that is not. */
+ private static int counted(boolean whereThePageSetsIt) {
+ return whereThePageSetsIt ? 1 : 0;
+ }
+
+ /** Whether a marker puts nothing before its item: no picture, no letters. */
+ private static boolean drawsNoMarker(com.demcha.compose.document.node.ListMarker marker) {
+ return !marker.isRich() && marker.prefix().isBlank();
}
/**
@@ -3491,9 +3519,9 @@ private void itemSet(boolean whereThePageSetsIt) {
* where the page sets it: without {@code hangingIndent} the page sets those very letters (its
* tree flattened into them); with it, a column past the marker, or at the edge with none.
*/
- private static boolean asItsLetters(com.demcha.compose.document.node.ListNode list, int depth,
- com.demcha.compose.document.node.ListMarker marker) {
- return !list.hangingIndent() || depth == 0 && marker.prefix().isBlank();
+ private static boolean standsAsItsLetters(com.demcha.compose.document.node.ListNode list, int depth,
+ com.demcha.compose.document.node.ListMarker marker) {
+ return !list.hangingIndent() || depth == 0 && drawsNoMarker(marker);
}
/**
@@ -3567,9 +3595,18 @@ private void spaceBeforeTheNextItem() {
anItemWasWritten = true;
}
- private void writeListItems(XWPFDocument document,
- com.demcha.compose.document.node.ListNode list,
- BigInteger numId) {
+ /**
+ * Writes a list's items, flat ones first.
+ *
+ * @return how many of them stand where the page sets them: a Word list's item at the column
+ * the layout measured (see {@link #numberingFor}), any other where its path writes it
+ * there. Each path says so for itself, so an item a new path writes counts as off its
+ * column until that path says otherwise.
+ */
+ private int writeListItems(XWPFDocument document,
+ com.demcha.compose.document.node.ListNode list,
+ BigInteger numId) {
+ int atTheirColumn = 0;
for (String item : list.items()) {
// Same normalization as the fixed-layout pipeline: strip an
// author-typed leading marker and skip items with no content.
@@ -3579,38 +3616,46 @@ private void writeListItems(XWPFDocument document,
continue;
}
java.util.OptionalDouble lineHeight = layout.lineHeight(list);
+ boolean atItsColumn;
if (list.marker().isRich()) {
// A drawn marker's pieces are runs, so the row is written the way
// any row with runs in it is; its item is still just a label.
- itemSet(writeRichListLine(document, list.textStyle(), list.marker(),
+ atItsColumn = writeRichListLine(document, list.textStyle(), list.marker(),
com.demcha.compose.document.node.ListItem.of(normalized), 0, lineHeight, layout.firstLine(list),
- layout.pathOf(list), 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);
- itemSet(measuredColumns.containsKey(numId));
+ atItsColumn = measuredColumns.containsKey(numId);
} 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.
- itemSet(writeRichListLine(document, list.textStyle(), list.marker(),
+ atItsColumn = writeRichListLine(document, list.textStyle(), list.marker(),
com.demcha.compose.document.node.ListItem.of(normalized), 0, lineHeight, layout.firstLine(list),
- layout.pathOf(list), list));
+ layout.pathOf(list), list);
} else {
writeListLine(document, list.textStyle(),
list.marker().prefix() + normalized, 0, null, lineHeight);
- itemSet(asItsLetters(list, 0, list.marker()));
+ atItsColumn = standsAsItsLetters(list, 0, list.marker());
}
+ atTheirColumn += counted(atItsColumn);
}
for (com.demcha.compose.document.node.ListItem item : list.nestedItems()) {
- writeNestedItem(document, list, item, 0, numId);
+ atTheirColumn += writeNestedItem(document, list, item, 0, numId);
}
+ return atTheirColumn;
}
- private void writeNestedItem(XWPFDocument document,
- com.demcha.compose.document.node.ListNode list,
- com.demcha.compose.document.node.ListItem item,
- int depth,
- BigInteger numId) {
+ /**
+ * Writes a nested item and the items under it.
+ *
+ * @return how many of them stand where the page sets them (see {@link #writeListItems})
+ */
+ private int writeNestedItem(XWPFDocument document,
+ com.demcha.compose.document.node.ListNode list,
+ com.demcha.compose.document.node.ListItem item,
+ int depth,
+ BigInteger numId) {
// prefix() carries its own trailing space (and is empty for
// markerless lists). Items without an explicit (or markerFor-baked)
// marker fall back to the same depth cascade the fixed-layout
@@ -3620,26 +3665,29 @@ private void writeNestedItem(XWPFDocument document,
? item.marker()
: com.demcha.compose.document.node.ListMarker.defaultForDepth(depth);
java.util.OptionalDouble lineHeight = layout.lineHeight(list);
+ boolean atItsColumn;
if (item.isRich() || marker.isRich()) {
// 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.
- itemSet(writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight,
- layout.firstLine(list), layout.pathOf(list), depth == 0 && marker.equals(list.marker()) ? list : null));
+ atItsColumn = writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight,
+ layout.firstLine(list), layout.pathOf(list), depth == 0 && marker.equals(list.marker()) ? list : null);
} else if (numId != null) {
writeListLine(document, list.textStyle(), item.label(), depth, numId, lineHeight);
- itemSet(depth == 0 && measuredColumns.containsKey(numId));
+ atItsColumn = depth == 0 && measuredColumns.containsKey(numId);
} else if (depth == 0 && marker.equals(list.marker()) && setInAColumn(list, marker)) {
- itemSet(writeRichListLine(document, list.textStyle(), marker, item, depth, lineHeight,
- layout.firstLine(list), layout.pathOf(list), list));
+ atItsColumn = 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);
- itemSet(asItsLetters(list, depth, marker));
+ atItsColumn = standsAsItsLetters(list, depth, marker);
}
+ int atTheirColumn = counted(atItsColumn);
for (com.demcha.compose.document.node.ListItem child : item.children()) {
- writeNestedItem(document, list, child, depth + 1, numId);
+ atTheirColumn += writeNestedItem(document, list, child, depth + 1, numId);
}
+ return atTheirColumn;
}
/**
@@ -3787,8 +3835,7 @@ private boolean writeRichListLine(XWPFDocument document, DocumentTextStyle style
// A nested item with no marker stands where the layout set its text, every line of it,
// rather than two spaces a level past the list's edge: Panel's project descriptions,
// set under their names, started 3.2pt short of them and wrapped back 7.8pt short.
- boolean atItsText = depth > 0 && !marker.isRich() && marker.prefix().isBlank()
- && indentAsTheItemIs(para, laidOut);
+ boolean atItsText = depth > 0 && drawsNoMarker(marker) && indentAsTheItemIs(para, laidOut);
XWPFRun leading = para.createRun();
applyStyle(leading, style);
// A marker of text the layout set in a column of its own is followed by a tab to where
@@ -3857,8 +3904,7 @@ private boolean writeRichListLine(XWPFDocument document, DocumentTextStyle style
}
makeRoomForPictures(para, pictures);
styleTheMark(para, markStyle);
- return atItsText || textTab || pictureTab
- || depth == 0 && !marker.isRich() && marker.prefix().isBlank();
+ return atItsText || textTab || pictureTab || depth == 0 && drawsNoMarker(marker);
}
/** How far past a list marker's picture its tab stop must stand for the tab to reach it, in points. */
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java
index d2a5d5973..edf01912f 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListReportTest.java
@@ -35,7 +35,7 @@ class DocxListReportTest {
private static final String LONG = "A highlight long enough to run past the end of its line, "
+ "and on past the end of the next one, in a narrow page";
private static final String TWO_LINES = "One highlight that wraps onto exactly two lines here";
- private static final String STATED =" stand at a stated column — 9pt in, 6pt more a level — or a space past "
+ private static final String STATED = " stand at a stated column — 9pt in, 6pt more a level — or a space past "
+ "their marker or two spaces a level in, not where the page sets them";
@Test
@@ -107,6 +107,26 @@ void aListThatWritesNoItemIsNotNamed() throws Exception {
.align(TextAlign.CENTER).noMarker().continuationIndent(" ")))).isEmpty();
}
+ @Test
+ void aMarkerThePageDrawsAloneForABlankItemIsNamed() throws Exception {
+ // With hangingIndent the page keeps a blank item whose marker shows as a row of its own,
+ // a marker before nothing; the export writes no paragraph for a blank item. Its row is no
+ // item run onto the next page, and the list's lineSpacing is not named for it.
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills").hangingIndent(true).lineSpacing(4)
+ .items("Java", "", "Kotlin"))))
+ .isEqualTo("written as a Word list; 1 row the page draws as a marker alone, for a blank item, "
+ + "is not written");
+ assertThat(listNote(page -> page.addList(list -> list.name("Skills").hangingIndent(true)
+ .items("", " "))))
+ .isEqualTo("writes no paragraph; 2 rows the page draws as a marker alone, for blank items, "
+ + "are not written");
+
+ assertThat(listNotes(page -> page.addList(list -> list.name("Skills").items("Java", "", "Kotlin"))))
+ .as("without hangingIndent the page skips a blank item too").isEmpty();
+ assertThat(listNotes(page -> page.addList(list -> list.name("Skills").hangingIndent(true).noMarker()
+ .items("Java", "", "Kotlin")))).as("a blank item with no marker draws nothing").isEmpty();
+ }
+
@Test
void anItemThatDoesNotStandWhereThePageSetsItIsCounted() throws Exception {
// A hangingIndent list that nests keeps the stated columns: its levels are not measured.
@@ -175,6 +195,13 @@ void withNoLayoutAListAtTheStatedColumnIsNamedAndTheSectionsNoteNamesTheSpaceBet
assertThat(report.bySubject().get("measured geometry")).extracting(DocxExportReport.Note::detail)
.containsExactly("this document could not be laid out, so line heights, the space between lines "
+ "and auto column widths are the editor's rather than the engine's");
+
+ // With no lines of its own to read, a continuationIndent is named whether or not an item wraps.
+ DocxExportReport continued = DocxExports.reportWithoutLayout(400, 600, 40, page -> page
+ .addList(list -> list.name("Notes").noMarker().continuationIndent(" ").items("Short")));
+ assertThat(continued.bySubject().get("ListNode")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("written as a paragraph per item; its continuationIndent is not written before a "
+ + "wrapped item's lines — whether an item wraps is not measured");
}
private static String listNote(Consumer content) throws Exception {
@@ -200,6 +227,7 @@ private static List listNotes(double pageHeight, Consumer