diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1dc888e..775f9ec0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ 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, 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, 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; + - 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`, 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 24c58db66..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 | +| 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 0caf321b6..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 @@ -445,6 +447,16 @@ 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 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 A container that paints — a fill, per-side borders, a uniform stroke — exports as a table of 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 3122cd30b..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 @@ -838,12 +838,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; @@ -3396,8 +3397,9 @@ 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 atTheirColumn; try { - writeListItems(document, list, numId); + atTheirColumn = writeListItems(document, list, numId); } finally { insetLeft = outerLeft; insetRight = outerRight; @@ -3409,6 +3411,117 @@ private void writeList(XWPFDocument document, listItemsMatched = previousMatched; } owePendingSpacingAfter(list.margin().bottom() + list.padding().bottom()); + 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)); + } + + /** + * 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 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 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 atTheirColumn how many of its items stand where the page sets them + */ + private ListEach 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 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. + 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