Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A list can hang its wrapped lines under its own text instead of under its marker.**
`ListBuilder.hangingIndent(true)` gives an item a marker column and a content column, so
every visual line of it starts at one horizontal position — the first line, the lines it
wraps onto, and the lines that continue on the next page. `markerGap(points)` sets the
space between the two columns and defaults to 4pt.

Without it a list renders exactly as it always has, and there is no plan to change that
default. The reason to reach for it is that the older arrangement puts the marker inside
the item's text and indents wrapped lines with a run of spaces wide enough to clear it.
A whole number of spaces rarely equals a bullet, so those lines land a little past the
first line's own text — 2.9pt at the default style, enough to read as ragged in a CV or
a report. The marker is measured now, and its width is used directly.

Measured, never assumed and never counted in characters: a bullet, a dash, an arrow and
a multi-character marker each get the column they actually need. An item with no marker
takes no marker width and no gap, so it starts flush rather than at an inset with nothing
in it. An item with a marker and no text stays a row and keeps its marker. Nested lists
indent as an outline — a child's marker starts where its parent's text starts, a
grandchild's where the child's does, and each level keeps its own content width.
`CENTER` and `RIGHT` align text inside the content column and leave the marker where it
is.

The marker is drawn on the item's first line and shares its baseline. It does not make
the row taller, does not paginate on its own, and is not drawn again when an item
continues onto later pages. Where the marker column is wider than the room available,
the marker overflows and the text is broken as narrowly as it can be — what the text
engine already does with a word too long for its line, rather than dropping the text.

**This is fixed-layout geometry: PDF and PPTX honour it, the semantic DOCX export does
not.** DOCX writes a Word paragraph per item and lets Word lay it out, so it keeps the
marker in the item's text and exports identically whether or not the setting is on —
same paragraphs, same text, same nesting, all content intact. Word places content at
absolute indents and has no way to be told "start the text one marker width plus a gap
from here", so honouring this there would mean measuring the marker, which the semantic
backend cannot do without a font runtime it deliberately does not depend on. Approximating
it was measured and rejected: a reserved-column approximation renders a gap that is not
the one you asked for, and misaligns outright for a marker wider than the column.

- **A timeline's rail is one line, drawn from where its markers landed.**
It was a left border repeated on every entry section, which is why it sat at the entry's
edge whatever the markers did, could not stop short of them, and had no way to be
Expand Down
Binary file modified assets/readme/examples/nested-list-showcase.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public final class ListBuilder {
private boolean normalizeMarkers = true;
private DocumentInsets padding = DocumentInsets.zero();
private DocumentInsets margin = DocumentInsets.zero();
private boolean hangingIndent = false;
private double markerGap = ListNode.DEFAULT_MARKER_GAP;

/**
* Creates a list builder.
Expand Down Expand Up @@ -266,6 +268,79 @@ public ListBuilder continuationIndent(String continuationIndent) {
return this;
}

/**
* Lays the list out as a marker column and a content column, so every
* visual line of an item — the lines it wraps onto, and the lines that
* continue on the next page — starts at the same horizontal position, one
* marker width plus {@link #markerGap(double)} in from the item's own start.
*
* <p>Off by default, and this is not a step towards making it the default.
* Unset, a list renders exactly as it did in v1.4 through 2.3: the marker is
* a text prefix on the first line and wrapped lines carry a run of spaces
* measured to clear it, which lands them a fraction of a space width off the
* first line's text. Setting this replaces that approximation with
* geometry.</p>
*
* <p>Applies to nested lists too — depth, marker and content stay apart
* instead of being concatenated into one label, so each level resolves its
* own content origin. Two consequences of that are worth knowing. Because a
* nested label is no longer carrying a baked-in marker that must survive,
* {@link #normalizeMarkers(boolean)} applies to it the way it already
* applies to a flat item, so an author-typed {@code "- "} is stripped from a
* child label as well. And an item that draws nothing at all — no text and
* no marker — contributes no row, so its children hang at the level it would
* have occupied rather than one deeper.</p>
*
* <p><b>Fixed-layout only.</b> This is geometry, and it applies to the
* backends that do their own layout — PDF and PPTX. The semantic DOCX
* export writes a Word paragraph per item and lets Word lay it out, so it
* keeps the marker in the item's text and is unchanged by this setting: the
* same paragraphs, the same text, the same nesting. Word positions content
* at absolute indents and has no way to be told "start the text one marker
* width plus a gap from here", so reproducing this geometry there would mean
* measuring the marker — which the semantic backend deliberately cannot do,
* since it depends on neither a font runtime nor a layout pass. A document
* exported both ways is therefore identical in content and nesting, and
* differs in how its wrapped lines line up.</p>
*
* @param hangingIndent whether items use marker/content geometry
* @return this builder
* @since 2.4.0
*/
public ListBuilder hangingIndent(boolean hangingIndent) {
this.hangingIndent = hangingIndent;
return this;
}

/**
* Sets the space between an item's marker and its content, in points.
*
* <p>Observed only when {@link #hangingIndent(boolean)} is set. The legacy
* layout's gap is whatever the marker's own trailing separator measures, and
* this value does not change it.</p>
*
* <p>Real geometry, never spaces. A markerless item takes no gap at all,
* rather than an unexplained inset.</p>
*
* <p><b>Fixed-layout only</b>, for the reason given on
* {@link #hangingIndent(boolean)}: the semantic DOCX export does not lay text
* out and cannot place content a measured distance after a marker, so it
* ignores this value rather than approximating it with something that would
* render as a different number than the one asked for.</p>
*
* @param markerGap gap in points; {@code 0} is allowed
* @return this builder
* @throws IllegalArgumentException when {@code markerGap} is negative, NaN or infinite
* @since 2.4.0
*/
public ListBuilder markerGap(double markerGap) {
if (markerGap < 0 || Double.isNaN(markerGap) || Double.isInfinite(markerGap)) {
throw new IllegalArgumentException("markerGap must be finite and non-negative: " + markerGap);
}
this.markerGap = markerGap;
return this;
}

/**
* Sets whether leading raw markers should be stripped from input items.
*
Expand Down Expand Up @@ -360,7 +435,9 @@ public ListNode build() {
continuationIndent,
normalizeMarkers,
padding,
margin);
margin,
hangingIndent,
markerGap);
}
// Nested path. Source order across flat and nested entries is
// preserved because both flow through the unified `items` list.
Expand All @@ -377,7 +454,9 @@ public ListNode build() {
continuationIndent,
normalizeMarkers,
padding,
margin);
margin,
hangingIndent,
markerGap);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.demcha.compose.document.layout;

import com.demcha.compose.document.node.ListNode;

/**
* How one {@link ListNode}'s items are laid out — the single internal strategy
* that the public {@code hangingIndent} flag normalizes into.
*
* <p>The point of naming the strategy is that the decision is made <b>once</b>,
* in {@link TextFlowSupport#prepareList}, instead of being re-read as a boolean
* at every step of measure, split and emit. Preparation then branches on the
* strategy and each branch owns its own geometry end to end.</p>
*
* <p>This is a layout concept and stays inside the {@code @Internal}
* {@code document.layout} package: the public authoring surface is
* {@code ListBuilder.hangingIndent(boolean)} and
* {@code ListBuilder.markerGap(double)}, and nothing outside the compiler needs
* to name the strategy.</p>
*
* @author Artem Demchyshyn
* @since 2.4.0
*/
public enum ListItemLayout {

/**
* The v1.4-through-2.3 behaviour, unchanged. The marker is a text prefix on
* the item's first visual line, wrapped lines are indented with a run of
* ASCII spaces wide enough to clear it, and a nested list is flattened into
* a flat one with the depth indent and the resolved marker baked into each
* label. There is no marker geometry: the marker is content.
*
* <p>{@code markerGap} is not observed in this mode — the gap is whatever
* the marker's own trailing separator measures.</p>
*/
LEGACY_PREFIX,

/**
* Opt-in marker/content geometry. Depth, marker and content stay separate
* all the way through preparation instead of being concatenated into one
* string, so the marker can be measured on its own and every visual line of
* an item can share one content origin.
*
* <p>{@code markerGap} is real geometry in this mode, in points.</p>
*/
MARKER_CONTENT;

/**
* Resolves the strategy for a list. This is the one place the public flag
* turns into an internal decision.
*
* @param node list node carrying the authored intent
* @return the strategy its items are prepared with
*/
public static ListItemLayout of(ListNode node) {
return node != null && node.hangingIndent() ? MARKER_CONTENT : LEGACY_PREFIX;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.demcha.compose.document.layout;

import com.demcha.compose.document.layout.payloads.ListItemSpec;
import com.demcha.compose.document.node.ListItem;
import com.demcha.compose.document.node.ListMarker;
import com.demcha.compose.document.node.ListNode;

import java.util.ArrayList;
import java.util.List;

/**
* Flattens an authored list into {@link ListItemSpec}s for
* {@link ListItemLayout#MARKER_CONTENT}.
*
* <p>This is the structural counterpart to the legacy flatten in
* {@code TextFlowSupport}. Both walk the same tree in the same depth-first
* order and produce one entry per rendered row; the difference is what they
* produce. The legacy walk concatenates — depth becomes non-breaking spaces,
* the marker becomes a text prefix, and the row is a single string. This one
* keeps the three apart, because a marker that has become characters at the
* front of a string can no longer be measured as a marker.</p>
*
* <p>Marker resolution is identical to the legacy walk and deliberately shares
* {@link ListMarker#defaultForDepth(int)} with it, so a list does not change
* which glyph it shows when it opts in — only where that glyph sits.</p>
*
* @author Artem Demchyshyn
* @since 2.4.0
*/
final class ListItemNormalizer {

private ListItemNormalizer() {
}

/**
* Normalizes every rendered row of a list, flat or nested, in source order.
*
* @param node authored list node
* @return one spec per rendered row; empty when the list renders nothing
*/
static List<ListItemSpec> normalize(ListNode node) {
List<ListItemSpec> out = new ArrayList<>();
if (node.nestedItems().isEmpty()) {
for (String item : node.items()) {
String content = ListMarker.normalizeItemText(item, node.normalizeMarkers());
if (rendersSomething(node.marker(), content)) {
out.add(new ListItemSpec(0, node.marker(), content));
}
}
return List.copyOf(out);
}
normalizeNested(node, node.nestedItems(), 0, out);
return List.copyOf(out);
}

/**
* Whether an item puts anything on the page — the rule that decides which
* authored items survive normalization.
*
* <p>Authored cardinality is preserved: an item whose text is empty but
* whose marker is visible is a <b>marker-only row</b> and is kept, because
* the author asked for that marker and opting into marker geometry is not a
* reason to lose it. Only an item with neither text nor marker draws
* nothing, and that is the case the existing normalized-content contract
* already omits.</p>
*/
private static boolean rendersSomething(ListMarker marker, String content) {
return !content.isBlank() || marker.isVisible();
}

private static void normalizeNested(ListNode node,
List<ListItem> items,
int depth,
List<ListItemSpec> out) {
for (ListItem item : items) {
ListMarker marker = item.marker() != null
? item.marker()
: ListMarker.defaultForDepth(depth);
String content = ListMarker.normalizeItemText(item.label(), node.normalizeMarkers());
boolean rendered = rendersSomething(marker, content);
if (rendered) {
out.add(new ListItemSpec(depth, marker, content));
}
// Children are walked either way: an item that draws nothing is a
// reason to skip that one row, never a reason to lose the sub-tree
// hanging off it. They hang at the level the row itself would have
// occupied, not one deeper — there is no visible row to hang under,
// and indenting them past a level that was never drawn would leave
// them looking inset from nothing.
if (!item.children().isEmpty()) {
normalizeNested(node, item.children(), rendered ? depth + 1 : depth, out);
}
}
}
}
Loading
Loading