feat(api): opt-in list hanging indent with a measured marker column - #674
Merged
Conversation
A semantic list has no marker geometry: the marker is a string prefix on
the first line, and wrapped lines are indented with a run of ASCII spaces
computed as ceil(w(markerPrefix) / w(" ")). Rounding up overshoots, so a
wrapped item's continuation lines sit right of its own first line — 2.884pt
for a bullet at the default style. Changing that safely needs the current
numbers on record first, as a diff rather than as a claim.
ListLegacyGeometryFreezeTest records eighteen fixtures — bullet, dash, three
custom marker widths, markerless, wrapped, nested, padding, margin, item
spacing, a narrow container, and both pagination shapes — into one golden
dump, and pins the claims that matter individually: the marker is a first-line
prefix and is never repeated, the indent formula, the measured overshoot, wrap
width shrinking with marker width, nesting, page splitting, and padding versus
margin.
Two of those record behaviour that is true rather than right, so a later change
to either has to be deliberate: a nested item's wrapped lines get no indent at
all (the flatten path sets marker = none(), collapsing the strategy to NONE),
and nested markers below depth 0 reach the PDF as '?' because Helvetica/WinAnsi
cannot encode U+25E6 or U+25AA.
DocxListLegacyGeometryFreezeTest covers the DOCX side separately, because it
shares no geometry code with the PDF path — only two ListMarker statics. It
pins the marker as run text with no w:numPr and no w:ind, one paragraph per
item, and the depth indent as two ASCII spaces where the PDF path uses two
non-breaking ones.
The x assertions are taken from PDF glyph positions rather than fragment
geometry: legacy indentation is space glyphs inside the line string, so every
line of a LEFT-aligned list is drawn from the same lineX and fragment x cannot
show it. No layout snapshot was added — a snapshot records nodes only, with no
fragments and no line text — and no pixel baseline, since nothing frozen here
is visual-only.
Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf,
:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,
:graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1062 tests in the
testing module and the two new suites (13 + 3) green inside the reactor. Each
frozen behaviour was verified by sabotage: marker spacing, indent rounding,
nesting indent, list width, marker repetition, DOCX indent unit and pagination
were each broken in turn and every one was caught.
A list item is a paragraph whose marker is the first characters of its text. That is why there is no marker geometry to speak of: by the time anything is measured the marker is gone, and wrapped lines are indented with a run of spaces rounded up to clear it. Replacing that with real geometry needs the marker, the depth and the content to still be separate things at measure time. ListBuilder gains hangingIndent(boolean) and markerGap(double), and ListNode carries them. ListItemLayout.of(ListNode) turns that flag into one of two strategies exactly once, in prepareList, and each strategy owns its preparation from there — LEGACY_PREFIX is the previous body moved verbatim, so nothing below the switch re-reads the flag and the old path cannot acquire a branch it would have to be re-proven against. ListItemNormalizer walks the same tree as the legacy flatten, in the same order, resolving markers through the same defaultForDepth cascade so opting in never changes which glyph is shown. What differs is what it produces: ListItemSpec(depth, marker, content) instead of one concatenated label. markerGap is deliberately not on the spec — it is one value for the whole list, and a per-item copy would let rows of one list disagree. markerText() drops the separator ListMarker appends for the prefix path, since under MARKER_CONTENT the space between marker and content is markerGap and measuring both would count it twice; normalize() strips author whitespace before appending that separator, so for any non-empty marker the last character is always synthetic and dropping exactly one never touches author text. Measurement and emit still run the legacy pipeline, so an opted-in list renders exactly as before. The geometry pass replaces the body of prepareMarkerContentList rather than adding branches to the legacy one. Two behaviours differ under MARKER_CONTENT, both invisible unless opted in: a blank item contributes no row, matching the flat rule normalizeItemText already documents, where the nested legacy path renders a marker-only row instead — and children of a blank parent are always kept; and normalizeMarkers applies to nested labels, which legacy forces off only to protect a baked prefix that no longer exists here. New public API is additive. ListNode keeps its 11- and 12-argument constructors as delegating overloads, so japicmp against the pinned baseline reports two new ListBuilder methods and one new ListNode constructor and nothing else. Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf, :graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates, :graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1076 tests. ListItemLayoutModelTest adds 14 covering the decision, the normalized model, markerText, and optingInChangesNothingYet, which compares legacy against opted-in output across five shapes. The frozen legacy geometry dump does not appear in the diff at all — byte-identical rather than merely passing — and no snapshot or pixel baseline moved. Making the marker/content branch render differently failed optingInChangesNothingYet while the legacy freeze stayed green, so the two paths are provably independent. japicmp run as CI runs it (-P japicmp) and the knowledge surface --check and stability-doc gates are green.
The normalizer dropped any item whose text was blank, which quietly changed how many rows an authored list has: a bullet with no text beside it is a row the author asked for, and opting into marker geometry is not a reason to lose it. Nested lists made that visible — a parent with an empty label and children of its own lost its own marker while keeping the sub-tree. An item is kept when it draws something: text, or a visible marker, or both. Only an item with neither is omitted, which is the case the normalized-content contract already describes. Children are walked either way. Legacy is untouched and keeps the behaviour it shipped with — a blank flat item is dropped whatever its marker, a blank nested parent still renders its baked one — so the two layouts differ here on purpose, and theSameEmptyItemShapesRenderUnchangedUnderTheLegacyLayout pins the legacy side by exact line text rather than leaving it to the freeze alone. Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf, :graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates, :graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1079 tests. Four cases added: a flat marker-only item, a marker-only parent with children, a markerless empty item flat and nested, and the legacy line texts for all three. Dropping empty items unconditionally again failed exactly the two marker-only tests with the legacy freeze green, and the frozen dump stays out of the diff.
The marker/content model knew what each row was made of but not where any of it
goes. This resolves that: the marker's width is measured, in the list's own text
style, and the row's content origin follows from it.
markerX = depth == 0 ? 0 : contentX of the most recent row one level up
markerWidth = hasMarker ? measure(style, markerText()) : 0
gap = hasMarker ? node.markerGap() : 0
contentX = markerX + markerWidth + gap
contentWidth = max(1.0, availableItemWidth - contentX)
Every x is relative to the row's own start, inside the list padding, so
placement adds the item origin exactly once and the numbers hold on the second
page of a split as well as the first.
Depth is an outline rather than a fixed step: a child's marker starts where its
parent's text starts. The normalizer emits rows depth-first in source order, so
the most recent row one level up is the parent, and one array indexed by depth
resolves the whole tree in a single pass — no tree walk, no per-item search,
nothing that grows with the square of the item count.
A markerless row takes width 0 and gap 0, so it starts flush instead of at an
inset nothing explains. contentWidth floors at 1.0, mirroring the legacy wrap
clamp, so a marker wider than its container overflows rather than collapsing the
text to nothing. MarkerContentItem checks contentX against its own parts on
construction, because contentX is the number every line is placed at and a
resolver deriving it some other way would misalign a row in a way no width
assertion sees.
Wrapping and emit still run the legacy pipeline, so an opted-in list renders
exactly as before and the frozen legacy geometry is untouched. Placing the
content at contentX and the marker at markerX is the change that moves text, and
it is worth its own diff.
Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf,
:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,
:graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1092 tests.
ListMarkerGeometryTest adds 13, including the bullet's resolved numbers at the
default style (markerWidth 4.900, gap 4.0, contentX 8.900) beside the legacy
layout's 8.792 first line and 11.676 wrapped lines. Four sabotages were run and
each was caught with the legacy freeze green: a constant marker width, a gap
forced to zero, a nested indent taken from a fixed space step, and the marker
measured with the separator ListMarker appends. The first of those also exposed
a test that compared two deltas which were both zero under it, so it now asserts
the width difference is real before comparing.
…e it hangingIndent(true) now renders what it names. An item's text is wrapped inside its own contentWidth and drawn from its own contentX, and the marker is drawn beside it at markerX — so the first line, the lines it wraps onto and the lines that continue on later pages all begin at one horizontal position. The legacy layout approximates that position separately for each line with a run of spaces rounded up to clear the marker, which is why its wrapped lines sit 2.884pt past its own first line at the default style. The marker never enters the flow: it is not a prefix, not a token, and takes no part in deciding where a line breaks. Nested items keep their authored shape rather than being flattened into labels, because depth is geometry here. Emission is two paragraph fragments per row sharing one box — same localY, same height, same vertical padding — the marker at markerX with its measured width and always LEFT, the content at contentX with contentWidth and the list's own alignment. LayoutFragment.localX already existed and was 0.0 for every list fragment, so no render handler in any backend changed. The marker's single line is built from the content's own first line: its width is the width already resolved for it, and its metrics are copied from that line, so the two share a baseline by construction rather than by agreeing. Nothing is measured twice, the marker adds no height, and it is not a pagination unit of its own. Whether a marker is drawn is explicit state. PreparedListItemLayout carries startsItem, and sliceListItem sets it structurally: a slice beginning at line 0 starts the item, anything past it continues one. It cannot be inferred from a fragment index — pagination restarts those on every page, so the continuation of a split item is also index 0. A four-page item proves the difference: its later pages carry content at index 0 and no marker. The narrow-container rule is now stated rather than inherited. The engine drops text at a width of zero or less and overflows at any positive width, and the one place it already floors a width after subtracting a prefix is availableWidthForPrefix, at 1pt. That floor is now the shared ParagraphWrapping.MIN_TEXT_WIDTH and the list uses it, so a marker column wider than its row overflows and its text survives instead of disappearing. Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf, :graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates, :graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1105 tests. ListHangingIndentTest adds 12 asserting marker and content x and width, glyph x per line, the shared baseline, one marker across four pages with contentX and contentWidth fixed on each, the nested cascade 12.000 → 20.900 → 32.684 → 44.468, markerless, marker-only, alignment, padding, margin and the narrow-container contract. One visual baseline is added for what geometry cannot show; the other 98 are unchanged, compared by SHA. The legacy freeze is byte-identical and no snapshot moved. Six sabotages were run and each was caught with the legacy freeze green: wrapping at the parent width, taking the marker from fragmentIndex == 0, putting the marker off the first line's baseline, forcing the gap to zero, deriving nested indentation from spaces, and reserving a gap for a marker that is not there. The first exposed a hole in the suite — it asserted the content box was contentWidth but never that the lines inside it fit — so the tests now assert that too, and that some line uses most of the width so the bound is not vacuous.
…sible row leaving a stale level Two defects in the marker/content layout, both found by review rather than by use, and both invisible until a document does something slightly unusual. A slice measured itself with maxListLineWidth — the width of its text alone — while an unsplit list measured with markerContentMaxLineWidth, which adds the content origin. So the moment a list paginated, its box lost the marker column it still drew into: the text hung past its own right edge, and inside a shrink-to-fit or centred parent the whole list shifted relative to how it sat on the page before the break. Deeper nesting scaled the error, because contentX grows per level. A slice is the same list with fewer rows, so it now measures the same way. The normalizer dropped a row that draws nothing — no text and no marker — but went on walking its children one level deeper. That level then never recorded a content origin, so a grandchild read whichever earlier row last occupied the slot and hung under an unrelated branch. Rather than patch the read, the cause is gone: an item that draws nothing is not a level, so its children hang where it would have hung. Reachable through the public ListItem, whose per-item marker lets two branches at one depth differ. The DOCX limitation is now stated where people meet it: ListBuilder Javadoc, CHANGELOG, the lists and DOCX-export recipes, and a row in the backend capability matrix. DocxHangingIndentIsIgnoredTest holds the export identical with and without the flag across flat, multi-character marker, every gap value, long item, nested, markerless and marker-only shapes, and checks that no w:ind, w:numPr or w:tabs appears and no gap leaks in as spaces or a tab. PptxListHangingIndentTest closes a claim that was true but unproven: the docs said PDF and PPTX both honour the geometry, and no test rendered a list through PPTX at all. It now asserts a real deck — marker frame at the item start, every content frame at one x past it — so the matrix's tick means the same thing in both columns. ListMarkerContentCostTest states the cost as counts rather than timings: one measurement per distinct marker rather than one per row (the resolver memoises, which it did not before), resolving stays linear in item count, and a marked row costs a second fragment while a markerless row and the legacy layout cost one. Correcting the record from an earlier commit here, since it cannot be corrected there: 4d68d0c said japicmp reported two ListBuilder methods and one ListNode constructor "and nothing else". ListNode is a record, so the two components also add public accessors and a constant — the surface went from 2088 methods to 2093 and 233 constants to 234. The binary-compatibility claim was right; the scope sentence was not. Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf, :graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates, :graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1112 tests. Both defects were reproduced red before the fix: the split list's box measured 137.632 against content drawn to 138.000, and the orphaned grandchild resolved to the wrong depth. The frozen legacy dump stays out of the diff and no pixel baseline moved. NestedListExample gains a section contrasting the two layouts, and its committed preview is regenerated.
The hanging-indent baseline was recorded with the default text style, which resolves to Helvetica — a Standard-14 face that carries no font file into the PDF. A renderer therefore substitutes whatever the host provides, the same document rasterises differently on Windows and on Linux, and the stored image only matches on the machine that wrote it. Recorded on Windows, it failed on Linux with 3606 of 62400 pixels differing at a maximum delta of 86 — glyph outlines, not antialiasing. The fixture now uses a bundled face, which is embedded as a subset so every platform draws the same outlines. That is what every other text-heavy baseline here already does; this one was the only Standard-14 exception and the only one that moved. The test also checks its own precondition before comparing: every font in the rendered document must be embedded. A later fixture that reaches for a Standard-14 face now fails on the machine that records it, naming the font, rather than passing there and failing wherever it did not. Tests: ./mvnw clean verify -pl :graph-compose-core,:graph-compose-render-pdf, :graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates, :graph-compose-testing,:graph-compose-qa -am — BUILD SUCCESS, 1112 tests. Only this baseline's bytes changed; the other 98 are SHA-identical, and the re-render shows the same hanging alignment. Linux is the platform that exposed the original failure, so CI on the pull request is the check that matters here.
DemchaAV
added a commit
that referenced
this pull request
Sep 12, 2026
Brings the 2.4.0 engine work onto the promotion branch: native letter spacing (#676), opt-in list hanging indent (#674), the resolved timeline rail (#671-#673), the row-child margin fix, the RTL documentation corrections (#679, #680) and the templates japicmp gate (#681). Eight files conflicted. CHANGELOG.md is a union of both v2.4.0 sections, with the branch-local "### Deprecated" folded into the house heading "### Deprecations" and the sections ordered the way released entries are. The other seven are generated and were regenerated from the merged source rather than resolved by side: knowledge/api/templates.json and .md through extract-api --from-reactor, and the five cv preview PDFs by re-rendering their example classes. Five qa baselines moved, all from f75def6, which stops a row child's horizontal margin being taken off twice. Each of the four layout snapshots changes by exactly one node's own horizontal margin - HeadingRule_EXPERIENCE +9.0, EducationHeadingRule +11.285, FooterDueIcon -3.479 (a negative margin) and FooterSite +1.693 (a right margin) - with startPage and endPage unchanged, so no page ownership moved. cobalt_rota keeps its geometry snapshot and moves only in pixels, inside composed table cells, which emit fragments rather than PlacedNodes and so cannot appear in a layout snapshot; the changed region is the day-header and note cells. One of 126 pixel baselines changed, verified by checksum before and after.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A semantic list has never had marker geometry. The marker is the first characters of the item's text, and a wrapped line is indented with a run of ASCII spaces wide enough to clear it —
" ".repeat(ceil(w(markerPrefix) / w(" "))). A whole number of spaces rarely equals a bullet, so the approximation rounds up: at the default 14pt style a bullet item's text starts at x=20.792 on its first line and x=23.676 on every line after it. 2.884pt of drift, on every wrapped item, in the same direction — enough to read as ragged in a CV or a report, and impossible to fix from the outside because there is no marker to align to by the time anything is measured.Nested lists were worse. The tree is flattened into labels with the depth indent and the marker baked in, and the synthesized node carries
marker = none(), which collapses the indent strategy toNONE— so a wrapped nested item's continuation lines had no indent at all and fell back to the container's left edge, losing both the depth indent and the marker indent.What changed
ListBuilder.hangingIndent(true)gives an item a marker column and a content column;markerGap(points)sets the space between them, defaulting to 4pt. Both are off unless asked for, and this does not make hanging indent the default.ListItemLayout.of(ListNode)turns the public flag intoLEGACY_PREFIXorMARKER_CONTENTat a single point —TextFlowSupport.prepareList, which already held the only fork in the list path. Below it each strategy owns its preparation and neither re-reads the flag, so the legacy path cannot acquire a branch it would have to be re-proven against. The enum lives in the@Internaldocument.layoutpackage: japicmp enforcesdocument.node/document.dsland excludesdocument.layout, so a layout concept on the public node would freeze an internal decision into the binary-compat surface.ListItemNormalizerwalks the authored tree in the same order and through the sameListMarker.defaultForDepthcascade as the legacy flatten, but producesListItemSpec(depth, marker, content)instead of one concatenated label — because a marker that has become characters at the front of a string can no longer be measured as a marker.ListMarkerGeometry.resolvemeasuresspec.markerText()in the list's own text style; nothing is derived from character counts.markerText()drops the separatorListMarkerappends for the prefix path, since here the space between marker and content ismarkerGapand measuring both would charge for it twice. Widths are memoised per distinct marker, so a 200-row list measures its bullet once.double[]indexed by depth resolves the whole tree in a single pass — no tree walk, nothing quadratic. Measured cascade:12.000 → 20.900 → 32.684 → 44.468, content widths narrowing187.100 → 175.316 → 163.532.padding.left + markerXwith its measured width and alwaysTextAlign.LEFT; the content atpadding.left + contentXwithcontentWidthand the list's own alignment, soCENTER/RIGHTalign text inside the content column without moving the marker. Both are ordinaryParagraphFragmentPayload, andLayoutFragment.localXalready existed (0.0for every list fragment until now) — no render handler changed in any backend.ParagraphLineis built from that line: the width is the one already resolved in the item's geometry, and the metrics are copied fromvisualLines().get(0). So the shared baseline is structural rather than a match, nothing is measured twice, the marker adds no row height, and it is not a pagination unit.PreparedListItemLayout.startsItemis set structurally bysliceListItem— a slice beginning at line 0 starts the item, anything past it continues one. It cannot be inferred from a fragment index, because pagination restarts those per page: on a four-page item, pages 2–4 carry the content at fragment index 0 and must draw no marker.contentWidthfloors at the sharedParagraphWrapping.MIN_TEXT_WIDTH(1pt) — the same floor the text pipeline already applies when a prefix eats a line. Below it the engine's zero-width behaviour takes over, which renders an empty line and loses the text; that is the outcome this floor exists to avoid. The marker itself is drawn at its measured width and may overflow, unclipped.Two defects found and fixed on the branch before opening, both invisible until a document does something slightly unusual:
sliceListPreparedNodeusedmaxListLineWidth(text only) where preparation usedmarkerContentMaxLineWidth(text +contentX), so the moment a list paginated its box lost the column it still drew into — text hanging past its own right edge, and the whole list shifting inside a shrink-to-fit or centred parent. Reproduced at 137.632 against content drawn to 138.000. A slice is the same list with fewer rows, so it now measures the same way.ListItem, whose per-item marker lets two branches at one depth differ.Compatibility
prepareLegacyPrefixListis the previous body, moved verbatim.qa/src/test/resources/list-legacy/legacy-list-geometry.txtrecords 18 legacy fixtures — page counts, fragment boxes, per-line text and width, and PDF glyph x — captured at this branch point before any implementation. It is added by the first commit and touched by no later one:git log --followon it returns exactly119139fe, andgit diff origin/develop...HEAD --diff-filter=Mlists it zero times. Legacy geometry is therefore unchanged as a diff, not as a claim.list-hanging-indent-page-0.png) and modifies none.qa/src/test/resources/layout-snapshots/appears in the diff,document/list_markersanddocument/nested_list_three_levelsincluded.render-pdf/src/main,render-pptx/src/mainorrender-docx/src/mainis in the diff. The 12 production files are core plus one example.ListNodekeeps its 11- and 12-argument constructors as delegating overloads, so japicmp against the pinned baseline reports additions only.Backend behaviour
markerX/contentXPptxListHangingIndentTestasserts it against a real rendered deck rather than inferring itThe DOCX limitation, stated plainly. A list that opts in exports exactly as one that did not: one paragraph per item, the marker in the item's text, two spaces per nesting depth. Nothing is lost, but wrapped lines align the way Word aligns them and
markerGaphas no effect there.This is a decision, not an omission, and it was measured before it was made. Word positions content at absolute indents and has no relative-advance primitive —
w:indandw:tab@w:posare absolute,w:suffoffers only tab/space/nothing — so the distance beside a marker is alwaysabsoluteIndent − markerWidth, a number only Word knows. Honouring the gap would mean measuring the marker, and the semantic backend has no font runtime to measure with:MeasurementResources' only implementation isPdfMeasurementResourcesinrender-pdf, and DOCX depends on the core model and POI alone. All three candidate mechanisms were built as real DOCX, converted through Word, and measured: a plain hanging indent misaligns per marker; a hanging indent with a tab stop and Word numbering both align for•/-/>but send=>andMMMto Word's default half-inch grid while continuation lines stay at the paragraph indent; and in every variant the rendered gap is the reserved column minus the marker's width, never the configured value (a 0pt gap rendered as 5.72pt, a 4pt gap as 9.68pt). Shipping any of them would meanmarkerGap(8)rendering as something other than 8.Documented where people meet it:
ListBuilderJavadoc, CHANGELOG,docs/recipes/lists.md,docs/recipes/docx-export.md, and a row indocs/architecture/backend-capability-matrix.md.DocxHangingIndentIsIgnoredTestholds the export identical with and without the flag and asserts now:ind,w:numProrw:tabsappears and no gap leaks in as spaces or a tab — so if native DOCX geometry is built later, that test is the one that has to be deliberately rewritten rather than quietly deleted.Verification
./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa -am→ BUILD SUCCESS, 1112 tests.japicmp exactly as CI runs it (
-DskipTests -P japicmp verify -pl :graph-compose-core) → BUILD SUCCESS, additions only.extract-api.mjs --from-reactor --checkandcheck-stability-doc.mjsboth current.+75 tests:
ListLegacyGeometryFreezeTestListItemLayoutModelTestListMarkerGeometryTestmarkerX/measuredMarkerWidth/markerGap/contentX/contentWidth, including the bullet's exact numbers at the default styleListHangingIndentTestcontentX/contentWidthfixed on each, the nesting cascade, alignment, padding, margin, narrow containerListMarkerContentCostTestDocxHangingIndentIsIgnoredTestDocxListLegacyGeometryFreezeTestPptxListHangingIndentTestListHangingIndentVisualTestEach frozen behaviour was verified by sabotage, with the legacy freeze staying green under every one: wrapping at the parent width, taking the marker from
fragmentIndex == 0, putting the marker off the first line's baseline, forcing the gap to zero, deriving nested indentation from spaces, and reserving a gap for a marker that is not there.NestedListExamplegains a section contrasting the two layouts and its committed preview is regenerated.Known follow-ups (non-blocking)
markerFor()inside a nestedaddItem(label, body)scope is silently ignored — pre-existing and unrelated to this work: the child scope'smarkerOverridesare discarded, so the call compiles, reads naturally, and does nothing. Needs a semantics decision (merge relative depth, merge absolute, or throw) before a patch.Lane: shared-engine + canonical —
document.layout(strategy, normalizer, geometry, emit) with two additive methods ondocument.dsl.ListBuilderand two components ondocument.node.ListNode.