From 119139fe2c1fa35237ae38d5a2c7767ba2cd6291 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 10 Sep 2026 11:26:13 +0100 Subject: [PATCH 1/7] test(list): freeze the legacy list geometry before it changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../api/ListLegacyGeometryFreezeTest.java | 564 ++++++++++++++++++ .../list-legacy/legacy-list-geometry.txt | 238 ++++++++ .../DocxListLegacyGeometryFreezeTest.java | 132 ++++ 3 files changed, 934 insertions(+) create mode 100644 qa/src/test/java/com/demcha/compose/document/api/ListLegacyGeometryFreezeTest.java create mode 100644 qa/src/test/resources/list-legacy/legacy-list-geometry.txt create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java diff --git a/qa/src/test/java/com/demcha/compose/document/api/ListLegacyGeometryFreezeTest.java b/qa/src/test/java/com/demcha/compose/document/api/ListLegacyGeometryFreezeTest.java new file mode 100644 index 000000000..fac1f7b94 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/ListLegacyGeometryFreezeTest.java @@ -0,0 +1,564 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Freezes the geometry of the legacy (prefix-based) semantic list before any + * hanging-indent or marker-gap work exists, so that "unchanged" can later be + * shown as a diff instead of asserted as a claim. + * + *

Recorded on the exact tip of {@code origin/develop} this branch was cut + * from, so the golden dump is develop's behaviour: a later branch that + * re-runs {@link #legacyGeometryStillMatchesTheRecordedDump()} is doing a + * cross-branch comparison without needing a second checkout.

+ * + *

Why these instruments

+ * + */ +class ListLegacyGeometryFreezeTest { + + private static final String DUMP_RESOURCE = "list-legacy/legacy-list-geometry.txt"; + private static final String UPDATE_PROPERTY = "graphcompose.updateSnapshots"; + + /** Every frozen fixture, in dump order. */ + private static Map fixtures() { + Map all = new LinkedHashMap<>(); + all.put("bullet-short", new Fixture(320, 240, 12, + l -> l.name("L").bullet().items("Java", "SQL"))); + all.put("dash-short", new Fixture(320, 240, 12, + l -> l.name("L").dash().items("Java", "SQL"))); + all.put("custom-arrow", new Fixture(320, 240, 12, + l -> l.name("L").marker(">").items("Java"))); + all.put("custom-wide", new Fixture(320, 240, 12, + l -> l.name("L").marker("=>").items("Java"))); + all.put("custom-mmm", new Fixture(320, 240, 12, + l -> l.name("L").marker("MMM").items("Java"))); + all.put("markerless", new Fixture(320, 240, 12, + l -> l.name("L").noMarker().items("Java"))); + all.put("markerless-continuation", new Fixture(165, 240, 12, + l -> l.name("L").noMarker().continuationIndent(" ") + .items("Long item text should wrap across several visual lines here."))); + all.put("bullet-wrapped", new Fixture(165, 240, 12, + l -> l.name("L").bullet().items( + "Long item text should wrap across several visual lines while keeping one bullet."))); + all.put("dash-wrapped", new Fixture(165, 240, 12, + l -> l.name("L").dash().items( + "Long item text should wrap across several visual lines while keeping one dash."))); + all.put("wide-marker-wrapped", new Fixture(165, 240, 12, + l -> l.name("L").marker("MMM").items( + "Long item text should wrap across several visual lines while keeping one marker."))); + all.put("padding", new Fixture(320, 240, 12, + l -> l.name("L").bullet().padding(3, 5, 7, 11).items("Java", "SQL"))); + all.put("margin", new Fixture(320, 240, 12, + l -> l.name("L").bullet().margin(3, 5, 7, 11).items("Java", "SQL"))); + all.put("narrow", new Fixture(90, 240, 6, + l -> l.name("L").bullet().items("Wrapping in a very narrow container indeed"))); + all.put("item-spacing", new Fixture(320, 240, 12, + l -> l.name("L").bullet().itemSpacing(6).items("Java", "SQL"))); + all.put("nested", new Fixture(320, 240, 12, + l -> l.name("L").bullet() + .addItem("Top one", c -> c + .addItem("Child one") + .addItem("Child two", g -> g.addItem("Grandchild"))) + .addItem("Top two"))); + all.put("nested-wrapped", new Fixture(165, 240, 12, + l -> l.name("L").bullet() + .addItem("Top", c -> c + .addItem("Child item text that should wrap across several visual lines here.")))); + all.put("pagination-split-item", new Fixture(165, 90, 12, + l -> l.name("L").bullet().items( + "Long item text should wrap across many visual lines so that it has to cross a page " + + "boundary and continue on the following page with its continuation indent intact."))); + all.put("pagination-whole-items", new Fixture(165, 90, 12, + l -> l.name("L").bullet() + .items("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"))); + return all; + } + + // ------------------------------------------------------------------ + // The broad, diffable freeze + // ------------------------------------------------------------------ + + @Test + void legacyGeometryStillMatchesTheRecordedDump() throws Exception { + StringBuilder actual = new StringBuilder(); + for (Map.Entry entry : fixtures().entrySet()) { + actual.append(dump(entry.getKey(), entry.getValue())); + } + + Path resource = Path.of("src", "test", "resources", DUMP_RESOURCE); + if (Boolean.getBoolean(UPDATE_PROPERTY)) { + Files.createDirectories(resource.getParent()); + Files.writeString(resource, actual.toString(), StandardCharsets.UTF_8); + return; + } + + assertThat(resource) + .as("legacy list geometry dump is recorded; re-record with -D" + UPDATE_PROPERTY + "=true") + .exists(); + String expected = Files.readString(resource, StandardCharsets.UTF_8).replace("\r\n", "\n"); + assertThat(actual.toString()) + .as("legacy list geometry changed; every line here is a compatibility contract") + .isEqualTo(expected); + } + + // ------------------------------------------------------------------ + // Named claims — one per class of behaviour the sabotage must break + // ------------------------------------------------------------------ + + @Test + void markerLivesInTheFirstLineTextAndIsNeverRepeatedOnAContinuationLine() throws Exception { + List payloads = payloads(fixtures().get("bullet-wrapped")); + List lines = lineTexts(payloads.get(0)); + + assertThat(lines.get(0)).startsWith("• "); + assertThat(lines).hasSizeGreaterThan(1); + assertThat(lines.subList(1, lines.size())) + .as("the marker is a first-line prefix, not a per-line decoration") + .noneMatch(line -> line.contains("•")); + } + + @Test + void continuationIndentIsCeilOfTheMarkerWidthMeasuredInSpaces() throws Exception { + // The legacy formula, stated as the equation it is: + // spaces = ceil(width(markerPrefix) / width(" ")) + // Helvetica/1000: bullet 350, dash 333, 'M' 833, space 278. + assertLeadingSpaces("bullet-wrapped", 3); // ceil((350+278)/278) = ceil(2.259) + assertLeadingSpaces("dash-wrapped", 3); // ceil((333+278)/278) = ceil(2.198) + assertLeadingSpaces("wide-marker-wrapped", 10); // ceil((3*833+278)/278) = ceil(9.989) + } + + @Test + void continuationLinesSitRightOfTheFirstLineContentBecauseTheIndentIsRoundedUp() throws Exception { + // This is the defect the opt-in geometry is meant to remove. Freezing it + // keeps the "before" number honest instead of remembered. + // + // Compared like with like: where the *text* starts on line 0 (past the + // marker) against where it starts on the wrapped lines (past the indent). + // Rounding the indent up to a whole number of spaces overshoots. + Rendered rendered = render(fixtures().get("bullet-wrapped")); + List textX = rendered.textStartX(); + + assertThat(textX).hasSizeGreaterThan(1); + double firstLineText = textX.get(0); + for (int i = 1; i < textX.size(); i++) { + assertThat(textX.get(i)) + .as("continuation line %d overshoots the first line's text start", i) + .isGreaterThan(firstLineText); + } + // All continuation lines agree with each other — the overshoot is constant. + assertThat(textX.subList(1, textX.size())) + .allMatch(x -> Math.abs(x - textX.get(1)) < 0.001); + assertThat(textX.get(1) - firstLineText) + .as("measured legacy overshoot for a bullet at the default style") + .isCloseTo(2.884, within(0.05)); + } + + @Test + void wrapWidthIsReducedByThePrefixSoAWiderMarkerWrapsSooner() throws Exception { + List bullet = lineTexts(payloads(fixtures().get("bullet-wrapped")).get(0)); + List wide = lineTexts(payloads(fixtures().get("wide-marker-wrapped")).get(0)); + + assertThat(wide.size()) + .as("a 10-space indent leaves less room per line than a 3-space one") + .isGreaterThan(bullet.size()); + } + + @Test + void nestedDepthIsTwoNonBreakingSpacesPerLevelAndNestedItemsLoseTheContinuationIndent() throws Exception { + List nested = payloads(fixtures().get("nested")); + assertThat(nested).as("depth-first flatten, one fragment per item").hasSize(5); + + assertThat(lineTexts(nested.get(0)).get(0)).isEqualTo("• Top one"); + assertThat(lineTexts(nested.get(1)).get(0)).isEqualTo("  ◦ Child one"); + assertThat(lineTexts(nested.get(2)).get(0)).isEqualTo("  ◦ Child two"); + assertThat(lineTexts(nested.get(3)).get(0)).isEqualTo("    ▪ Grandchild"); + assertThat(lineTexts(nested.get(4)).get(0)).isEqualTo("• Top two"); + + // The flatten path sets marker = none(), so a nested item's wrapped lines + // get no indent at all: they fall back to the container's left edge, + // losing both the depth indent and the marker indent. + List wrapped = lineTexts(payloads(fixtures().get("nested-wrapped")).get(1)); + assertThat(wrapped).hasSizeGreaterThan(1); + assertThat(wrapped.get(0)).startsWith("  ◦ "); + assertThat(wrapped.subList(1, wrapped.size())) + .as("nested continuation lines are flush left today") + .allMatch(line -> !line.isEmpty() && !Character.isWhitespace(line.charAt(0))); + } + + @Test + void nestedMarkersBelowDepthZeroReachThePdfAsQuestionMarksWithTheDefaultFont() throws Exception { + // Recorded because it is true today, not because it is right: the layout + // line carries the real codepoints, but the default Helvetica/WinAnsi font + // cannot encode U+25E6 or U+25AA, so the render substitutes '?'. The NBSP + // depth indent degrades to a plain space the same way. Any change here is + // a deliberate fix, and the freeze should make someone say so. + Rendered rendered = render(fixtures().get("nested")); + + List layoutLines = payloads(fixtures().get("nested")).stream() + .map(p -> p.lines().get(0).text()) + .toList(); + assertThat(layoutLines.get(1)).isEqualTo("  ◦ Child one"); + assertThat(layoutLines.get(3)).isEqualTo("    ▪ Grandchild"); + + List pdfLines = rendered.rows().stream().map(GlyphRow::text).toList(); + assertThat(pdfLines.get(0)).as("U+2022 is in WinAnsi and survives").startsWith("• "); + assertThat(pdfLines.get(1)).isEqualTo(" ? Child one"); + assertThat(pdfLines.get(3)).isEqualTo(" ? Grandchild"); + } + + @Test + void aLongItemSplitsAcrossPagesKeepingTheIndentAndNotRepeatingTheMarker() throws Exception { + Rendered rendered = render(fixtures().get("pagination-split-item")); + assertThat(rendered.graph().totalPages()).isEqualTo(2); + + List page0 = payloadsOnPage(rendered.graph(), 0); + List page1 = payloadsOnPage(rendered.graph(), 1); + assertThat(page0).hasSize(1); + assertThat(page1).hasSize(1); + + assertThat(lineTexts(page0.get(0)).get(0)).startsWith("• "); + assertThat(lineTexts(page1.get(0))) + .as("the continuation page keeps the indent and gets no second marker") + .allMatch(line -> line.startsWith(" ") && !line.contains("•")); + } + + @Test + void wholeItemsPaginateOnItemBoundaries() throws Exception { + Rendered rendered = render(fixtures().get("pagination-whole-items")); + assertThat(rendered.graph().totalPages()).isEqualTo(2); + assertThat(payloadsOnPage(rendered.graph(), 0)).hasSize(5); + assertThat(payloadsOnPage(rendered.graph(), 1)).hasSize(3); + } + + @Test + void paddingMovesContentInsideTheBoxAndMarginMovesTheBox() throws Exception { + Rendered padded = render(fixtures().get("padding")); + Rendered margined = render(fixtures().get("margin")); + + PlacedFragment paddedFragment = paragraphFragments(padded.graph()).get(0); + PlacedFragment marginedFragment = paragraphFragments(margined.graph()).get(0); + + assertThat(paddedFragment.x()).as("padding leaves the fragment box where it was").isEqualTo(12.0); + assertThat(payload(paddedFragment).padding().left()).isEqualTo(11.0); + assertThat(marginedFragment.x()).as("margin moves the fragment box").isEqualTo(23.0); + assertThat(payload(marginedFragment).padding().left()).isEqualTo(0.0); + + // Both land the glyphs in the same place — by different routes. + assertThat(padded.firstVisibleGlyphX().get(0)).isEqualTo(23.0, within(0.001)); + assertThat(margined.firstVisibleGlyphX().get(0)).isEqualTo(23.0, within(0.001)); + } + + @Test + void aNarrowContainerWrapsWithoutOverflowingItsInnerWidth() throws Exception { + Rendered rendered = render(fixtures().get("narrow")); + PlacedNode list = listNode(rendered.graph()); + double innerWidth = 90.0 - 2 * 6.0; + + assertThat(list.placementWidth()).isLessThanOrEqualTo(innerWidth); + for (ParagraphLine line : payloads(fixtures().get("narrow")).get(0).lines()) { + assertThat(line.width()) + .as("no measured line overflows the container") + .isLessThanOrEqualTo(innerWidth + 0.001); + } + } + + @Test + void aMarkerlessListIsFlushLeftUnlessContinuationIndentIsSet() throws Exception { + assertThat(lineTexts(payloads(fixtures().get("markerless")).get(0)).get(0)).isEqualTo("Java"); + + List withIndent = lineTexts(payloads(fixtures().get("markerless-continuation")).get(0)); + assertThat(withIndent.get(0)).doesNotStartWith(" "); + assertThat(withIndent.subList(1, withIndent.size())) + .as("continuationIndent is passed through verbatim, from the second line") + .allMatch(line -> line.startsWith(" ") && !line.startsWith(" ")); + } + + @Test + void markerGlyphWidthAloneMovesTheContentStart() throws Exception { + // Marker width is measured, so a wider glyph pushes content further right + // even before any explicit gap exists. + Rendered arrow = render(fixtures().get("custom-arrow")); + Rendered wide = render(fixtures().get("custom-wide")); + Rendered mmm = render(fixtures().get("custom-mmm")); + + // Every marker is drawn from the same left edge, because the marker is + // content, not geometry — there is no marker column to sit in. + assertThat(arrow.firstVisibleGlyphX().get(0)).isEqualTo(12.0, within(0.001)); + assertThat(wide.firstVisibleGlyphX().get(0)).isEqualTo(12.0, within(0.001)); + assertThat(mmm.firstVisibleGlyphX().get(0)).isEqualTo(12.0, within(0.001)); + + // ...and the text start is pushed right by exactly the marker's own width. + // Measured on the two non-alphanumeric markers, where "first letter" is + // unambiguously the item text: ">" 24.068, "=>" 32.244. + assertThat(arrow.textStartX().get(0)).isEqualTo(24.068, within(0.001)); + assertThat(wide.textStartX().get(0)).isEqualTo(32.244, within(0.001)); + + // The item's measured width is what grows with the marker. + assertThat(listNode(render(fixtures().get("custom-wide")).graph()).placementWidth()) + .isGreaterThan(listNode(render(fixtures().get("custom-arrow")).graph()).placementWidth()); + assertThat(listNode(render(fixtures().get("custom-mmm")).graph()).placementWidth()) + .isGreaterThan(listNode(render(fixtures().get("custom-wide")).graph()).placementWidth()); + } + + // ------------------------------------------------------------------ + // Fixture plumbing + // ------------------------------------------------------------------ + + private record Fixture(double pageWidth, double pageHeight, double margin, Consumer spec) { + } + + private record Rendered(LayoutGraph graph, List rows) { + /** x of the first non-blank glyph on each line — the marker on a marked line. */ + List firstVisibleGlyphX() { + return rows.stream().map(GlyphRow::visibleX).toList(); + } + + /** + * x of the first letter-or-digit glyph on each line — where the item's + * text actually starts, past any marker and any indent. This is + * the number the opt-in geometry is meant to make equal across the lines + * of one item, so it is the one the freeze has to pin. + * + *

Limit: the rule cannot tell a marker from text when the marker + * is itself alphanumeric (the {@code MMM} fixtures), where it returns the + * marker's own x. The dump records it anyway — with {@code markerX} and the + * line text beside it, so the reading is reconstructible — but claims that + * depend on the text start use a non-alphanumeric marker.

+ */ + List textStartX() { + return rows.stream().map(GlyphRow::textX).toList(); + } + } + + private record GlyphRow(int page, double x, double visibleX, double textX, String text) { + } + + private static Rendered render(Fixture fixture) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(fixture.pageWidth(), fixture.pageHeight()) + .margin(DocumentInsets.of(fixture.margin())) + .create()) { + session.pageFlow().name("Root").addList(fixture.spec()).build(); + return new Rendered(session.layoutGraph(), glyphRows(session.toPdfBytes())); + } + } + + private static List payloads(Fixture fixture) throws Exception { + return paragraphFragments(render(fixture).graph()).stream() + .map(ListLegacyGeometryFreezeTest::payload) + .toList(); + } + + private static void assertLeadingSpaces(String fixtureName, int expected) throws Exception { + List lines = lineTexts(payloads(fixtures().get(fixtureName)).get(0)); + assertThat(lines).hasSizeGreaterThan(1); + for (int i = 1; i < lines.size(); i++) { + assertThat(leadingSpaces(lines.get(i))) + .as("%s continuation line %d", fixtureName, i) + .isEqualTo(expected); + } + } + + private static List paragraphFragments(LayoutGraph graph) { + return graph.fragments().stream() + .filter(f -> f.payload() instanceof ParagraphFragmentPayload) + .toList(); + } + + private static List payloadsOnPage(LayoutGraph graph, int page) { + return paragraphFragments(graph).stream() + .filter(f -> f.pageIndex() == page) + .map(ListLegacyGeometryFreezeTest::payload) + .toList(); + } + + private static ParagraphFragmentPayload payload(PlacedFragment fragment) { + return (ParagraphFragmentPayload) fragment.payload(); + } + + private static PlacedNode listNode(LayoutGraph graph) { + return graph.nodes().stream() + .filter(n -> "ListNode".equals(n.nodeKind())) + .findFirst() + .orElseThrow(); + } + + private static List lineTexts(ParagraphFragmentPayload payload) { + return payload.lines().stream().map(ParagraphLine::text).toList(); + } + + private static int leadingSpaces(String text) { + int n = 0; + while (n < text.length() && text.charAt(n) == ' ') { + n++; + } + return n; + } + + // ------------------------------------------------------------------ + // Dump rendering + // ------------------------------------------------------------------ + + private static String dump(String label, Fixture fixture) throws Exception { + Rendered rendered = render(fixture); + LayoutGraph graph = rendered.graph(); + StringBuilder out = new StringBuilder(); + out.append("### ").append(label) + .append(" page=").append(fmt(fixture.pageWidth())).append('x').append(fmt(fixture.pageHeight())) + .append(" margin=").append(fmt(fixture.margin())) + .append(" pages=").append(graph.totalPages()).append('\n'); + + for (PlacedNode node : graph.nodes()) { + out.append(" node ").append(node.path()) + .append(" kind=").append(node.nodeKind()) + .append(" box=").append(box(node.placementX(), node.placementY(), + node.placementWidth(), node.placementHeight())) + .append(" pages=").append(node.startPage()).append("..").append(node.endPage()) + .append(" pad=").append(insets(node.padding().top(), node.padding().right(), + node.padding().bottom(), node.padding().left())) + .append(" margin=").append(insets(node.margin().top(), node.margin().right(), + node.margin().bottom(), node.margin().left())) + .append('\n'); + } + + for (PlacedFragment fragment : paragraphFragments(graph)) { + ParagraphFragmentPayload payload = payload(fragment); + out.append(" frag ").append(fragment.path()) + .append(" idx=").append(fragment.fragmentIndex()) + .append(" page=").append(fragment.pageIndex()) + .append(" box=").append(box(fragment.x(), fragment.y(), fragment.width(), fragment.height())) + .append(" pad=").append(insets(payload.padding().top(), payload.padding().right(), + payload.padding().bottom(), payload.padding().left())) + .append('\n'); + List lines = payload.lines(); + for (int i = 0; i < lines.size(); i++) { + ParagraphLine line = lines.get(i); + out.append(" line[").append(i).append(']') + .append(" w=").append(fmt(line.width())) + .append(" lead=").append(leadingWhitespace(line.text())) + .append(" text=").append(escape(line.text())) + .append('\n'); + } + } + + for (GlyphRow row : rendered.rows()) { + out.append(" pdf page=").append(row.page()) + .append(" x=").append(fmt(row.x())) + .append(" markerX=").append(fmt(row.visibleX())) + .append(" textX=").append(fmt(row.textX())) + .append(" text=").append(escape(row.text())) + .append('\n'); + } + return out.append('\n').toString(); + } + + private static String box(double x, double y, double w, double h) { + return "[" + fmt(x) + " " + fmt(y) + " " + fmt(w) + " " + fmt(h) + "]"; + } + + private static String insets(double top, double right, double bottom, double left) { + return "[" + fmt(top) + " " + fmt(right) + " " + fmt(bottom) + " " + fmt(left) + "]"; + } + + private static String fmt(double value) { + return String.format(Locale.ROOT, "%.3f", value); + } + + private static int leadingWhitespace(String text) { + int n = 0; + while (n < text.length() && Character.isWhitespace(text.charAt(n))) { + n++; + } + return n; + } + + /** ASCII-only rendering so the dump survives any console or file encoding. */ + private static String escape(String text) { + StringBuilder sb = new StringBuilder("\""); + for (char c : text.toCharArray()) { + if (c == ' ') { + sb.append('_'); + } else if (c >= 0x20 && c < 0x7F && c != '"' && c != '\\') { + sb.append(c); + } else { + sb.append(String.format(Locale.ROOT, "\\u%04X", (int) c)); + } + } + return sb.append('"').toString(); + } + + private static List glyphRows(byte[] pdf) throws IOException { + List rows = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(pdf)) { + PDFTextStripper stripper = new PDFTextStripper() { + @Override + protected void writeString(String text, List positions) { + if (positions.isEmpty()) { + return; + } + double visibleX = positions.stream() + .filter(p -> !p.getUnicode().isBlank()) + .mapToDouble(TextPosition::getXDirAdj) + .findFirst() + .orElse(positions.get(0).getXDirAdj()); + double textX = positions.stream() + .filter(p -> !p.getUnicode().isEmpty() + && Character.isLetterOrDigit(p.getUnicode().charAt(0))) + .mapToDouble(TextPosition::getXDirAdj) + .findFirst() + .orElse(visibleX); + rows.add(new GlyphRow(getCurrentPageNo() - 1, + positions.get(0).getXDirAdj(), visibleX, textX, text)); + } + }; + stripper.setSortByPosition(true); + stripper.getText(document); + } + return rows; + } +} diff --git a/qa/src/test/resources/list-legacy/legacy-list-geometry.txt b/qa/src/test/resources/list-legacy/legacy-list-geometry.txt new file mode 100644 index 000000000..04a0d3494 --- /dev/null +++ b/qa/src/test/resources/list-legacy/legacy-list-geometry.txt @@ -0,0 +1,238 @@ +### bullet-short page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 202.100 38.360 25.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 202.100 38.360 25.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 38.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=38.360 lead=0 text="\u2022_Java" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 202.100 38.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=36.806 lead=0 text="\u2022_SQL" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Java" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_SQL" + +### dash-short page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 202.100 38.122 25.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 202.100 38.122 25.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 38.122 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=38.122 lead=0 text="-_Java" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 202.100 38.122 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=36.568 lead=0 text="-_SQL" + pdf page=0 x=12.000 markerX=12.000 textX=20.554 text="-_Java" + pdf page=0 x=12.000 markerX=12.000 textX=20.554 text="-_SQL" + +### custom-arrow page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 215.050 41.636 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 215.050 41.636 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 41.636 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=41.636 lead=0 text=">_Java" + pdf page=0 x=12.000 markerX=12.000 textX=24.068 text=">_Java" + +### custom-wide page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 215.050 49.812 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 215.050 49.812 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 49.812 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=49.812 lead=0 text="=>_Java" + pdf page=0 x=12.000 markerX=12.000 textX=32.244 text="=>_Java" + +### custom-mmm page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 215.050 68.446 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 215.050 68.446 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 68.446 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=68.446 lead=0 text="MMM_Java" + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="MMM_Java" + +### markerless page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 215.050 29.568 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 215.050 29.568 12.950] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 29.568 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=29.568 lead=0 text="Java" + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="Java" + +### markerless-continuation page=165.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 189.150 140.042 38.850] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 189.150 140.042 38.850] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 189.150 140.042 38.850] pad=[0.000 0.000 0.000 0.000] + line[0] w=133.070 lead=0 text="Long_item_text_should" + line[1] w=140.042 lead=4 text="____wrap_across_several" + line[2] w=119.826 lead=4 text="____visual_lines_here." + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="Long_item_text_should" + pdf page=0 x=12.000 markerX=27.568 textX=27.568 text="____wrap_across_several" + pdf page=0 x=12.000 markerX=27.568 textX=27.568 text="____visual_lines_here." + +### bullet-wrapped page=165.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 163.250 132.272 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 163.250 132.272 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 163.250 132.272 64.750] pad=[0.000 0.000 0.000 0.000] + line[0] w=96.726 lead=0 text="\u2022_Long_item_text" + line[1] w=132.272 lead=3 text="___should_wrap_across" + line[2] w=129.150 lead=3 text="___several_visual_lines" + line[3] w=123.732 lead=3 text="___while_keeping_one" + line[4] w=49.028 lead=3 text="___bullet." + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Long_item_text" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___should_wrap_across" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___several_visual_lines" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___while_keeping_one" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___bullet." + +### dash-wrapped page=165.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 163.250 132.272 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 163.250 132.272 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 163.250 132.272 64.750] pad=[0.000 0.000 0.000 0.000] + line[0] w=96.488 lead=0 text="-_Long_item_text" + line[1] w=132.272 lead=3 text="___should_wrap_across" + line[2] w=129.150 lead=3 text="___several_visual_lines" + line[3] w=123.732 lead=3 text="___while_keeping_one" + line[4] w=45.920 lead=3 text="___dash." + pdf page=0 x=12.000 markerX=12.000 textX=20.554 text="-_Long_item_text" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___should_wrap_across" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___several_visual_lines" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___while_keeping_one" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___dash." + +### wide-marker-wrapped page=165.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 150.300 129.164 77.700] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 150.300 129.164 77.700] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 150.300 129.164 77.700] pad=[0.000 0.000 0.000 0.000] + line[0] w=126.812 lead=0 text="MMM_Long_item_text" + line[1] w=114.394 lead=10 text="__________should_wrap" + line[2] w=129.164 lead=10 text="__________across_several" + line[3] w=107.380 lead=10 text="__________visual_lines" + line[4] w=123.732 lead=10 text="__________while_keeping" + line[5] w=113.610 lead=10 text="__________one_marker." + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="MMM_Long_item_text" + pdf page=0 x=12.000 markerX=50.920 textX=50.920 text="__________should_wrap" + pdf page=0 x=12.000 markerX=50.920 textX=50.920 text="__________across_several" + pdf page=0 x=12.000 markerX=50.920 textX=50.920 text="__________visual_lines" + pdf page=0 x=12.000 markerX=50.920 textX=50.920 text="__________while_keeping" + pdf page=0 x=12.000 markerX=50.920 textX=50.920 text="__________one_marker." + +### padding page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 192.100 54.360 35.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 192.100 54.360 35.900] pages=0..0 pad=[3.000 5.000 7.000 11.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 212.050 54.360 15.950] pad=[3.000 5.000 0.000 11.000] + line[0] w=38.360 lead=0 text="\u2022_Java" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 192.100 54.360 19.950] pad=[0.000 5.000 7.000 11.000] + line[0] w=36.806 lead=0 text="\u2022_SQL" + pdf page=0 x=23.000 markerX=23.000 textX=31.792 text="\u2022_Java" + pdf page=0 x=23.000 markerX=23.000 textX=31.792 text="\u2022_SQL" + +### margin page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 192.100 54.360 35.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[23.000 199.100 38.360 25.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[3.000 5.000 7.000 11.000] + frag Root[0]/L[0] idx=0 page=0 box=[23.000 212.050 38.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=38.360 lead=0 text="\u2022_Java" + frag Root[0]/L[0] idx=1 page=0 box=[23.000 199.100 38.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=36.806 lead=0 text="\u2022_SQL" + pdf page=0 x=23.000 markerX=23.000 textX=31.792 text="\u2022_Java" + pdf page=0 x=23.000 markerX=23.000 textX=31.792 text="\u2022_SQL" + +### narrow page=90.000x240.000 margin=6.000 pages=1 + node Root[0] kind=ContainerNode box=[6.000 169.250 69.258 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[6.000 169.250 69.258 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[6.000 169.250 69.258 64.750] pad=[0.000 0.000 0.000 0.000] + line[0] w=68.698 lead=0 text="\u2022_Wrapping" + line[1] w=64.582 lead=3 text="___in_a_very" + line[2] w=54.460 lead=3 text="___narrow" + line[3] w=69.258 lead=3 text="___container" + line[4] w=53.704 lead=3 text="___indeed" + pdf page=0 x=6.000 markerX=6.000 textX=14.792 text="\u2022_Wrapping" + pdf page=0 x=6.000 markerX=17.676 textX=17.676 text="___in_a_very" + pdf page=0 x=6.000 markerX=17.676 textX=17.676 text="___narrow" + pdf page=0 x=6.000 markerX=17.676 textX=17.676 text="___container" + pdf page=0 x=6.000 markerX=17.676 textX=17.676 text="___indeed" + +### item-spacing page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 196.100 38.360 31.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 196.100 38.360 31.900] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 38.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=38.360 lead=0 text="\u2022_Java" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 196.100 38.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=36.806 lead=0 text="\u2022_SQL" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Java" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_SQL" + +### nested page=320.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 163.250 94.934 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 163.250 94.934 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 94.934 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=60.158 lead=0 text="\u2022_Top_one" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 202.100 94.934 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=78.596 lead=0 text="\u00A0\u00A0\u25E6_Child_one" + frag Root[0]/L[0] idx=2 page=0 box=[12.000 189.150 94.934 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=77.028 lead=0 text="\u00A0\u00A0\u25E6_Child_two" + frag Root[0]/L[0] idx=3 page=0 box=[12.000 176.200 94.934 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=94.934 lead=0 text="\u00A0\u00A0\u00A0\u00A0\u25AA_Grandchild" + frag Root[0]/L[0] idx=4 page=0 box=[12.000 163.250 94.934 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=58.590 lead=0 text="\u2022_Top_two" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Top_one" + pdf page=0 x=12.000 markerX=19.784 textX=31.460 text="__?_Child_one" + pdf page=0 x=12.000 markerX=19.784 textX=31.460 text="__?_Child_two" + pdf page=0 x=12.000 markerX=27.568 textX=39.244 text="____?_Grandchild" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Top_two" + +### nested-wrapped page=165.000x240.000 margin=12.000 pages=1 + node Root[0] kind=ContainerNode box=[12.000 163.250 135.394 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 163.250 135.394 64.750] pages=0..0 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 215.050 135.394 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=32.914 lead=0 text="\u2022_Top" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 163.250 135.394 51.800] pad=[0.000 0.000 0.000 0.000] + line[0] w=135.394 lead=0 text="\u00A0\u00A0\u25E6_Child_item_text_that" + line[1] w=120.596 lead=0 text="should_wrap_across" + line[2] w=117.474 lead=0 text="several_visual_lines" + line[3] w=31.906 lead=0 text="here." + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Top" + pdf page=0 x=12.000 markerX=19.784 textX=31.460 text="__?_Child_item_text_that" + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="should_wrap_across" + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="several_visual_lines" + pdf page=0 x=12.000 markerX=12.000 textX=12.000 text="here." + +### pagination-split-item page=165.000x90.000 margin=12.000 pages=2 + node Root[0] kind=ContainerNode box=[12.000 -38.550 136.962 116.550] pages=0..1 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 13.250 136.962 116.550] pages=0..1 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 13.250 136.962 64.750] pad=[0.000 0.000 0.000 0.000] + line[0] w=96.726 lead=0 text="\u2022_Long_item_text" + line[1] w=132.272 lead=3 text="___should_wrap_across" + line[2] w=136.934 lead=3 text="___many_visual_lines_so" + line[3] w=136.962 lead=3 text="___that_it_has_to_cross_a" + line[4] w=132.314 lead=3 text="___page_boundary_and" + frag Root[0]/L[0] idx=0 page=1 box=[12.000 26.200 129.948 51.800] pad=[0.000 0.000 0.000 0.000] + line[0] w=107.408 lead=3 text="___continue_on_the" + line[1] w=129.948 lead=3 text="___following_page_with" + line[2] w=105.056 lead=3 text="___its_continuation" + line[3] w=91.056 lead=3 text="___indent_intact." + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Long_item_text" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___should_wrap_across" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___many_visual_lines_so" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___that_it_has_to_cross_a" + pdf page=0 x=12.000 markerX=23.676 textX=23.676 text="___page_boundary_and" + pdf page=1 x=12.000 markerX=23.676 textX=23.676 text="___continue_on_the" + pdf page=1 x=12.000 markerX=23.676 textX=23.676 text="___following_page_with" + pdf page=1 x=12.000 markerX=23.676 textX=23.676 text="___its_continuation" + pdf page=1 x=12.000 markerX=23.676 textX=23.676 text="___indent_intact." + +### pagination-whole-items page=165.000x90.000 margin=12.000 pages=2 + node Root[0] kind=ContainerNode box=[12.000 -25.600 48.482 103.600] pages=0..1 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + node Root[0]/L[0] kind=ListNode box=[12.000 13.250 48.482 103.600] pages=0..1 pad=[0.000 0.000 0.000 0.000] margin=[0.000 0.000 0.000 0.000] + frag Root[0]/L[0] idx=0 page=0 box=[12.000 65.050 45.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=35.252 lead=0 text="\u2022_One" + frag Root[0]/L[0] idx=1 page=0 box=[12.000 52.100 45.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=35.238 lead=0 text="\u2022_Two" + frag Root[0]/L[0] idx=2 page=0 box=[12.000 39.150 45.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=45.360 lead=0 text="\u2022_Three" + frag Root[0]/L[0] idx=3 page=0 box=[12.000 26.200 45.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=37.576 lead=0 text="\u2022_Four" + frag Root[0]/L[0] idx=4 page=0 box=[12.000 13.250 45.360 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=35.238 lead=0 text="\u2022_Five" + frag Root[0]/L[0] idx=0 page=1 box=[12.000 65.050 48.482 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=28.238 lead=0 text="\u2022_Six" + frag Root[0]/L[0] idx=1 page=1 box=[12.000 52.100 48.482 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=48.482 lead=0 text="\u2022_Seven" + frag Root[0]/L[0] idx=2 page=1 box=[12.000 39.150 48.482 12.950] pad=[0.000 0.000 0.000 0.000] + line[0] w=40.698 lead=0 text="\u2022_Eight" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_One" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Two" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Three" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Four" + pdf page=0 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Five" + pdf page=1 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Six" + pdf page=1 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Seven" + pdf page=1 x=12.000 markerX=12.000 textX=20.792 text="\u2022_Eight" + diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java new file mode 100644 index 000000000..e8b0d1ce9 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java @@ -0,0 +1,132 @@ +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.style.DocumentInsets; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Freezes what the semantic DOCX export of a list is today, before any + * hanging-indent or marker-gap work exists. + * + *

The DOCX list path shares no geometry code with the PDF one — it walks the + * authored node tree directly and never touches the layout engine — so its + * contract has to be pinned separately, and pinned as codepoints. In particular + * the two backends indent nesting with different characters that happen + * to look the same, which is the kind of agreement that breaks silently the + * first time someone "unifies" them.

+ */ +class DocxListLegacyGeometryFreezeTest { + + /** What the DOCX writer uses per nesting level: two ASCII spaces. */ + private static final String DOCX_INDENT_UNIT = " "; + + /** What the PDF flatten path uses per nesting level: two non-breaking spaces. */ + private static final String PDF_INDENT_UNIT = "  "; + + @Test + void nestingIndentsWithTwoAsciiSpacesPerLevelAndNotWithTheNonBreakingSpacesThePdfPathUses() throws Exception { + List texts = exportTexts(flow -> flow + .addList(list -> list + .name("Outline") + .addItem("alpha", l1 -> l1 + .addItem("beta", l2 -> l2 + .addItem("gamma"))))); + + assertThat(texts).contains( + "• alpha", + DOCX_INDENT_UNIT + "◦ beta", + DOCX_INDENT_UNIT.repeat(2) + "▪ gamma"); + + // Stated as the divergence it is: same visual width, different codepoints, + // held together by convention rather than by shared code. + assertThat(DOCX_INDENT_UNIT).isNotEqualTo(PDF_INDENT_UNIT); + assertThat(texts.stream().anyMatch(t -> t.contains(" "))) + .as("no non-breaking space reaches the DOCX run text") + .isFalse(); + } + + @Test + void theMarkerIsRunTextWithATrailingSpaceAndNotWordNumbering() throws Exception { + try (XWPFDocument document = export(flow -> flow + .addList(list -> list.name("Flat").bullet().items("Java", "SQL")))) { + + List paragraphs = document.getParagraphs().stream() + .filter(p -> !p.getText().isBlank()) + .toList(); + assertThat(paragraphs).hasSize(2); + assertThat(paragraphs.get(0).getText()).isEqualTo("• Java"); + assertThat(paragraphs.get(1).getText()).isEqualTo("• SQL"); + + for (XWPFParagraph paragraph : paragraphs) { + CTPPr properties = paragraph.getCTP().getPPr(); + boolean numbered = properties != null && properties.isSetNumPr(); + boolean indented = properties != null && properties.isSetInd(); + assertThat(numbered).as("no w:numPr — these are plain paragraphs").isFalse(); + assertThat(indented).as("no w:ind — hanging indent has no DOCX representation today").isFalse(); + } + } + assertThat(hasNumberingPart()).as("no numbering.xml is written").isFalse(); + } + + @Test + void oneParagraphPerItemRegardlessOfLengthBecauseDocxDoesNotWrap() throws Exception { + // The DOCX path never measures or wraps: a long item is one w:p and Word + // does its own line breaking, so there is no continuation line to indent + // and continuationIndent() has no effect here. + List texts = exportTexts(flow -> flow + .addList(list -> list + .name("Long") + .bullet() + .continuationIndent(" ") + .items("Long item text that would wrap across several visual lines in the PDF " + + "backend but stays a single Word paragraph here."))); + + List items = texts.stream().filter(t -> !t.isBlank()).toList(); + assertThat(items).hasSize(1); + assertThat(items.get(0)).startsWith("• Long item text"); + assertThat(items.get(0)).doesNotContain(" "); + } + + // ------------------------------------------------------------------ + + private static boolean hasNumberingPart() throws Exception { + try (XWPFDocument document = export(flow -> flow + .addList(list -> list.name("Flat").bullet().items("Java")))) { + return document.getNumbering() != null; + } + } + + private static List exportTexts( + Consumer author) throws Exception { + try (XWPFDocument document = export(author)) { + return document.getParagraphs().stream() + .map(XWPFParagraph::getText) + .toList(); + } + } + + private static XWPFDocument export( + Consumer author) throws Exception { + byte[] docxBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 842) + .margin(DocumentInsets.of(36)) + .create()) { + var flow = session.dsl().pageFlow().name("Flow"); + author.accept(flow); + flow.build(); + docxBytes = session.export(new DocxSemanticBackend()); + } + return new XWPFDocument(new ByteArrayInputStream(docxBytes)); + } +} From 4d68d0cd0734851424d6e72d4a84138fce82ec14 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 10 Sep 2026 11:59:48 +0100 Subject: [PATCH 2/7] feat(list): give the list one layout strategy and a marker/content model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../compose/document/dsl/ListBuilder.java | 59 +++- .../document/layout/ListItemLayout.java | 57 ++++ .../document/layout/ListItemNormalizer.java | 78 +++++ .../document/layout/TextFlowSupport.java | 66 +++- .../layout/payloads/ListItemSpec.java | 89 ++++++ .../layout/payloads/PreparedListLayout.java | 35 ++- .../compose/document/node/ListNode.java | 59 +++- knowledge/api/authoring.json | 125 +++++++- knowledge/api/authoring.md | 10 +- knowledge/api/excluded.json | 16 +- .../layout/ListItemLayoutModelTest.java | 295 ++++++++++++++++++ 11 files changed, 871 insertions(+), 18 deletions(-) create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ListItemLayout.java create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java create mode 100644 core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java create mode 100644 qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java diff --git a/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java index eef60e9f2..385ce5575 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java @@ -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. @@ -266,6 +268,55 @@ 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. + * + *

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.

+ * + *

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.

+ * + * @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. + * + *

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.

+ * + *

Real geometry, never spaces. A markerless item takes no gap at all, + * rather than an unexplained inset.

+ * + * @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. * @@ -360,7 +411,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. @@ -377,7 +430,9 @@ public ListNode build() { continuationIndent, normalizeMarkers, padding, - margin); + margin, + hangingIndent, + markerGap); } /** diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListItemLayout.java b/core/src/main/java/com/demcha/compose/document/layout/ListItemLayout.java new file mode 100644 index 000000000..95860c1fd --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/ListItemLayout.java @@ -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. + * + *

The point of naming the strategy is that the decision is made once, + * 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.

+ * + *

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.

+ * + * @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. + * + *

{@code markerGap} is not observed in this mode — the gap is whatever + * the marker's own trailing separator measures.

+ */ + 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. + * + *

{@code markerGap} is real geometry in this mode, in points.

+ */ + 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; + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java new file mode 100644 index 000000000..570b47e1a --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java @@ -0,0 +1,78 @@ +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}. + * + *

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.

+ * + *

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.

+ * + * @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 normalize(ListNode node) { + List out = new ArrayList<>(); + if (node.nestedItems().isEmpty()) { + for (String item : node.items()) { + String content = ListMarker.normalizeItemText(item, node.normalizeMarkers()); + if (content.isBlank()) { + // Same rule as the flat legacy path: an item with no + // renderable content contributes no row, marker or not. + continue; + } + out.add(new ListItemSpec(0, node.marker(), content)); + } + return List.copyOf(out); + } + normalizeNested(node, node.nestedItems(), 0, out); + return List.copyOf(out); + } + + private static void normalizeNested(ListNode node, + List items, + int depth, + List out) { + for (ListItem item : items) { + ListMarker marker = item.marker() != null + ? item.marker() + : ListMarker.defaultForDepth(depth); + String content = ListMarker.normalizeItemText(item.label(), node.normalizeMarkers()); + if (!content.isBlank()) { + out.add(new ListItemSpec(depth, marker, content)); + } + // Children are walked either way: an empty label is a reason to skip + // that one row, never a reason to lose the sub-tree hanging off it. + if (!item.children().isEmpty()) { + normalizeNested(node, item.children(), depth + 1, out); + } + } + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index b85d89db4..1ef51a258 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -179,7 +179,12 @@ public static List emitParagraphFragments(PreparedNodeThis is the one place the public {@code hangingIndent} flag becomes a + * decision. Below this method each strategy owns its own preparation, and + * neither re-reads the flag — so the legacy path cannot acquire a branch it + * has to be re-proven against.

* * @param node list node to prepare * @param ctx prepare-phase context @@ -189,6 +194,20 @@ public static List emitParagraphFragments(PreparedNode prepareList(ListNode node, PrepareContext ctx, BoxConstraints constraints) { + return switch (ListItemLayout.of(node)) { + case LEGACY_PREFIX -> prepareLegacyPrefixList(node, ctx, constraints); + case MARKER_CONTENT -> prepareMarkerContentList(node, ctx, constraints); + }; + } + + /** + * The v1.4-through-2.3 preparation, unchanged: nested items are flattened + * into indent-and-marker-prefixed labels, and every item becomes a paragraph + * whose marker is a text prefix. + */ + private static PreparedNode prepareLegacyPrefixList(ListNode node, + PrepareContext ctx, + BoxConstraints constraints) { ListNode effective = node.nestedItems().isEmpty() ? node : flattenNestedListNode(node); @@ -200,6 +219,33 @@ public static PreparedNode prepareList(ListNode node, layout); } + /** + * Marker/content preparation. The normalized depth/marker/content view of + * the list is built here and attached to the prepared layout. + * + *

Measurement and emit still run the legacy pipeline, so an opted-in list + * currently renders exactly as it did before. That is deliberate: this + * change introduces the model and the seam, and the pass that turns the + * model into {@code markerX} / {@code contentX} / {@code contentWidth} + * replaces the body of this method rather than adding branches to the legacy + * one.

+ */ + private static PreparedNode prepareMarkerContentList(ListNode node, + PrepareContext ctx, + BoxConstraints constraints) { + PreparedNode prepared = prepareLegacyPrefixList(node, ctx, constraints); + PreparedListLayout layout = prepared.requirePreparedLayout(PreparedListLayout.class); + return PreparedNode.leaf( + prepared.node(), + prepared.measureResult(), + new PreparedListLayout( + layout.items(), + layout.maxLineWidth(), + layout.totalHeight(), + layout.resolvedWidth(), + ListItemNormalizer.normalize(node))); + } + /** * Synthesizes a flat {@link ListNode} from a nested one by walking * the tree depth-first and prefixing each label with @@ -209,6 +255,12 @@ public static PreparedNode prepareList(ListNode node, * baked marker characters are not stripped during paragraph * normalization. The existing flat-list rendering pipeline then * paginates and emits fragments unchanged. + * + *

The result is a legacy-shaped node by construction — its markers are + * characters inside its labels — so it reports {@code hangingIndent = false} + * whatever the authored node said. The marker/content strategy keeps its own + * structural view of the same tree in + * {@link ListItemNormalizer}; it does not read this one.

*/ private static ListNode flattenNestedListNode(ListNode node) { List flatItems = new ArrayList<>(); @@ -225,7 +277,9 @@ private static ListNode flattenNestedListNode(ListNode node) { node.continuationIndent(), false, node.padding(), - node.margin()); + node.margin(), + false, + node.markerGap()); } private static void flattenNestedItems(List items, int depth, List output) { @@ -441,9 +495,13 @@ private static PreparedNode sliceListPreparedNode(ListNode source, ? maxLineWidth + padding.horizontal() : sourceLayout.resolvedWidth(); + // A slice is the same list with fewer rows, so it keeps the authored + // layout intent. Dropping it here would leave a paginated list's tail + // disagreeing with its head about which strategy it is. ListNode fragmentNode = new ListNode( source.name(), safeItems.stream().map(PreparedListItemLayout::text).toList(), + List.of(), source.marker(), source.textStyle(), source.align(), @@ -452,7 +510,9 @@ private static PreparedNode sliceListPreparedNode(ListNode source, source.continuationIndent(), false, padding, - margin); + margin, + source.hangingIndent(), + source.markerGap()); PreparedListLayout fragmentLayout = new PreparedListLayout( safeItems, maxLineWidth, diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java new file mode 100644 index 000000000..5ec2bd98c --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java @@ -0,0 +1,89 @@ +package com.demcha.compose.document.layout.payloads; + +import com.demcha.compose.document.node.ListMarker; + +import java.util.Objects; + +/** + * One list item under + * {@link com.demcha.compose.document.layout.ListItemLayout#MARKER_CONTENT}, + * with its three parts kept apart. + * + *

The legacy path concatenates these into a single label — depth becomes a + * run of non-breaking spaces, the marker becomes a text prefix, and what is left + * is measured as one paragraph. That is exactly what makes marker geometry + * impossible: by the time anything is measured there is no marker any more, only + * characters at the front of a string.

+ * + *

This record is the opposite arrangement. It is produced by one structural + * walk of the authored tree and keeps {@code depth}, {@code marker} and + * {@code content} independent, so the next phase can measure the marker on its + * own and derive {@code markerX} / {@code contentX} / {@code contentWidth} from + * the depth rather than from a count of spaces.

+ * + *

{@code markerGap} is deliberately not here: it is one value for the + * whole list, carried on the node, and duplicating it per item would let items + * of one list disagree.

+ * + * @param depth zero-based nesting depth; 0 for every item of a flat list + * @param marker the item's resolved marker — the per-item override when it has + * one, otherwise the list's marker at depth 0 or the per-depth + * cascade below it. Markerless items carry + * {@link ListMarker#none()}, which measures 0 wide and takes no + * gap + * @param content the item's text, with author-typed markers already normalized + * away, and with no depth indent and no marker glued to it + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public record ListItemSpec(int depth, ListMarker marker, String content) { + + /** + * Normalizes nullable inputs and rejects a negative depth. + * + * @throws IllegalArgumentException when {@code depth} is negative + */ + public ListItemSpec { + if (depth < 0) { + throw new IllegalArgumentException("depth must be non-negative: " + depth); + } + marker = marker == null ? ListMarker.none() : marker; + content = content == null ? "" : content; + } + + /** + * Returns the marker text to measure — the marker's value without the + * synthetic trailing separator {@link ListMarker} appends for the legacy + * prefix path. + * + *

That separator exists so {@code "•" + text} does not render as + * {@code "•text"}; under {@code MARKER_CONTENT} the space between marker and + * content is {@code markerGap}, so measuring the separator too would count + * the gap twice. Only that one appended separator is removed: whitespace the + * author put inside their own marker is theirs and is measured with it.

+ * + * @return marker text for measurement, empty for a markerless item + */ + public String markerText() { + String value = marker.value(); + return value.endsWith(" ") ? value.substring(0, value.length() - 1) : value; + } + + /** + * Returns whether this item shows a marker at all. A markerless item takes + * marker width 0 and gap 0, so it starts flush with the depth + * indent rather than at an unexplained inset. + * + * @return {@code true} when the marker has visible content + */ + public boolean hasMarker() { + return marker.isVisible(); + } + + @Override + public String toString() { + return "ListItemSpec[depth=" + depth + + ", marker=" + Objects.toString(marker.value(), "") + + ", content=" + content + "]"; + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java index 2296adcd3..8b7150aae 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java @@ -9,21 +9,44 @@ * Aggregates the per-item prepared layouts and the resolved width/height * the list definition uses for the emit pass. * - * @param items per-item prepared layouts - * @param maxLineWidth widest measured line width across items - * @param totalHeight cumulative list height - * @param resolvedWidth resolved layout width + * @param items per-item prepared layouts + * @param maxLineWidth widest measured line width across items + * @param totalHeight cumulative list height + * @param resolvedWidth resolved layout width + * @param markerContentItems the normalized depth/marker/content view of the same + * items, in the same order — populated only under + * {@link com.demcha.compose.document.layout.ListItemLayout#MARKER_CONTENT}, + * and empty under the legacy prefix layout, which has + * no marker left to keep apart from its text */ public record PreparedListLayout( List items, double maxLineWidth, double totalHeight, - double resolvedWidth + double resolvedWidth, + List markerContentItems ) implements PreparedNodeLayout { /** - * Freezes the items list to keep the prepared layout immutable. + * Freezes both item lists to keep the prepared layout immutable. */ public PreparedListLayout { items = List.copyOf(items); + markerContentItems = markerContentItems == null ? List.of() : List.copyOf(markerContentItems); + } + + /** + * Creates a prepared list layout with no normalized item view — the legacy + * prefix layout, where depth and marker are already inside each item's text. + * + * @param items per-item prepared layouts + * @param maxLineWidth widest measured line width across items + * @param totalHeight cumulative list height + * @param resolvedWidth resolved layout width + */ + public PreparedListLayout(List items, + double maxLineWidth, + double totalHeight, + double resolvedWidth) { + this(items, maxLineWidth, totalHeight, resolvedWidth, List.of()); } } diff --git a/core/src/main/java/com/demcha/compose/document/node/ListNode.java b/core/src/main/java/com/demcha/compose/document/node/ListNode.java index 43bb2b217..9afd1eb65 100644 --- a/core/src/main/java/com/demcha/compose/document/node/ListNode.java +++ b/core/src/main/java/com/demcha/compose/document/node/ListNode.java @@ -42,6 +42,11 @@ * @param normalizeMarkers whether leading user-supplied bullets or dashes are stripped * @param padding inner list padding * @param margin outer list margin + * @param hangingIndent whether items get marker/content geometry instead of the + * legacy text prefix; {@code false} — the default — keeps the + * v1.4-through-2.3 rendering exactly + * @param markerGap space between marker and content in points, observed only + * when {@code hangingIndent} is set * @author Artem Demchyshyn */ public record ListNode( @@ -56,8 +61,19 @@ public record ListNode( String continuationIndent, boolean normalizeMarkers, DocumentInsets padding, - DocumentInsets margin + DocumentInsets margin, + boolean hangingIndent, + double markerGap ) implements DocumentNode { + + /** + * Space between marker and content used when {@code hangingIndent} is set + * and the author did not choose a gap, in points. + * + * @since 2.4.0 + */ + public static final double DEFAULT_MARKER_GAP = 4.0; + /** * Creates a normalized list node. */ @@ -77,6 +93,12 @@ public record ListNode( if (itemSpacing < 0 || Double.isNaN(itemSpacing) || Double.isInfinite(itemSpacing)) { throw new IllegalArgumentException("itemSpacing must be finite and non-negative: " + itemSpacing); } + // Validated even when hangingIndent is false: a node that carries a + // nonsense gap and only reveals it the day someone opts in is worse + // than one that refuses to be built. + if (markerGap < 0 || Double.isNaN(markerGap) || Double.isInfinite(markerGap)) { + throw new IllegalArgumentException("markerGap must be finite and non-negative: " + markerGap); + } } /** @@ -110,6 +132,41 @@ public ListNode(String name, continuationIndent, normalizeMarkers, padding, margin); } + /** + * Back-compat constructor matching the v1.8-through-2.3 12-component + * signature. Keeps the legacy prefix layout — {@code hangingIndent} is + * {@code false} and {@code markerGap} takes its default, which that layout + * does not observe. + * + * @param name optional semantic name used in snapshots and diagnostics + * @param items item texts in source order — used when {@code nestedItems} is empty + * @param nestedItems nested item tree, empty for flat lists + * @param marker top-level marker rendered before each flat item + * @param textStyle shared item text style + * @param align horizontal alignment for item text + * @param lineSpacing extra space between wrapped lines within one item + * @param itemSpacing extra space between list items + * @param continuationIndent prefix used only for wrapped continuation lines when the marker is hidden + * @param normalizeMarkers whether leading user-supplied bullets or dashes are stripped + * @param padding inner list padding + * @param margin outer list margin + */ + public ListNode(String name, + List items, + List nestedItems, + ListMarker marker, + DocumentTextStyle textStyle, + TextAlign align, + double lineSpacing, + double itemSpacing, + String continuationIndent, + boolean normalizeMarkers, + DocumentInsets padding, + DocumentInsets margin) { + this(name, items, nestedItems, marker, textStyle, align, lineSpacing, itemSpacing, + continuationIndent, normalizeMarkers, padding, margin, false, DEFAULT_MARKER_GAP); + } + private static List normalizeItems(List items) { if (items == null || items.isEmpty()) { return List.of(); diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json index 21947648e..ebfdc6605 100644 --- a/knowledge/api/authoring.json +++ b/knowledge/api/authoring.json @@ -23,9 +23,9 @@ ], "counts": { "types": 235, - "methods": 2088, - "constants": 233, - "generated": 1114 + "methods": 2093, + "constants": 234, + "generated": 1117 }, "packages": [ { @@ -9364,6 +9364,34 @@ } ] }, + { + "kind": "method", + "name": "hangingIndent", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "ListBuilder", + "params": [ + { + "type": "boolean", + "name": "hangingIndent" + } + ] + }, + { + "kind": "method", + "name": "markerGap", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "ListBuilder", + "params": [ + { + "type": "double", + "name": "markerGap" + } + ] + }, { "kind": "method", "name": "normalizeMarkers", @@ -20518,6 +20546,13 @@ ], "artifact": "graph-compose-core", "members": [ + { + "kind": "constant", + "name": "DEFAULT_MARKER_GAP", + "static": true, + "origin": "generated", + "type": "double" + }, { "kind": "constructor", "name": "ListNode", @@ -20573,6 +20608,14 @@ { "type": "DocumentInsets", "name": null + }, + { + "type": "boolean", + "name": null + }, + { + "type": "double", + "name": null } ] }, @@ -20630,6 +20673,64 @@ } ] }, + { + "kind": "constructor", + "name": "ListNode", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": null, + "params": [ + { + "type": "String", + "name": "name" + }, + { + "type": "List", + "name": "items" + }, + { + "type": "List", + "name": "nestedItems" + }, + { + "type": "ListMarker", + "name": "marker" + }, + { + "type": "DocumentTextStyle", + "name": "textStyle" + }, + { + "type": "TextAlign", + "name": "align" + }, + { + "type": "double", + "name": "lineSpacing" + }, + { + "type": "double", + "name": "itemSpacing" + }, + { + "type": "String", + "name": "continuationIndent" + }, + { + "type": "boolean", + "name": "normalizeMarkers" + }, + { + "type": "DocumentInsets", + "name": "padding" + }, + { + "type": "DocumentInsets", + "name": "margin" + } + ] + }, { "kind": "method", "name": "name", @@ -20737,6 +20838,24 @@ "typeParameters": null, "returns": "DocumentInsets", "params": [] + }, + { + "kind": "method", + "name": "hangingIndent", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "boolean", + "params": [] + }, + { + "kind": "method", + "name": "markerGap", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "double", + "params": [] } ] }, diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md index 5a00f1060..4039d7bdb 100644 --- a/knowledge/api/authoring.md +++ b/knowledge/api/authoring.md @@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se **GraphCompose version:** 2.4.0-SNAPSHOT -Types: 235 · methods: 2088 · constants: 233 · compiler-generated members: 1114 +Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 1117 ## com.demcha.compose @@ -762,6 +762,8 @@ Types: 235 · methods: 2088 · constants: 233 · compiler-generated members: 111 - `ListBuilder lineSpacing(double lineSpacing)` - `ListBuilder itemSpacing(double itemSpacing)` - `ListBuilder continuationIndent(String continuationIndent)` +- `ListBuilder hangingIndent(boolean hangingIndent)` +- `ListBuilder markerGap(double markerGap)` - `ListBuilder normalizeMarkers(boolean normalizeMarkers)` - `ListBuilder padding(DocumentInsets padding)` - `ListBuilder padding(float top, float right, float bottom, float left)` @@ -1517,8 +1519,9 @@ Types: 235 · methods: 2088 · constants: 233 · compiler-generated members: 111 - `String value()` ### ListNode (record) -- `new ListNode(String, List, List, ListMarker, DocumentTextStyle, TextAlign, double, double, String, boolean, DocumentInsets, DocumentInsets)` +- `new ListNode(String, List, List, ListMarker, DocumentTextStyle, TextAlign, double, double, String, boolean, DocumentInsets, DocumentInsets, boolean, double)` - `new ListNode(String name, List items, ListMarker marker, DocumentTextStyle textStyle, TextAlign align, double lineSpacing, double itemSpacing, String continuationIndent, boolean normalizeMarkers, DocumentInsets padding, DocumentInsets margin)` +- `new ListNode(String name, List items, List nestedItems, ListMarker marker, DocumentTextStyle textStyle, TextAlign align, double lineSpacing, double itemSpacing, String continuationIndent, boolean normalizeMarkers, DocumentInsets padding, DocumentInsets margin)` - `String name()` - `List items()` - `List nestedItems()` @@ -1531,6 +1534,9 @@ Types: 235 · methods: 2088 · constants: 233 · compiler-generated members: 111 - `boolean normalizeMarkers()` - `DocumentInsets padding()` - `DocumentInsets margin()` +- `boolean hangingIndent()` +- `double markerGap()` +- constants: `DEFAULT_MARKER_GAP` ### PageBreakNode (record) - `new PageBreakNode(String, DocumentInsets)` diff --git a/knowledge/api/excluded.json b/knowledge/api/excluded.json index 89037fec6..7e33a5add 100644 --- a/knowledge/api/excluded.json +++ b/knowledge/api/excluded.json @@ -3,7 +3,7 @@ "verifiedAgainst": "2.4.0-SNAPSHOT", "generator": "knowledge/tools/api-surface/extract-api.mjs", "note": "Public types and members deliberately kept out of every surface. An exclusion nobody can see is indistinguishable from a bug, so each one records why.", - "count": 180, + "count": 182, "excluded": [ { "binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer", @@ -453,6 +453,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout)" }, + { + "binaryName": "com.demcha.compose.document.layout.ListItemLayout", + "package": "com.demcha.compose.document.layout", + "kind": "enum", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout)" + }, { "binaryName": "com.demcha.compose.document.layout.MeasureContext", "package": "com.demcha.compose.document.layout", @@ -572,6 +579,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout.payloads)" }, + { + "binaryName": "com.demcha.compose.document.layout.payloads.ListItemSpec", + "package": "com.demcha.compose.document.layout.payloads", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout.payloads)" + }, { "binaryName": "com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload", "package": "com.demcha.compose.document.layout.payloads", diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java new file mode 100644 index 000000000..b9caac6ef --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java @@ -0,0 +1,295 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory; +import com.demcha.compose.document.layout.payloads.ListItemSpec; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.PreparedListLayout; +import com.demcha.compose.document.node.ListMarker; +import com.demcha.compose.document.node.ListNode; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.engine.components.style.Margin; +import com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem; +import com.demcha.compose.engine.measurement.TextMeasurementSystem; +import com.demcha.compose.engine.render.pdf.PdfFont; +import com.demcha.compose.font.FontLibrary; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * The internal list layout model: one strategy, resolved once, and the + * normalized depth/marker/content view that the marker/content strategy keeps + * instead of a concatenated label. + * + *

This change introduces the model and the seam only — an opted-in list still + * renders through the legacy pipeline, and {@link #optingInChangesNothingYet()} + * is what holds that true while the geometry is built on top.

+ */ +class ListItemLayoutModelTest { + + // --- the one decision -------------------------------------------------- + + @Test + void theStrategyIsResolvedFromTheAuthoredFlagAndNothingElse() { + assertThat(ListItemLayout.of(list(l -> l.bullet().items("a")))) + .isEqualTo(ListItemLayout.LEGACY_PREFIX); + assertThat(ListItemLayout.of(list(l -> l.bullet().hangingIndent(true).items("a")))) + .isEqualTo(ListItemLayout.MARKER_CONTENT); + + // A gap on its own is not an opt-in: geometry is what hangingIndent buys. + assertThat(ListItemLayout.of(list(l -> l.bullet().markerGap(12).items("a")))) + .isEqualTo(ListItemLayout.LEGACY_PREFIX); + } + + @Test + void defaultsAreLegacyWithTheDocumentedGap() { + ListNode node = list(l -> l.bullet().items("a")); + assertThat(node.hangingIndent()).isFalse(); + assertThat(node.markerGap()).isEqualTo(ListNode.DEFAULT_MARKER_GAP); + assertThat(ListNode.DEFAULT_MARKER_GAP).isEqualTo(4.0); + } + + @Test + void theBackCompatConstructorsStillProduceLegacyNodes() { + ListNode eleven = new ListNode("L", List.of("a"), ListMarker.bullet(), null, null, + 0, 0, "", true, null, null); + ListNode twelve = new ListNode("L", List.of("a"), List.of(), ListMarker.bullet(), null, null, + 0, 0, "", true, null, null); + + assertThat(eleven.hangingIndent()).isFalse(); + assertThat(twelve.hangingIndent()).isFalse(); + assertThat(eleven.markerGap()).isEqualTo(ListNode.DEFAULT_MARKER_GAP); + assertThat(twelve.markerGap()).isEqualTo(ListNode.DEFAULT_MARKER_GAP); + } + + @Test + void markerGapIsValidatedEvenWhenTheLayoutWouldNotObserveIt() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ListBuilder().markerGap(-1)) + .withMessageContaining("markerGap"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new ListBuilder().markerGap(Double.NaN)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new ListBuilder().markerGap(Double.POSITIVE_INFINITY)); + + // Zero is a legitimate choice, not a mistake. + assertThat(list(l -> l.markerGap(0).items("a")).markerGap()).isEqualTo(0.0); + } + + // --- the normalized model ---------------------------------------------- + + @Test + void aFlatListNormalizesToDepthZeroRowsCarryingTheListMarker() { + List specs = ListItemNormalizer.normalize( + list(l -> l.dash().items("Java", "SQL"))); + + assertThat(specs).hasSize(2); + assertThat(specs).allSatisfy(spec -> { + assertThat(spec.depth()).isZero(); + assertThat(spec.marker()).isEqualTo(ListMarker.dash()); + assertThat(spec.hasMarker()).isTrue(); + }); + assertThat(specs.stream().map(ListItemSpec::content)).containsExactly("Java", "SQL"); + } + + @Test + void aNestedListKeepsDepthAndMarkerApartFromContentInsteadOfBakingThemIn() { + List specs = ListItemNormalizer.normalize(list(l -> l + .addItem("Top one", c -> c + .addItem("Child one") + .addItem("Child two", g -> g.addItem("Grandchild"))) + .addItem("Top two"))); + + assertThat(specs).hasSize(5); + assertThat(specs.stream().map(ListItemSpec::depth)).containsExactly(0, 1, 1, 2, 0); + assertThat(specs.stream().map(ListItemSpec::content)).containsExactly( + "Top one", "Child one", "Child two", "Grandchild", "Top two"); + + // Same glyph cascade as the legacy walk, so opting in never changes + // which marker is shown — only where it sits. + assertThat(specs.stream().map(spec -> spec.marker().value())).containsExactly( + "• ", "◦ ", "◦ ", "▪ ", "• "); + + // ...and not one of them has the depth indent or the marker in its text. + assertThat(specs).allSatisfy(spec -> { + assertThat(spec.content()).doesNotContain(" "); + assertThat(spec.content()).doesNotStartWith("•"); + assertThat(spec.content()).doesNotStartWith("◦"); + assertThat(spec.content()).doesNotStartWith("▪"); + }); + } + + @Test + void aPerItemMarkerOverrideStillBeatsTheDepthCascade() { + List specs = ListItemNormalizer.normalize(list(l -> l + .markerFor(1, ListMarker.custom("→")) + .addItem("Top", c -> c.addItem("Child")))); + + assertThat(specs.get(0).marker()).isEqualTo(ListMarker.bullet()); + assertThat(specs.get(1).marker().value()).isEqualTo("→ "); + } + + @Test + void aBlankItemContributesNoRowButNeverCostsItsChildren() { + assertThat(ListItemNormalizer.normalize(list(l -> l.items("Java", " ", "SQL")))) + .extracting(ListItemSpec::content) + .containsExactly("Java", "SQL"); + + List nested = ListItemNormalizer.normalize(list(l -> l + .addItem("", c -> c.addItem("Child survives")))); + assertThat(nested).hasSize(1); + assertThat(nested.get(0).depth()).isEqualTo(1); + assertThat(nested.get(0).content()).isEqualTo("Child survives"); + } + + @Test + void aMarkerlessItemIsMarkerlessRatherThanEmptyMarkered() { + List specs = ListItemNormalizer.normalize( + list(l -> l.noMarker().items("Aligned row"))); + + assertThat(specs).hasSize(1); + assertThat(specs.get(0).hasMarker()).isFalse(); + assertThat(specs.get(0).markerText()).isEmpty(); + } + + // --- what gets measured ------------------------------------------------- + + @Test + void markerTextDropsTheSyntheticSeparatorAndKeepsAuthorTextIntact() { + // ListMarker appends one trailing space so "•" + text does not render as + // "•text". Under MARKER_CONTENT the space between marker and content is + // markerGap, so measuring that separator too would count the gap twice. + assertThat(spec(ListMarker.bullet()).markerText()).isEqualTo("•"); + assertThat(spec(ListMarker.dash()).markerText()).isEqualTo("-"); + assertThat(spec(ListMarker.custom("=>")).markerText()).isEqualTo("=>"); + assertThat(spec(ListMarker.none()).markerText()).isEmpty(); + + // Only that one separator goes. A marker whose own text contains spaces + // keeps them — trimming an author's marker is not ours to do. + assertThat(spec(ListMarker.custom("[ x ]")).markerText()).isEqualTo("[ x ]"); + assertThat(spec(ListMarker.custom("a b")).markerText()).isEqualTo("a b"); + } + + @Test + void aSpecRejectsANegativeDepthAndNormalizesNulls() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new ListItemSpec(-1, ListMarker.bullet(), "x")) + .withMessageContaining("depth"); + + ListItemSpec normalized = new ListItemSpec(0, null, null); + assertThat(normalized.marker()).isEqualTo(ListMarker.none()); + assertThat(normalized.content()).isEmpty(); + } + + // --- the seam, and what it does not do yet ------------------------------ + + @Test + void thePreparedLayoutCarriesTheModelOnlyForTheMarkerContentStrategy() throws Exception { + assertThat(preparedSpecs(l -> l.bullet().items("Java", "SQL"))) + .as("legacy has no marker left to keep apart from its text") + .isEmpty(); + + assertThat(preparedSpecs(l -> l.bullet().hangingIndent(true).items("Java", "SQL"))) + .extracting(ListItemSpec::content) + .containsExactly("Java", "SQL"); + } + + @Test + void theModelSurvivesNestingThroughThePipeline() throws Exception { + List specs = preparedSpecs(l -> l + .hangingIndent(true) + .addItem("Top", c -> c.addItem("Child"))); + + assertThat(specs).hasSize(2); + assertThat(specs.stream().map(ListItemSpec::depth)).containsExactly(0, 1); + assertThat(specs.get(1).content()).isEqualTo("Child"); + } + + @Test + void optingInChangesNothingYet() throws Exception { + // The acceptance condition for this phase, stated as an equation. The + // model is in place and the geometry is not, so the two strategies still + // produce the same lines in the same places. The pass that measures the + // marker is what makes this test change — deliberately, and with the + // frozen legacy dump left untouched beside it. + for (Consumer shape : List.>of( + l -> l.bullet().items("Java", "SQL"), + l -> l.dash().items("Long item text that wraps across more than one visual line here."), + l -> l.marker("=>").items("Custom"), + l -> l.noMarker().items("Plain"), + l -> l.addItem("Top", c -> c.addItem("Child", g -> g.addItem("Grandchild"))))) { + + List legacy = renderedLines(shape); + assertThat(legacy).as("a shape that renders nothing would prove nothing").isNotEmpty(); + assertThat(renderedLines(shape.andThen(l -> l.hangingIndent(true)))) + .as("opting in must not move anything yet") + .isEqualTo(legacy); + } + } + + // ------------------------------------------------------------------ + + private static ListItemSpec spec(ListMarker marker) { + return new ListItemSpec(0, marker, "content"); + } + + private static ListNode list(Consumer spec) { + ListBuilder builder = new ListBuilder().name("L"); + spec.accept(builder); + return builder.build(); + } + + /** + * Prepares a list the way the compiler does and returns the normalized model + * the prepared layout came back carrying. + */ + private static List preparedSpecs(Consumer spec) throws Exception { + try (PDDocument measurementDocument = new PDDocument()) { + FontLibrary fonts = PdfFontLibraryFactory.library(measurementDocument); + PrepareContext ctx = new MeasuringPrepareContext( + fonts, new FontLibraryTextMeasurementSystem(fonts, PdfFont.class)); + return TextFlowSupport.prepareList(list(spec), ctx, new BoxConstraints(296.0, 216.0)) + .requirePreparedLayout(PreparedListLayout.class) + .markerContentItems(); + } + } + + /** Enough of a prepare pass to measure text; a list leaf needs nothing else. */ + private record MeasuringPrepareContext(FontLibrary fonts, TextMeasurementSystem textMeasurement) + implements PrepareContext { + + @Override + public PreparedNode prepare( + E node, BoxConstraints constraints) { + throw new UnsupportedOperationException("a list leaf prepares no children"); + } + + @Override + public LayoutCanvas canvas() { + return LayoutCanvas.from(320, 240, new Margin(12, 12, 12, 12)); + } + } + + private static List renderedLines(Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 240) + .margin(DocumentInsets.of(12)) + .create()) { + session.pageFlow().name("Root").addList(spec).build(); + return session.layoutGraph().fragments().stream() + .filter(f -> f.payload() instanceof ParagraphFragmentPayload) + .flatMap(f -> ((ParagraphFragmentPayload) f.payload()).lines().stream()) + .map(line -> line.text() + "@" + String.format(Locale.ROOT, "%.3f", line.width())) + .toList(); + } + } +} From 33cf8609d9fe5e85f31c815e5cb4b5449766a908 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 10 Sep 2026 12:10:45 +0100 Subject: [PATCH 3/7] fix(list): keep an authored marker-only row when marker geometry is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../document/layout/ListItemNormalizer.java | 24 +++++-- .../layout/ListItemLayoutModelTest.java | 72 +++++++++++++++++-- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java index 570b47e1a..b81c40e25 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java +++ b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java @@ -43,12 +43,9 @@ static List normalize(ListNode node) { if (node.nestedItems().isEmpty()) { for (String item : node.items()) { String content = ListMarker.normalizeItemText(item, node.normalizeMarkers()); - if (content.isBlank()) { - // Same rule as the flat legacy path: an item with no - // renderable content contributes no row, marker or not. - continue; + if (rendersSomething(node.marker(), content)) { + out.add(new ListItemSpec(0, node.marker(), content)); } - out.add(new ListItemSpec(0, node.marker(), content)); } return List.copyOf(out); } @@ -56,6 +53,21 @@ static List normalize(ListNode node) { return List.copyOf(out); } + /** + * Whether an item puts anything on the page — the rule that decides which + * authored items survive normalization. + * + *

Authored cardinality is preserved: an item whose text is empty but + * whose marker is visible is a marker-only row 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.

+ */ + private static boolean rendersSomething(ListMarker marker, String content) { + return !content.isBlank() || marker.isVisible(); + } + private static void normalizeNested(ListNode node, List items, int depth, @@ -65,7 +77,7 @@ private static void normalizeNested(ListNode node, ? item.marker() : ListMarker.defaultForDepth(depth); String content = ListMarker.normalizeItemText(item.label(), node.normalizeMarkers()); - if (!content.isBlank()) { + if (rendersSomething(marker, content)) { out.add(new ListItemSpec(depth, marker, content)); } // Children are walked either way: an empty label is a reason to skip diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java index b9caac6ef..6d350d0f6 100644 --- a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java @@ -139,16 +139,59 @@ void aPerItemMarkerOverrideStillBeatsTheDepthCascade() { } @Test - void aBlankItemContributesNoRowButNeverCostsItsChildren() { - assertThat(ListItemNormalizer.normalize(list(l -> l.items("Java", " ", "SQL")))) + void anEmptyItemWithAVisibleMarkerStaysAsAMarkerOnlyRow() { + // Authored cardinality is preserved: three items in, three rows out. The + // author asked for that bullet, and opting into marker geometry is not a + // reason to lose it. + List specs = ListItemNormalizer.normalize( + list(l -> l.bullet().items("Java", " ", "SQL"))); + + assertThat(specs).hasSize(3); + assertThat(specs).extracting(ListItemSpec::content).containsExactly("Java", "", "SQL"); + assertThat(specs.get(1).hasMarker()).isTrue(); + assertThat(specs.get(1).markerText()).isEqualTo("•"); + } + + @Test + void anEmptyParentKeepsItsOwnMarkerRowAndAllOfItsChildren() { + List specs = ListItemNormalizer.normalize(list(l -> l + .addItem("", c -> c.addItem("Child survives")))); + + assertThat(specs).hasSize(2); + assertThat(specs.get(0).depth()).isZero(); + assertThat(specs.get(0).content()).isEmpty(); + assertThat(specs.get(0).hasMarker()).as("the parent is a marker-only row").isTrue(); + assertThat(specs.get(1).depth()).isEqualTo(1); + assertThat(specs.get(1).content()).isEqualTo("Child survives"); + } + + @Test + void anEmptyItemWithNoMarkerDrawsNothingAndIsOmitted() { + // The one case with neither text nor marker: nothing to draw, no marker, + // no gap, and no row — which is what the normalized-content contract + // already says. + assertThat(ListItemNormalizer.normalize(list(l -> l.noMarker().items("Java", " ", "SQL")))) .extracting(ListItemSpec::content) .containsExactly("Java", "SQL"); - List nested = ListItemNormalizer.normalize(list(l -> l - .addItem("", c -> c.addItem("Child survives")))); - assertThat(nested).hasSize(1); - assertThat(nested.get(0).depth()).isEqualTo(1); - assertThat(nested.get(0).content()).isEqualTo("Child survives"); + assertThat(ListItemNormalizer.normalize(list(l -> l + .markerFor(0, ListMarker.none()) + .addItem("", c -> c.addItem("Child survives"))))) + .extracting(ListItemSpec::content) + .containsExactly("Child survives"); + } + + @Test + void theSameEmptyItemShapesRenderUnchangedUnderTheLegacyLayout() throws Exception { + // The cardinality rule is a marker/content decision. Legacy keeps the + // behaviour it shipped with: a blank flat item is dropped whatever the + // marker, and a blank nested parent still renders its baked marker. + assertThat(legacyLineTexts(l -> l.bullet().items("Java", " ", "SQL"))) + .containsExactly("• Java", "• SQL"); + assertThat(legacyLineTexts(l -> l.noMarker().items("Java", " ", "SQL"))) + .containsExactly("Java", "SQL"); + assertThat(legacyLineTexts(l -> l.addItem("", c -> c.addItem("Child survives")))) + .containsExactly("•", "  ◦ Child survives"); } @Test @@ -279,6 +322,21 @@ public LayoutCanvas canvas() { } } + /** Exact line texts a shape produces under the legacy layout. */ + private static List legacyLineTexts(Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 240) + .margin(DocumentInsets.of(12)) + .create()) { + session.pageFlow().name("Root").addList(spec).build(); + return session.layoutGraph().fragments().stream() + .filter(f -> f.payload() instanceof ParagraphFragmentPayload) + .flatMap(f -> ((ParagraphFragmentPayload) f.payload()).lines().stream()) + .map(line -> line.text()) + .toList(); + } + } + private static List renderedLines(Consumer spec) throws Exception { try (DocumentSession session = GraphCompose.document() .pageSize(200, 240) From 563f9de7096d2d3fd12f4a77549ef9480d50417e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 10 Sep 2026 12:25:36 +0100 Subject: [PATCH 4/7] feat(list): measure the marker and resolve the content origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../document/layout/ListMarkerGeometry.java | 98 ++++++ .../document/layout/TextFlowSupport.java | 18 +- .../layout/payloads/MarkerContentItem.java | 114 +++++++ .../layout/payloads/PreparedListLayout.java | 6 +- knowledge/api/excluded.json | 9 +- .../layout/ListItemLayoutModelTest.java | 17 +- .../layout/ListMarkerGeometryTest.java | 320 ++++++++++++++++++ 7 files changed, 563 insertions(+), 19 deletions(-) create mode 100644 core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java create mode 100644 core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java create mode 100644 qa/src/test/java/com/demcha/compose/document/layout/ListMarkerGeometryTest.java diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java new file mode 100644 index 000000000..d53b5745b --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java @@ -0,0 +1,98 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.layout.payloads.ListItemSpec; +import com.demcha.compose.document.layout.payloads.MarkerContentItem; +import com.demcha.compose.document.node.ListNode; +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.measurement.TextMeasurementSystem; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static com.demcha.compose.document.layout.DocumentNodeAdapters.toTextStyle; + +/** + * Resolves the marker/content geometry of a list from its normalized items. + * + *

The marker's width is measured, in the list's own text style. It is + * never counted in characters and never approximated by spaces — that + * approximation is precisely what the legacy layout does and what this replaces. + * The caller never computes any of these numbers; it asks for them.

+ * + *

Depth

+ *

Nesting is an outline, not a fixed step: a child's marker starts where its + * parent's text starts. Because {@link ListItemNormalizer} emits items + * depth-first in source order, the most recent item one level up is exactly the + * parent, so one array of content offsets resolves the whole tree in a single + * pass — no tree walk, no per-item search, and nothing that grows with the + * square of the item count.

+ * + * @author Artem Demchyshyn + * @since 2.4.0 + */ +final class ListMarkerGeometry { + + /** + * Narrowest content a row may be given. Mirrors the legacy wrap clamp, which + * lets an over-wide marker overflow rather than collapse the text to nothing. + */ + private static final double MIN_CONTENT_WIDTH = 1.0; + + private ListMarkerGeometry() { + } + + /** + * Resolves geometry for every normalized item of a list. + * + * @param specs normalized items, depth-first in source order + * @param node the list, for its marker gap and text style + * @param availableItemWidth width one row may occupy, inside the list padding + * @param measurement text measurement service + * @return one resolved item per spec, in the same order + */ + static List resolve(List specs, + ListNode node, + double availableItemWidth, + TextMeasurementSystem measurement) { + if (specs.isEmpty()) { + return List.of(); + } + TextStyle style = toTextStyle(node.textStyle()); + List out = new ArrayList<>(specs.size()); + // contentX of the most recent item at each depth, which is where a child + // of that item hangs its own marker. + double[] contentXByDepth = new double[8]; + + for (ListItemSpec spec : specs) { + int depth = spec.depth(); + contentXByDepth = ensureCapacity(contentXByDepth, depth + 1); + + // A depth with no item above it resolves to 0 rather than throwing: + // the builder cannot produce that, but a hand-built node can, and a + // list is not worth failing a render over. + double markerX = depth == 0 ? 0.0 : contentXByDepth[depth - 1]; + + boolean hasMarker = spec.hasMarker(); + double markerWidth = hasMarker ? measurement.textWidth(style, spec.markerText()) : 0.0; + double gap = hasMarker ? node.markerGap() : 0.0; + double contentX = markerX + markerWidth + gap; + double contentWidth = Math.max(MIN_CONTENT_WIDTH, availableItemWidth - contentX); + + contentXByDepth[depth] = contentX; + out.add(new MarkerContentItem(spec, markerX, markerWidth, gap, contentX, contentWidth)); + } + return List.copyOf(out); + } + + private static double[] ensureCapacity(double[] values, int needed) { + if (needed <= values.length) { + return values; + } + int grown = values.length; + while (grown < needed) { + grown *= 2; + } + return Arrays.copyOf(values, grown); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index 1ef51a258..c725fc757 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -223,18 +223,18 @@ private static PreparedNode prepareLegacyPrefixList(ListNode node, * Marker/content preparation. The normalized depth/marker/content view of * the list is built here and attached to the prepared layout. * - *

Measurement and emit still run the legacy pipeline, so an opted-in list - * currently renders exactly as it did before. That is deliberate: this - * change introduces the model and the seam, and the pass that turns the - * model into {@code markerX} / {@code contentX} / {@code contentWidth} - * replaces the body of this method rather than adding branches to the legacy - * one.

+ *

Wrapping and emit still run the legacy pipeline, so an opted-in list + * currently renders exactly as it did before. That is deliberate: the change + * that moves text is the one that wraps at {@code contentWidth} and draws the + * marker at {@code markerX}, and keeping it separate leaves the numbers here + * provable on their own.

*/ private static PreparedNode prepareMarkerContentList(ListNode node, PrepareContext ctx, BoxConstraints constraints) { PreparedNode prepared = prepareLegacyPrefixList(node, ctx, constraints); PreparedListLayout layout = prepared.requirePreparedLayout(PreparedListLayout.class); + double availableItemWidth = Math.max(0.0, constraints.availableWidth() - node.padding().horizontal()); return PreparedNode.leaf( prepared.node(), prepared.measureResult(), @@ -243,7 +243,11 @@ private static PreparedNode prepareMarkerContentList(ListNode node, layout.maxLineWidth(), layout.totalHeight(), layout.resolvedWidth(), - ListItemNormalizer.normalize(node))); + ListMarkerGeometry.resolve( + ListItemNormalizer.normalize(node), + node, + availableItemWidth, + ctx.textMeasurement()))); } /** diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java new file mode 100644 index 000000000..066028c69 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java @@ -0,0 +1,114 @@ +package com.demcha.compose.document.layout.payloads; + +import java.util.Objects; + +/** + * One list item under + * {@link com.demcha.compose.document.layout.ListItemLayout#MARKER_CONTENT}, + * with its geometry resolved. + * + *

All four x values are relative to the item's own start — the left + * edge of the box the list gives each row, inside the list's padding — not to + * the page. Placement adds the item origin exactly once, so the same numbers + * hold whether the list sits at the page margin, inside a card, or on the second + * page of a split.

+ * + *
+ *   |<- markerX ->|<- measuredMarkerWidth ->|<- markerGap ->|<- contentWidth ->|
+ *   |             •                                          Item text that
+ *   |                                                        wraps to here
+ *   ^ item start                                             ^ contentX
+ * 
+ * + *

{@code contentX} is what every visual line of the item shares — the first + * line, the lines it wraps onto, and the lines that continue on the next page. + * That is the whole point of the layout: in the legacy one each of those was + * approximated separately by a run of spaces.

+ * + * @param spec the item's authored depth, marker and content + * @param markerX where the marker starts; {@code 0} at depth 0, and + * at deeper levels the {@code contentX} of the + * parent row, so a marker lines up with the text of + * the item it hangs under + * @param measuredMarkerWidth the marker's measured glyph width — measured, never + * assumed and never derived from character count; + * {@code 0} for a markerless item + * @param markerGap the gap actually applied between marker and + * content; the list's {@code markerGap} for an item + * with a marker, and {@code 0} for one without, so a + * markerless row starts flush instead of at an + * unexplained inset + * @param contentX {@code markerX + measuredMarkerWidth + markerGap} + * @param contentWidth the width the item's text wraps within + * @author Artem Demchyshyn + * @since 2.4.0 + */ +public record MarkerContentItem( + ListItemSpec spec, + double markerX, + double measuredMarkerWidth, + double markerGap, + double contentX, + double contentWidth +) { + + /** + * Validates that the resolved geometry is finite and self-consistent. + * + * @throws IllegalArgumentException when any value is not finite, when a + * width or offset is negative, or when + * {@code contentX} does not equal + * {@code markerX + measuredMarkerWidth + markerGap} + */ + public MarkerContentItem { + Objects.requireNonNull(spec, "spec"); + requireFinite(markerX, "markerX"); + requireFinite(measuredMarkerWidth, "measuredMarkerWidth"); + requireFinite(markerGap, "markerGap"); + requireFinite(contentX, "contentX"); + requireFinite(contentWidth, "contentWidth"); + // Checked rather than trusted: contentX is the number every line of the + // item is placed at, so a resolver that computed it a different way from + // its own parts would misalign the item in a way no width assertion sees. + double expected = markerX + measuredMarkerWidth + markerGap; + if (Math.abs(contentX - expected) > 1e-9) { + throw new IllegalArgumentException( + "contentX must be markerX + measuredMarkerWidth + markerGap: " + + contentX + " != " + expected); + } + } + + private static void requireFinite(double value, String name) { + if (Double.isNaN(value) || Double.isInfinite(value) || value < 0) { + throw new IllegalArgumentException(name + " must be finite and non-negative: " + value); + } + } + + /** + * Returns the item's nesting depth. + * + * @return zero-based depth + */ + public int depth() { + return spec.depth(); + } + + /** + * Returns the marker text to draw, without the separator the legacy prefix + * path appends. + * + * @return marker text, empty for a markerless item + */ + public String markerText() { + return spec.markerText(); + } + + /** + * Returns the item's text. + * + * @return item content + */ + public String content() { + return spec.content(); + } +} diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java index 8b7150aae..13f46b803 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListLayout.java @@ -13,8 +13,8 @@ * @param maxLineWidth widest measured line width across items * @param totalHeight cumulative list height * @param resolvedWidth resolved layout width - * @param markerContentItems the normalized depth/marker/content view of the same - * items, in the same order — populated only under + * @param markerContentItems the same items with their marker/content geometry + * resolved, in the same order — populated only under * {@link com.demcha.compose.document.layout.ListItemLayout#MARKER_CONTENT}, * and empty under the legacy prefix layout, which has * no marker left to keep apart from its text @@ -24,7 +24,7 @@ public record PreparedListLayout( double maxLineWidth, double totalHeight, double resolvedWidth, - List markerContentItems + List markerContentItems ) implements PreparedNodeLayout { /** * Freezes both item lists to keep the prepared layout immutable. diff --git a/knowledge/api/excluded.json b/knowledge/api/excluded.json index 7e33a5add..6956e7948 100644 --- a/knowledge/api/excluded.json +++ b/knowledge/api/excluded.json @@ -3,7 +3,7 @@ "verifiedAgainst": "2.4.0-SNAPSHOT", "generator": "knowledge/tools/api-surface/extract-api.mjs", "note": "Public types and members deliberately kept out of every surface. An exclusion nobody can see is indistinguishable from a bug, so each one records why.", - "count": 182, + "count": 183, "excluded": [ { "binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer", @@ -586,6 +586,13 @@ "artifact": "graph-compose-core", "reason": "package @Internal (com.demcha.compose.document.layout.payloads)" }, + { + "binaryName": "com.demcha.compose.document.layout.payloads.MarkerContentItem", + "package": "com.demcha.compose.document.layout.payloads", + "kind": "record", + "artifact": "graph-compose-core", + "reason": "package @Internal (com.demcha.compose.document.layout.payloads)" + }, { "binaryName": "com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload", "package": "com.demcha.compose.document.layout.payloads", diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java index 6d350d0f6..3c2008b72 100644 --- a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.dsl.ListBuilder; import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory; import com.demcha.compose.document.layout.payloads.ListItemSpec; +import com.demcha.compose.document.layout.payloads.MarkerContentItem; import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.PreparedListLayout; import com.demcha.compose.document.node.ListMarker; @@ -237,24 +238,24 @@ void aSpecRejectsANegativeDepthAndNormalizesNulls() { @Test void thePreparedLayoutCarriesTheModelOnlyForTheMarkerContentStrategy() throws Exception { - assertThat(preparedSpecs(l -> l.bullet().items("Java", "SQL"))) + assertThat(preparedItems(l -> l.bullet().items("Java", "SQL"))) .as("legacy has no marker left to keep apart from its text") .isEmpty(); - assertThat(preparedSpecs(l -> l.bullet().hangingIndent(true).items("Java", "SQL"))) - .extracting(ListItemSpec::content) + assertThat(preparedItems(l -> l.bullet().hangingIndent(true).items("Java", "SQL"))) + .extracting(MarkerContentItem::content) .containsExactly("Java", "SQL"); } @Test void theModelSurvivesNestingThroughThePipeline() throws Exception { - List specs = preparedSpecs(l -> l + List items = preparedItems(l -> l .hangingIndent(true) .addItem("Top", c -> c.addItem("Child"))); - assertThat(specs).hasSize(2); - assertThat(specs.stream().map(ListItemSpec::depth)).containsExactly(0, 1); - assertThat(specs.get(1).content()).isEqualTo("Child"); + assertThat(items).hasSize(2); + assertThat(items.stream().map(MarkerContentItem::depth)).containsExactly(0, 1); + assertThat(items.get(1).content()).isEqualTo("Child"); } @Test @@ -295,7 +296,7 @@ private static ListNode list(Consumer spec) { * Prepares a list the way the compiler does and returns the normalized model * the prepared layout came back carrying. */ - private static List preparedSpecs(Consumer spec) throws Exception { + private static List preparedItems(Consumer spec) throws Exception { try (PDDocument measurementDocument = new PDDocument()) { FontLibrary fonts = PdfFontLibraryFactory.library(measurementDocument); PrepareContext ctx = new MeasuringPrepareContext( diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListMarkerGeometryTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListMarkerGeometryTest.java new file mode 100644 index 000000000..bdcbff5dc --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListMarkerGeometryTest.java @@ -0,0 +1,320 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory; +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.layout.payloads.ListItemSpec; +import com.demcha.compose.document.layout.payloads.MarkerContentItem; +import com.demcha.compose.document.layout.payloads.PreparedListLayout; +import com.demcha.compose.document.node.ListMarker; +import com.demcha.compose.document.node.ListNode; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.engine.components.style.Margin; +import com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem; +import com.demcha.compose.engine.measurement.TextMeasurementSystem; +import com.demcha.compose.engine.render.pdf.PdfFont; +import com.demcha.compose.font.FontLibrary; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static com.demcha.compose.document.layout.DocumentNodeAdapters.toTextStyle; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.within; + +/** + * The measured marker/content geometry: where a marker starts, how wide it + * actually is, what gap follows it, and the one content origin every visual line + * of the item shares. + * + *

Nothing here is rendered yet — wrapping and emit still run the legacy + * pipeline, so these are the numbers the next change draws with, proved on their + * own while the legacy output is still frozen beside them.

+ */ +class ListMarkerGeometryTest { + + private static final double AVAILABLE = 296.0; + private static final double EPS = 1e-9; + + // --- the marker is measured, not counted ------------------------------ + + @Test + void theMarkerWidthIsTheMeasuredGlyphWidthOfTheMarkerAlone() throws Exception { + try (Measurer measurer = new Measurer()) { + for (ListMarker marker : List.of( + ListMarker.bullet(), ListMarker.dash(), + ListMarker.custom(">"), ListMarker.custom("=>"), ListMarker.custom("MMM"))) { + + MarkerContentItem item = measurer.one(l -> l.marker(marker).items("Item")); + double expected = measurer.width(marker.value().strip()); + + assertThat(item.measuredMarkerWidth()) + .as("marker %s", marker.value().strip()) + .isEqualTo(expected, within(EPS)); + } + } + } + + @Test + void theSyntheticSeparatorIsNotPartOfTheMeasuredMarker() throws Exception { + // The separator ListMarker appends exists so "•" + text does not render + // as "•text". Here the space between marker and content is markerGap, so + // measuring the separator too would charge for the gap twice. + try (Measurer measurer = new Measurer()) { + MarkerContentItem item = measurer.one(l -> l.bullet().markerGap(0).items("Item")); + + assertThat(item.measuredMarkerWidth()).isEqualTo(measurer.width("•"), within(EPS)); + assertThat(item.measuredMarkerWidth()) + .as("and is strictly narrower than the marker plus its separator") + .isLessThan(measurer.width("• ")); + assertThat(item.contentX()) + .as("at gap 0 the content starts exactly where the marker ends") + .isEqualTo(item.measuredMarkerWidth(), within(EPS)); + } + } + + @Test + void theBulletsResolvedGeometryAtTheDefaultStyle() throws Exception { + // The numbers themselves, so a change to any of them is a change someone + // has to look at. Beside the legacy layout at the same style, where the + // first line's text starts 8.792pt in and its wrapped lines start + // 11.676pt in — the gap this replaces was never one number. + try (Measurer measurer = new Measurer()) { + MarkerContentItem item = measurer.one(l -> l.bullet().items("Item")); + + assertThat(item.markerX()).isZero(); + assertThat(item.measuredMarkerWidth()).isEqualTo(4.900, within(0.001)); + assertThat(item.markerGap()).isEqualTo(4.0, within(EPS)); + assertThat(item.contentX()).isEqualTo(8.900, within(0.001)); + assertThat(item.contentWidth()).isEqualTo(AVAILABLE - 8.900, within(0.001)); + } + } + + @Test + void aWiderMarkerPushesContentFurtherRightByExactlyItsExtraWidth() throws Exception { + try (Measurer measurer = new Measurer()) { + MarkerContentItem bullet = measurer.one(l -> l.bullet().items("Item")); + MarkerContentItem wide = measurer.one(l -> l.marker("MMM").items("Item")); + + double extra = wide.measuredMarkerWidth() - bullet.measuredMarkerWidth(); + assertThat(extra).as("a constant marker width would make this vacuous").isPositive(); + assertThat(wide.contentX() - bullet.contentX()).isEqualTo(extra, within(EPS)); + assertThat(bullet.contentWidth() - wide.contentWidth()).isEqualTo(extra, within(EPS)); + } + } + + // --- markerGap is geometry -------------------------------------------- + + @Test + void theGapIsAppliedInPointsAndTheDefaultIsFour() throws Exception { + try (Measurer measurer = new Measurer()) { + double markerWidth = measurer.one(l -> l.bullet().items("Item")).measuredMarkerWidth(); + + for (double gap : List.of(0.0, 4.0, 8.0, 16.0)) { + MarkerContentItem item = measurer.one(l -> l.bullet().markerGap(gap).items("Item")); + assertThat(item.markerGap()).as("gap %s", gap).isEqualTo(gap, within(EPS)); + assertThat(item.contentX()).isEqualTo(markerWidth + gap, within(EPS)); + assertThat(item.contentWidth()).isEqualTo(AVAILABLE - markerWidth - gap, within(EPS)); + } + + assertThat(measurer.one(l -> l.bullet().items("Item")).markerGap()) + .as("the default gap, unstated") + .isEqualTo(ListNode.DEFAULT_MARKER_GAP, within(EPS)); + } + } + + @Test + void aMarkerlessItemTakesNoWidthAndNoGap() throws Exception { + try (Measurer measurer = new Measurer()) { + MarkerContentItem item = measurer.one(l -> l.noMarker().markerGap(16).items("Item")); + + assertThat(item.measuredMarkerWidth()).isZero(); + assertThat(item.markerGap()).as("no marker, so no gap to leave after it").isZero(); + assertThat(item.markerX()).isZero(); + assertThat(item.contentX()).as("flush, not inset by an unexplained gap").isZero(); + assertThat(item.contentWidth()).isEqualTo(AVAILABLE, within(EPS)); + } + } + + @Test + void aMarkerOnlyRowStillReservesItsMarkerAndGap() throws Exception { + try (Measurer measurer = new Measurer()) { + List items = measurer.all(l -> l.bullet().items("Java", " ", "SQL")); + + assertThat(items).hasSize(3); + assertThat(items.get(1).content()).isEmpty(); + assertThat(items.get(1).measuredMarkerWidth()).isPositive(); + assertThat(items.get(1).contentX()).isEqualTo(items.get(0).contentX(), within(EPS)); + } + } + + // --- depth is an outline, not a fixed step ---------------------------- + + @Test + void aChildsMarkerStartsWhereItsParentsTextStarts() throws Exception { + try (Measurer measurer = new Measurer()) { + List items = measurer.all(l -> l + .addItem("Top", c -> c + .addItem("Child", g -> g.addItem("Grandchild")))); + + assertThat(items).hasSize(3); + MarkerContentItem top = items.get(0); + MarkerContentItem child = items.get(1); + MarkerContentItem grandchild = items.get(2); + + assertThat(top.markerX()).isZero(); + assertThat(child.markerX()).isEqualTo(top.contentX(), within(EPS)); + assertThat(grandchild.markerX()).isEqualTo(child.contentX(), within(EPS)); + + // ...and each level's own content still clears its own marker. + assertThat(child.contentX()) + .isEqualTo(child.markerX() + child.measuredMarkerWidth() + child.markerGap(), within(EPS)); + assertThat(grandchild.contentWidth()) + .isEqualTo(AVAILABLE - grandchild.contentX(), within(EPS)); + } + } + + @Test + void siblingsAtOneDepthShareAMarkerColumnEvenWithDifferentMarkerWidths() throws Exception { + try (Measurer measurer = new Measurer()) { + List items = measurer.all(l -> l + .addItem("Parent", c -> c + .addItem("Narrow") + .addItem("Wide"))); + + assertThat(items).hasSize(3); + assertThat(items.get(2).markerX()) + .as("the second child hangs under the parent, not under its sibling") + .isEqualTo(items.get(1).markerX(), within(EPS)); + } + } + + @Test + void returningToAShallowerDepthReturnsToThatDepthsColumn() throws Exception { + try (Measurer measurer = new Measurer()) { + List items = measurer.all(l -> l + .addItem("First", c -> c.addItem("Child")) + .addItem("Second")); + + assertThat(items).extracting(MarkerContentItem::depth).containsExactly(0, 1, 0); + assertThat(items.get(2).markerX()) + .as("back at the top level, back at its origin") + .isEqualTo(items.get(0).markerX(), within(EPS)); + } + } + + // --- container interaction -------------------------------------------- + + @Test + void geometryIsRelativeToTheItemStartSoPaddingDoesNotEnterIt() throws Exception { + try (Measurer measurer = new Measurer()) { + MarkerContentItem plain = measurer.one(l -> l.bullet().items("Item")); + MarkerContentItem padded = measurer.one(l -> l.bullet().padding(3, 5, 7, 11).items("Item")); + + assertThat(padded.markerX()).as("padding moves the row, not the marker within it").isZero(); + assertThat(padded.contentX()).isEqualTo(plain.contentX(), within(EPS)); + // Only the room left for text shrinks, by the horizontal padding. + assertThat(padded.contentWidth()).isEqualTo(plain.contentWidth() - 16.0, within(EPS)); + } + } + + @Test + void aContainerTooNarrowForTheMarkerLetsContentOverflowRatherThanVanish() throws Exception { + try (Measurer measurer = new Measurer(12.0)) { + MarkerContentItem item = measurer.one(l -> l.marker("MMMMMMMMMM").markerGap(16).items("Item")); + + assertThat(item.contentX()).isGreaterThan(12.0); + assertThat(item.contentWidth()) + .as("clamped to something drawable, matching the legacy wrap clamp") + .isEqualTo(1.0, within(EPS)); + } + } + + // --- the record defends its own invariant ------------------------------ + + @Test + void aResolvedItemMustAgreeWithItsOwnParts() { + ListItemSpec spec = new ListItemSpec(0, ListMarker.bullet(), "Item"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MarkerContentItem(spec, 0, 5, 4, 99, 100)) + .withMessageContaining("contentX must be markerX + measuredMarkerWidth + markerGap"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> new MarkerContentItem(spec, 0, -1, 4, 3, 100)) + .withMessageContaining("measuredMarkerWidth"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MarkerContentItem(spec, 0, Double.NaN, 4, 4, 100)) + .withMessageContaining("measuredMarkerWidth"); + } + + // ------------------------------------------------------------------ + + /** Resolves list geometry through the real prepare pass and measures text the way it does. */ + private static final class Measurer implements AutoCloseable { + private final PDDocument document = new PDDocument(); + private final FontLibrary fonts; + private final TextMeasurementSystem measurement; + private final double available; + + private Measurer() throws Exception { + this(AVAILABLE); + } + + private Measurer(double available) throws Exception { + this.fonts = PdfFontLibraryFactory.library(document); + this.measurement = new FontLibraryTextMeasurementSystem(fonts, PdfFont.class); + this.available = available; + } + + private List all(Consumer spec) { + ListBuilder builder = new ListBuilder().name("L").hangingIndent(true); + spec.accept(builder); + ListNode node = builder.build(); + return TextFlowSupport.prepareList(node, context(), new BoxConstraints(available, 216.0)) + .requirePreparedLayout(PreparedListLayout.class) + .markerContentItems(); + } + + private MarkerContentItem one(Consumer spec) { + List items = all(spec); + assertThat(items).hasSize(1); + return items.get(0); + } + + private double width(String text) { + return measurement.textWidth(toTextStyle(DocumentTextStyle.DEFAULT), text); + } + + private PrepareContext context() { + return new PrepareContext() { + @Override + public PreparedNode prepare( + E node, BoxConstraints constraints) { + throw new UnsupportedOperationException("a list leaf prepares no children"); + } + + @Override + public FontLibrary fonts() { + return fonts; + } + + @Override + public TextMeasurementSystem textMeasurement() { + return measurement; + } + + @Override + public LayoutCanvas canvas() { + return LayoutCanvas.from(320, 240, new Margin(12, 12, 12, 12)); + } + }; + } + + @Override + public void close() throws Exception { + document.close(); + } + } +} From 1794c7c549cfa142742c1376d0ec3b4888e38420 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 10 Sep 2026 13:04:59 +0100 Subject: [PATCH 5/7] feat(list): draw the marker in its own column and hang the text beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 28 ++ .../document/layout/ListMarkerGeometry.java | 15 +- .../document/layout/ParagraphWrapping.java | 16 +- .../document/layout/TextFlowSupport.java | 182 +++++-- .../payloads/PreparedListItemLayout.java | 61 ++- .../document/api/ListHangingIndentTest.java | 453 ++++++++++++++++++ .../layout/ListItemLayoutModelTest.java | 34 +- .../visual/ListHangingIndentVisualTest.java | 76 +++ .../list-hanging-indent-page-0.png | Bin 0 -> 14062 bytes 9 files changed, 809 insertions(+), 56 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java create mode 100644 qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java create mode 100644 qa/src/test/resources/visual-baselines/list-hanging-indent-page-0.png diff --git a/CHANGELOG.md b/CHANGELOG.md index d94b0a809..ba01e6b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ 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. + - **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 diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java index d53b5745b..0f3b03c91 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java +++ b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java @@ -33,12 +33,6 @@ */ final class ListMarkerGeometry { - /** - * Narrowest content a row may be given. Mirrors the legacy wrap clamp, which - * lets an over-wide marker overflow rather than collapse the text to nothing. - */ - private static final double MIN_CONTENT_WIDTH = 1.0; - private ListMarkerGeometry() { } @@ -77,7 +71,14 @@ static List resolve(List specs, double markerWidth = hasMarker ? measurement.textWidth(style, spec.markerText()) : 0.0; double gap = hasMarker ? node.markerGap() : 0.0; double contentX = markerX + markerWidth + gap; - double contentWidth = Math.max(MIN_CONTENT_WIDTH, availableItemWidth - contentX); + // Floored at the width the text pipeline already treats as its + // minimum, so a marker column wider than its container overflows the + // way an over-long word does — rather than dropping to the engine's + // zero-width behaviour, which renders an empty line and loses the + // text. The marker itself is placed at markerX and drawn at its + // measured width either way; nothing here clips it. + double contentWidth = Math.max( + ParagraphWrapping.MIN_TEXT_WIDTH, availableItemWidth - contentX); contentXByDepth[depth] = contentX; out.add(new MarkerContentItem(spec, markerX, markerWidth, gap, contentX, contentWidth)); diff --git a/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java b/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java index 4295f1a0d..6de626a60 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java +++ b/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java @@ -29,6 +29,20 @@ */ final class ParagraphWrapping { + /** + * Narrowest region text is ever asked to fit into, in points. + * + *

The engine has two behaviours at the bottom end and this constant marks + * the boundary between them. At a width of zero or less a logical line + * becomes one empty visual line and its text is dropped; at any positive + * width the text is broken as far as it will go and allowed to overflow. + * Wherever a width is computed by subtracting something from a container — + * a bullet prefix here, a marker column in a list — it is floored at this + * value, so text that no longer fits overflows rather than + * disappearing.

+ */ + static final double MIN_TEXT_WIDTH = 1.0; + private ParagraphWrapping() { } @@ -529,7 +543,7 @@ private static double availableWidthForPrefix(double maxWidth, String prefix, TextStyle style, TextMeasurementSystem measurement) { - return Math.max(1.0, maxWidth - measurement.textWidth(style, prefix == null ? "" : prefix)); + return Math.max(MIN_TEXT_WIDTH, maxWidth - measurement.textWidth(style, prefix == null ? "" : prefix)); } private static String normalizeBulletPrefix(String bulletOffset) { diff --git a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index c725fc757..eb2e26706 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -223,31 +223,63 @@ private static PreparedNode prepareLegacyPrefixList(ListNode node, * Marker/content preparation. The normalized depth/marker/content view of * the list is built here and attached to the prepared layout. * - *

Wrapping and emit still run the legacy pipeline, so an opted-in list - * currently renders exactly as it did before. That is deliberate: the change - * that moves text is the one that wraps at {@code contentWidth} and draws the - * marker at {@code markerX}, and keeping it separate leaves the numbers here - * provable on their own.

+ *

Each item's text is wrapped inside its own {@code contentWidth}, and + * the marker takes no part in that: it is not a prefix, not a token, and + * never decides where a line breaks. The node keeps its authored shape — + * nested items are not flattened into labels here — because depth is + * geometry in this layout, not characters.

*/ private static PreparedNode prepareMarkerContentList(ListNode node, PrepareContext ctx, BoxConstraints constraints) { - PreparedNode prepared = prepareLegacyPrefixList(node, ctx, constraints); - PreparedListLayout layout = prepared.requirePreparedLayout(PreparedListLayout.class); double availableItemWidth = Math.max(0.0, constraints.availableWidth() - node.padding().horizontal()); + List geometry = ListMarkerGeometry.resolve( + ListItemNormalizer.normalize(node), node, availableItemWidth, ctx.textMeasurement()); + + List items = new ArrayList<>(geometry.size()); + for (MarkerContentItem item : geometry) { + ParagraphNode content = new ParagraphNode( + "", + item.content(), + node.textStyle(), + node.align(), + node.lineSpacing(), + "", + DocumentTextIndent.NONE, + DocumentInsets.zero(), + DocumentInsets.zero()); + items.add(new PreparedListItemLayout( + item.content(), + prepareParagraphLayout(content, item.contentWidth(), ctx.textMeasurement(), ctx.markdownEnabled()), + item, + true)); + } + + double totalHeight = listItemsHeight(items, node.itemSpacing()); + double maxLineWidth = markerContentMaxLineWidth(items); + double measuredWidth = Math.min(constraints.availableWidth(), maxLineWidth + node.padding().horizontal()); + double resolvedWidth = node.align() == TextAlign.LEFT + ? measuredWidth + : constraints.availableWidth(); + return PreparedNode.leaf( - prepared.node(), - prepared.measureResult(), - new PreparedListLayout( - layout.items(), - layout.maxLineWidth(), - layout.totalHeight(), - layout.resolvedWidth(), - ListMarkerGeometry.resolve( - ListItemNormalizer.normalize(node), - node, - availableItemWidth, - ctx.textMeasurement()))); + node, + new MeasureResult(resolvedWidth, totalHeight + node.padding().vertical()), + new PreparedListLayout(items, maxLineWidth, totalHeight, resolvedWidth, geometry)); + } + + /** + * Widest point any row reaches — the marker column and the content column + * are both candidates, and a marker-only row is measured by its marker. + */ + private static double markerContentMaxLineWidth(List items) { + double widest = 0.0; + for (PreparedListItemLayout item : items) { + MarkerContentItem geometry = item.geometry(); + widest = Math.max(widest, geometry.markerX() + geometry.measuredMarkerWidth()); + widest = Math.max(widest, geometry.contentX() + item.paragraphLayout().maxLineWidth()); + } + return widest; } /** @@ -407,37 +439,104 @@ public static List emitListFragments(PreparedNode prep List fragments = new ArrayList<>(layout.items().size()); double boxHeight = layout.totalHeight() + node.padding().vertical(); double itemTopOffset = 0.0; + int fragmentIndex = 0; for (int itemIndex = 0; itemIndex < layout.items().size(); itemIndex++) { - PreparedParagraphLayout itemLayout = layout.items().get(itemIndex).paragraphLayout(); + PreparedListItemLayout item = layout.items().get(itemIndex); + PreparedParagraphLayout itemLayout = item.paragraphLayout(); double itemHeight = itemLayout.totalHeight(); Padding itemPadding = itemPadding(node, itemIndex, layout.items().size()); double fragmentHeight = itemHeight + itemPadding.vertical(); double localY = boxHeight - itemTopOffset - fragmentHeight; - fragments.add(new LayoutFragment( - placement.path(), - itemIndex, - 0.0, - localY, - placement.width(), - fragmentHeight, - new ParagraphFragmentPayload( - toTextStyle(node.textStyle()), - node.align(), - itemPadding, - itemLayout.lineHeight(), - itemLayout.lineGap(), - itemLayout.baselineOffset(), - itemLayout.visualLines(), - null, - null, - TextVerticalAlign.DEFAULT))); + MarkerContentItem geometry = item.geometry(); + + if (geometry == null) { + // Legacy: one fragment spanning the row, marker inside the text. + fragments.add(new LayoutFragment( + placement.path(), fragmentIndex++, 0.0, localY, + placement.width(), fragmentHeight, + paragraphPayload(node, node.align(), itemPadding, itemLayout, itemLayout.visualLines()))); + } else { + // The marker sits in its own column and the text in another, so + // the two are separate fragments at separate x. They share one + // box: same localY, same height, same vertical padding — which is + // what puts the marker on the first content line's baseline + // instead of starting a second flow beside it. + Padding sides = new Padding(itemPadding.top(), 0.0, itemPadding.bottom(), 0.0); + if (item.drawsMarker() && !itemLayout.visualLines().isEmpty()) { + fragments.add(new LayoutFragment( + placement.path(), fragmentIndex++, + node.padding().left() + geometry.markerX(), localY, + geometry.measuredMarkerWidth(), fragmentHeight, + // Always LEFT: the marker's column is geometry, and a + // centred or right-aligned list aligns its text inside + // the content column without moving the marker. + paragraphPayload(node, TextAlign.LEFT, sides, itemLayout, + List.of(markerLine(node, itemLayout, geometry))))); + } + fragments.add(new LayoutFragment( + placement.path(), fragmentIndex++, + node.padding().left() + geometry.contentX(), localY, + geometry.contentWidth(), fragmentHeight, + paragraphPayload(node, node.align(), sides, itemLayout, itemLayout.visualLines()))); + } itemTopOffset += fragmentHeight + node.itemSpacing(); } return List.copyOf(fragments); } + /** + * The marker as a single measured line, built from the content's own first + * line rather than measured again. + * + *

Its width is the one already resolved in the item's geometry, and its + * line metrics are copied from the first content line — so the marker does + * not merely land near that line's baseline, it is placed by the same + * numbers and shares it by construction. Nothing here re-measures text, and + * the marker never becomes a second block of flow: it has one line, in a box + * that is the content's box.

+ */ + private static ParagraphLine markerLine(ListNode node, + PreparedParagraphLayout itemLayout, + MarkerContentItem geometry) { + ParagraphLine first = itemLayout.visualLines().get(0); + String text = geometry.markerText(); + return new ParagraphLine( + text, + geometry.measuredMarkerWidth(), + first.lineHeight(), + first.textLineHeight(), + first.textAscent(), + first.baselineOffsetFromBottom(), + List.of(new ParagraphTextSpan( + text, + toTextStyle(node.textStyle()), + geometry.measuredMarkerWidth(), + first.textLineHeight(), + null, + null, + false))); + } + + private static ParagraphFragmentPayload paragraphPayload(ListNode node, + TextAlign align, + Padding padding, + PreparedParagraphLayout metrics, + List lines) { + return new ParagraphFragmentPayload( + toTextStyle(node.textStyle()), + align, + padding, + metrics.lineHeight(), + metrics.lineGap(), + metrics.baselineOffset(), + lines, + null, + null, + TextVerticalAlign.DEFAULT); + } + // ------------------------------------------------------------------ // List helpers // ------------------------------------------------------------------ @@ -598,7 +697,12 @@ private static PreparedListItemLayout sliceListItem(PreparedListItemLayout item, totalHeight, false, false); - return new PreparedListItemLayout(String.join("\n", logicalLines), layout); + String slicedText = String.join("\n", logicalLines); + // Only a slice that begins at line 0 is still the start of the authored + // item; anything past it is a continuation and must not repeat a marker. + return fromInclusive == 0 + ? item.startingAs(slicedText, layout) + : item.continuedAs(slicedText, layout); } private static double maxListLineWidth(List items) { diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListItemLayout.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListItemLayout.java index 00cc1afe0..67e3662e8 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListItemLayout.java +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/PreparedListItemLayout.java @@ -9,10 +9,23 @@ * * @param text raw item text * @param paragraphLayout prepared paragraph layout for this item + * @param geometry resolved marker/content geometry under + * {@link com.demcha.compose.document.layout.ListItemLayout#MARKER_CONTENT}, + * or {@code null} under the legacy prefix layout, where + * the marker is already inside {@code text} + * @param startsItem whether this layout is the beginning of the + * item the author wrote, rather than the remainder of one + * that ran onto another page. It is what decides whether + * a marker is drawn, and it cannot be inferred from a + * fragment index: pagination restarts fragment indices on + * every page, so the continuation of a split item is also + * index 0 and would draw a second marker */ public record PreparedListItemLayout( String text, - PreparedParagraphLayout paragraphLayout + PreparedParagraphLayout paragraphLayout, + MarkerContentItem geometry, + boolean startsItem ) { /** * Normalizes the item text and validates the paragraph layout is @@ -22,4 +35,50 @@ public record PreparedListItemLayout( text = text == null ? "" : text; paragraphLayout = Objects.requireNonNull(paragraphLayout, "paragraphLayout"); } + + /** + * Creates a legacy-layout item: no resolved geometry, and a whole item + * rather than the tail of a split one. + * + * @param text raw item text + * @param paragraphLayout prepared paragraph layout for this item + */ + public PreparedListItemLayout(String text, PreparedParagraphLayout paragraphLayout) { + this(text, paragraphLayout, null, true); + } + + /** + * Returns whether this item draws a marker: it has resolved geometry with a + * visible marker, and it is the start of the authored item rather than a + * continuation of one. + * + * @return whether a marker belongs on this slice + */ + public boolean drawsMarker() { + return geometry != null && startsItem && geometry.spec().hasMarker(); + } + + /** + * Returns this item as the remainder of a split, which never repeats the + * marker its head already drew. + * + * @param slicedLayout the paragraph layout of the remaining lines + * @param slicedText the remaining text + * @return a continuation item + */ + public PreparedListItemLayout continuedAs(String slicedText, PreparedParagraphLayout slicedLayout) { + return new PreparedListItemLayout(slicedText, slicedLayout, geometry, false); + } + + /** + * Returns the head of a split, which starts the item exactly when this one + * did. + * + * @param slicedLayout the paragraph layout of the leading lines + * @param slicedText the leading text + * @return a head item + */ + public PreparedListItemLayout startingAs(String slicedText, PreparedParagraphLayout slicedLayout) { + return new PreparedListItemLayout(slicedText, slicedLayout, geometry, startsItem); + } } diff --git a/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java b/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java new file mode 100644 index 000000000..b55556693 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java @@ -0,0 +1,453 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * {@code hangingIndent(true)}: the marker sits in its own column and every + * visual line of an item — the first, the ones it wraps onto, and the ones that + * continue on later pages — starts at one shared content origin. + * + *

The legacy layout approximates that origin separately for each line with a + * run of spaces rounded up to clear the marker, which is why its wrapped lines + * land a fraction of a space width off its first line. Here the origin is one + * number, so there is nothing to drift.

+ * + *

All numbers below are measured at the default 14pt style on a page with a + * 12pt margin, so an item starts at x=12: bullet 4.900 wide, gap 4.0, content + * at 20.900.

+ */ +class ListHangingIndentTest { + + private static final double EPS = 0.001; + + // --- the shared content origin ----------------------------------------- + + @Test + void everyLineOfAWrappedItemStartsAtTheSameContentOrigin() throws Exception { + Rendered rendered = render(165, 240, l -> l.bullet().hangingIndent(true).items( + "Long item text should wrap across several visual lines while keeping one bullet.")); + + List fragments = rendered.fragments(); + assertThat(fragments).as("one marker, one content block").hasSize(2); + + PlacedFragment marker = fragments.get(0); + PlacedFragment content = fragments.get(1); + assertThat(texts(marker)).containsExactly("•"); + assertThat(marker.x()).isEqualTo(12.000, within(EPS)); + assertThat(marker.width()).as("the measured bullet, not a guess").isEqualTo(4.900, within(EPS)); + assertThat(content.x()).isEqualTo(20.900, within(EPS)); + assertThat(content.width()).isEqualTo(132.100, within(EPS)); + + assertThat(texts(content)).hasSizeGreaterThanOrEqualTo(3); + assertThat(texts(content)).as("no marker leaked into the flow").noneMatch(t -> t.contains("•")); + assertThat(texts(content)).as("and no space indent either") + .allMatch(t -> t.isEmpty() || !Character.isWhitespace(t.charAt(0))); + + // The text was wrapped inside the content column, not inside the row and + // then merely drawn there — every line fits the narrower width. + assertThat(lineWidths(content)) + .allSatisfy(w -> assertThat(w).isLessThanOrEqualTo(content.width() + EPS)); + assertThat(lineWidths(content).stream().mapToDouble(Double::doubleValue).max().orElseThrow()) + .as("and at least one line uses most of it, so the bound is real") + .isGreaterThan(content.width() * 0.75); + + // The claim, at the glyphs: every content line drawn from one x. + List glyphs = rendered.glyphs(); + assertThat(glyphs.stream().filter(g -> !g.text().equals("•"))) + .allSatisfy(g -> assertThat(g.x()).isEqualTo(20.900, within(EPS))); + } + + @Test + void theMarkerSharesTheFirstContentLinesBaseline() throws Exception { + Rendered rendered = render(165, 240, l -> l.bullet().hangingIndent(true).items( + "Long item text should wrap across several visual lines here.")); + + List glyphs = rendered.glyphs(); + Glyph marker = glyphs.get(0); + Glyph firstContentLine = glyphs.get(1); + + assertThat(marker.text()).isEqualTo("•"); + assertThat(marker.y()) + .as("the marker is on the first line, not a block of its own beside it") + .isEqualTo(firstContentLine.y(), within(EPS)); + + // ...and it does not make the row any taller. Compared against the same + // text with no marker at all, at a length that wraps to one line either + // way, so the two are the same content and only the marker differs. + double withMarker = listNode(render(200, 240, + l -> l.bullet().hangingIndent(true).items("Item")).graph()).placementHeight(); + double withoutMarker = listNode(render(200, 240, + l -> l.noMarker().hangingIndent(true).items("Item")).graph()).placementHeight(); + assertThat(withMarker) + .as("the marker rides the content's line rather than adding one") + .isEqualTo(withoutMarker, within(EPS)); + + // The two fragments of a row are one box: same top, same height, which + // is what makes the shared baseline structural rather than lucky. + PlacedFragment markerBox = rendered.fragments().get(0); + PlacedFragment contentBox = rendered.fragments().get(1); + assertThat(markerBox.y()).isEqualTo(contentBox.y(), within(EPS)); + assertThat(markerBox.height()).isEqualTo(contentBox.height(), within(EPS)); + } + + // --- markers ----------------------------------------------------------- + + @Test + void eachMarkerGetsItsOwnMeasuredColumn() throws Exception { + assertMarkerAndContent(l -> l.bullet().hangingIndent(true).items("Item"), "•", 4.900, 20.900); + assertMarkerAndContent(l -> l.dash().hangingIndent(true).items("Item"), "-", 4.662, 20.662); + assertMarkerAndContent(l -> l.marker(">").hangingIndent(true).items("Item"), ">", 8.176, 24.176); + assertMarkerAndContent(l -> l.marker("=>").hangingIndent(true).items("Item"), "=>", 16.352, 32.352); + assertMarkerAndContent(l -> l.marker("MMM").hangingIndent(true).items("Item"), "MMM", 34.986, 50.986); + } + + @Test + void theGapIsPointsOfRealSpaceBetweenMarkerAndContent() throws Exception { + for (double gap : List.of(0.0, 4.0, 8.0, 16.0)) { + Rendered rendered = render(200, 240, + l -> l.bullet().hangingIndent(true).markerGap(gap).items("Item")); + List fragments = rendered.fragments(); + + assertThat(fragments.get(0).x()).as("gap %s: the marker never moves", gap) + .isEqualTo(12.000, within(EPS)); + assertThat(fragments.get(1).x()).as("gap %s: content", gap) + .isEqualTo(12.000 + 4.900 + gap, within(EPS)); + assertThat(fragments.get(1).width()).as("gap %s: content width", gap) + .isEqualTo(176.000 - 4.900 - gap, within(EPS)); + } + } + + @Test + void aMarkerlessItemGetsNoMarkerFragmentAndNoGap() throws Exception { + Rendered rendered = render(165, 240, + l -> l.noMarker().hangingIndent(true).markerGap(16).items("Item")); + + assertThat(rendered.fragments()).as("nothing is drawn for a marker that is not there").hasSize(1); + PlacedFragment content = rendered.fragments().get(0); + assertThat(content.x()).as("flush at the item start, not inset by a reserved gap") + .isEqualTo(12.000, within(EPS)); + assertThat(content.width()).isEqualTo(141.000, within(EPS)); + assertThat(rendered.glyphs().get(0).x()).isEqualTo(12.000, within(EPS)); + } + + @Test + void aMarkerOnlyRowKeepsItsPlaceItsMarkerAndItsHeight() throws Exception { + Rendered rendered = render(165, 240, l -> l.bullet().hangingIndent(true).items("Alpha", " ", "Beta")); + + List fragments = rendered.fragments(); + assertThat(fragments).as("three rows, each a marker and a content block").hasSize(6); + assertThat(markerCount(rendered)).isEqualTo(3); + + PlacedFragment emptyRowMarker = fragments.get(2); + PlacedFragment emptyRowContent = fragments.get(3); + assertThat(texts(emptyRowMarker)).containsExactly("•"); + assertThat(texts(emptyRowContent)).as("no invented filler text").containsExactly(""); + assertThat(emptyRowMarker.height()).as("a real row with a real height").isPositive(); + + // The rows are evenly spaced, so the empty one occupies a full row. + double firstToSecond = fragments.get(0).y() - fragments.get(2).y(); + double secondToThird = fragments.get(2).y() - fragments.get(4).y(); + assertThat(firstToSecond).isEqualTo(secondToThird, within(EPS)); + } + + // --- nesting ----------------------------------------------------------- + + @Test + void aChildHangsUnderItsParentsTextAndAGrandchildUnderTheChilds() throws Exception { + Rendered rendered = render(220, 240, l -> l.hangingIndent(true) + .addItem("Top", c -> c.addItem("Child", g -> g.addItem("Grandchild")))); + + List fragments = rendered.fragments(); + assertThat(fragments).hasSize(6); + + double topMarker = fragments.get(0).x(); + double topContent = fragments.get(1).x(); + double childMarker = fragments.get(2).x(); + double childContent = fragments.get(3).x(); + double grandchildMarker = fragments.get(4).x(); + double grandchildContent = fragments.get(5).x(); + + assertThat(topMarker).isEqualTo(12.000, within(EPS)); + assertThat(topContent).isEqualTo(20.900, within(EPS)); + assertThat(childMarker).as("the child's marker starts at the parent's text") + .isEqualTo(topContent, within(EPS)); + assertThat(childContent).isEqualTo(32.684, within(EPS)); + assertThat(grandchildMarker).as("and the grandchild's at the child's") + .isEqualTo(childContent, within(EPS)); + assertThat(grandchildContent).isEqualTo(44.468, within(EPS)); + + // Each depth keeps its own width, narrowing by exactly what it indented. + assertThat(fragments.get(1).width()).isEqualTo(187.100, within(EPS)); + assertThat(fragments.get(3).width()).isEqualTo(175.316, within(EPS)); + assertThat(fragments.get(5).width()).isEqualTo(163.532, within(EPS)); + + // Depth is geometry here, so nothing in the text carries it. + assertThat(fragments).allSatisfy(f -> assertThat(texts(f)) + .allMatch(t -> !t.contains(" ") && !t.startsWith(" "))); + } + + // --- page splits -------------------------------------------------------- + + @Test + void anItemCrossingThreePageBoundariesDrawsItsMarkerOnceAndNeverMovesItsContent() throws Exception { + Rendered rendered = render(150, 70, l -> l.bullet().hangingIndent(true).items( + "Long item text should wrap across many visual lines so that it has to cross two page " + + "boundaries and continue on the following pages with its indent intact and unchanged.")); + + assertThat(rendered.graph().totalPages()).isEqualTo(4); + assertThat(markerCount(rendered)) + .as("the marker belongs to the item, not to each page of it") + .isEqualTo(1); + + // Page 0 opens with the marker; every later page is content alone — and + // is fragment index 0 on its own page, which is exactly why marker + // emission cannot be inferred from that index. + assertThat(rendered.fragments().stream().filter(f -> f.pageIndex() == 0)).hasSize(2); + for (int page = 1; page <= 3; page++) { + List onPage = onPage(rendered, page); + assertThat(onPage).as("page %d carries content only", page).hasSize(1); + assertThat(texts(onPage.get(0))).noneMatch(t -> t.contains("•")); + assertThat(onPage.get(0).fragmentIndex()) + .as("page %d restarts fragment numbering at zero", page) + .isZero(); + } + + // One content origin and one content width, on all four pages. + for (int page = 0; page <= 3; page++) { + PlacedFragment content = onPage(rendered, page).stream() + .filter(f -> !texts(f).equals(List.of("•"))) + .findFirst() + .orElseThrow(); + assertThat(content.x()).as("page %d contentX", page).isEqualTo(20.900, within(EPS)); + assertThat(content.width()).as("page %d contentWidth", page).isEqualTo(117.100, within(EPS)); + assertThat(lineWidths(content)).as("page %d wraps inside the content column", page) + .allSatisfy(w -> assertThat(w).isLessThanOrEqualTo(117.100 + EPS)); + } + + // ...and at the glyphs, on every page. + assertThat(rendered.glyphs().stream().filter(g -> !g.text().equals("•"))) + .allSatisfy(g -> assertThat(g.x()).isEqualTo(20.900, within(EPS))); + } + + // --- alignment ---------------------------------------------------------- + + @Test + void centreAndRightAlignTextInsideTheContentColumnWithoutMovingTheMarker() throws Exception { + for (TextAlign align : List.of(TextAlign.CENTER, TextAlign.RIGHT)) { + Rendered rendered = render(200, 240, l -> l.bullet().hangingIndent(true).align(align) + .items("Short")); + + List fragments = rendered.fragments(); + assertThat(fragments.get(0).x()).as("%s: the marker column is geometry", align) + .isEqualTo(12.000, within(EPS)); + assertThat(fragments.get(1).x()).as("%s: so is the content column", align) + .isEqualTo(20.900, within(EPS)); + assertThat(fragments.get(1).width()).isEqualTo(167.100, within(EPS)); + + List glyphs = rendered.glyphs(); + assertThat(glyphs.get(0).x()).as("%s: marker unmoved", align).isEqualTo(12.000, within(EPS)); + assertThat(glyphs.get(1).x()) + .as("%s: text placed inside the content column, not at its start", align) + .isGreaterThan(20.900); + assertThat(glyphs.get(1).x() + 33.460) + .as("%s: and inside its right edge", align) + .isLessThanOrEqualTo(20.900 + 167.100 + EPS); + } + } + + // --- containers --------------------------------------------------------- + + @Test + void paddingMovesTheWholeGeometryInAndMarginMovesTheBox() throws Exception { + Rendered padded = render(200, 240, l -> l.bullet().hangingIndent(true) + .padding(3, 5, 7, 11).items("Item")); + Rendered margined = render(200, 240, l -> l.bullet().hangingIndent(true) + .margin(3, 5, 7, 11).items("Item")); + + // Both put the marker 11pt in from the page margin, by different routes. + assertThat(padded.fragments().get(0).x()).isEqualTo(23.000, within(EPS)); + assertThat(margined.fragments().get(0).x()).isEqualTo(23.000, within(EPS)); + assertThat(padded.fragments().get(1).x()).isEqualTo(31.900, within(EPS)); + assertThat(margined.fragments().get(1).x()).isEqualTo(31.900, within(EPS)); + + assertThat(padded.glyphs().get(0).x()).isEqualTo(23.000, within(EPS)); + assertThat(margined.glyphs().get(0).x()).isEqualTo(23.000, within(EPS)); + } + + /** + * The narrow-container contract, stated rather than inherited. + * + *

When the marker column alone is wider than the row, the marker is still + * drawn at its measured width and overflows; the content is floored at + * {@code ParagraphWrapping.MIN_TEXT_WIDTH} — 1pt, the same floor the text + * pipeline applies whenever a prefix eats a line — and is broken as far as it + * will go, overflowing too. Nothing throws, and nothing is silently dropped. + * The alternative, letting the width reach zero, hits the engine's + * zero-width behaviour, where a line becomes empty and its text disappears; + * that is the outcome this floor exists to avoid.

+ */ + @Test + void aMarkerWiderThanTheRowOverflowsAndTheTextSurvives() throws Exception { + double page = 60.0; + Rendered rendered = render(page, 240, l -> l.marker("MMMMMMMMMM").hangingIndent(true) + .markerGap(16).items("Content")); + + List fragments = rendered.fragments(); + PlacedFragment marker = fragments.get(0); + PlacedFragment content = fragments.get(1); + double available = page - 24.0; + + assertThat(marker.width()).as("drawn at its measured width, unclipped") + .isGreaterThan(available); + assertThat(content.width()).as("floored at the engine's minimum text width") + .isEqualTo(1.0, within(EPS)); + assertThat(content.x()).as("still placed past the marker and its gap") + .isGreaterThan(12.000 + available); + + // The text is broken up and overflows, but every character of it is + // still there — no empty line, no exception. + String rendered1 = String.join("", texts(content)); + assertThat(rendered1).isEqualTo("Content"); + assertThat(texts(content)).as("one character per line at this width").hasSize(7); + } + + // --- the legacy layout is a different layout --------------------------- + + @Test + void optingInMovesTextAndLeavingItAloneDoesNot() throws Exception { + Consumer shape = l -> l.bullet().items( + "Long item text should wrap across several visual lines here."); + + Rendered legacy = render(165, 240, shape); + Rendered hanging = render(165, 240, shape.andThen(l -> l.hangingIndent(true))); + + // Legacy: the marker is inside the text and the wrapped lines carry + // spaces measured to clear it, landing them past the first line's text. + assertThat(legacy.fragments()).hasSize(1); + assertThat(texts(legacy.fragments().get(0)).get(0)).startsWith("• "); + assertThat(texts(legacy.fragments().get(0)).get(1)).startsWith(" "); + + // Hanging: a marker column, a content column, one origin. + assertThat(hanging.fragments()).hasSize(2); + assertThat(texts(hanging.fragments().get(1))).noneMatch(t -> t.startsWith(" ")); + + double legacyFirstLineText = legacy.glyphs().get(0).textX(); + double legacyWrappedText = legacy.glyphs().get(1).textX(); + assertThat(legacyWrappedText - legacyFirstLineText) + .as("the legacy drift this replaces") + .isEqualTo(2.884, within(0.05)); + + assertThat(hanging.glyphs().get(1).x() - hanging.glyphs().get(2).x()) + .as("and its absence") + .isEqualTo(0.0, within(EPS)); + } + + // ------------------------------------------------------------------ + + private static void assertMarkerAndContent(Consumer spec, + String markerText, + double markerWidth, + double contentX) throws Exception { + Rendered rendered = render(200, 240, spec); + List fragments = rendered.fragments(); + assertThat(texts(fragments.get(0))).as("%s marker", markerText).containsExactly(markerText); + assertThat(fragments.get(0).width()).as("%s width", markerText).isEqualTo(markerWidth, within(EPS)); + assertThat(fragments.get(1).x()).as("%s contentX", markerText).isEqualTo(contentX, within(EPS)); + } + + private record Rendered(LayoutGraph graph, List glyphs) { + List fragments() { + return graph.fragments().stream() + .filter(f -> f.payload() instanceof ParagraphFragmentPayload) + .toList(); + } + } + + private record Glyph(int page, double x, double textX, double y, String text) { + } + + private static Rendered render(double width, double height, Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(width, height) + .margin(DocumentInsets.of(12)) + .create()) { + session.pageFlow().name("Root").addList(spec).build(); + return new Rendered(session.layoutGraph(), glyphs(session.toPdfBytes())); + } + } + + private static List onPage(Rendered rendered, int page) { + return rendered.fragments().stream().filter(f -> f.pageIndex() == page).toList(); + } + + private static long markerCount(Rendered rendered) { + return rendered.fragments().stream() + .filter(f -> texts(f).equals(List.of("•"))) + .count(); + } + + private static List lineWidths(PlacedFragment fragment) { + return ((ParagraphFragmentPayload) fragment.payload()).lines().stream() + .map(ParagraphLine::width) + .toList(); + } + + private static List texts(PlacedFragment fragment) { + return ((ParagraphFragmentPayload) fragment.payload()).lines().stream() + .map(ParagraphLine::text) + .toList(); + } + + private static PlacedNode listNode(LayoutGraph graph) { + return graph.nodes().stream() + .filter(n -> "ListNode".equals(n.nodeKind())) + .findFirst() + .orElseThrow(); + } + + private static List glyphs(byte[] pdf) throws IOException { + List rows = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(pdf)) { + PDFTextStripper stripper = new PDFTextStripper() { + @Override + protected void writeString(String text, List positions) { + if (positions.isEmpty()) { + return; + } + double textX = positions.stream() + .filter(p -> !p.getUnicode().isEmpty() + && Character.isLetterOrDigit(p.getUnicode().charAt(0))) + .mapToDouble(TextPosition::getXDirAdj) + .findFirst() + .orElse(positions.get(0).getXDirAdj()); + rows.add(new Glyph(getCurrentPageNo() - 1, positions.get(0).getXDirAdj(), + textX, positions.get(0).getYDirAdj(), text)); + } + }; + stripper.setSortByPosition(true); + stripper.getText(document); + } + return rows; + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java index 3c2008b72..e07f145b9 100644 --- a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java @@ -259,12 +259,12 @@ void theModelSurvivesNestingThroughThePipeline() throws Exception { } @Test - void optingInChangesNothingYet() throws Exception { - // The acceptance condition for this phase, stated as an equation. The - // model is in place and the geometry is not, so the two strategies still - // produce the same lines in the same places. The pass that measures the - // marker is what makes this test change — deliberately, and with the - // frozen legacy dump left untouched beside it. + void notOptingInLeavesEveryShapeExactlyWhereItWas() throws Exception { + // The compatibility half of the contract. The marker/content layout now + // renders its own geometry, so the two are no longer interchangeable — + // what has to stay true is that a list which never asked for it is + // untouched, shape by shape. Where it lands when it does ask is + // ListHangingIndentTest's subject. for (Consumer shape : List.>of( l -> l.bullet().items("Java", "SQL"), l -> l.dash().items("Long item text that wraps across more than one visual line here."), @@ -274,10 +274,28 @@ void optingInChangesNothingYet() throws Exception { List legacy = renderedLines(shape); assertThat(legacy).as("a shape that renders nothing would prove nothing").isNotEmpty(); - assertThat(renderedLines(shape.andThen(l -> l.hangingIndent(true)))) - .as("opting in must not move anything yet") + assertThat(renderedLines(shape.andThen(l -> l.hangingIndent(false)))) + .as("saying no explicitly is the same as not saying anything") .isEqualTo(legacy); } + + // Where there is a marker, opting in is a different layout — the marker + // leaves the text and the wrapped lines lose their space indent. + for (Consumer marked : List.>of( + l -> l.bullet().items("Java", "SQL"), + l -> l.dash().items("Long item text that wraps across more than one visual line here."), + l -> l.marker("=>").items("Custom"), + l -> l.addItem("Top", c -> c.addItem("Child", g -> g.addItem("Grandchild"))))) { + + assertThat(renderedLines(marked.andThen(l -> l.hangingIndent(true)))) + .isNotEqualTo(renderedLines(marked)); + } + + // A list with no marker has nothing to hang, so the two layouts agree — + // and that agreement is a property worth stating, not an oversight. + Consumer markerless = l -> l.noMarker().items("Plain"); + assertThat(renderedLines(markerless.andThen(l -> l.hangingIndent(true)))) + .isEqualTo(renderedLines(markerless)); } // ------------------------------------------------------------------ diff --git a/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java b/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java new file mode 100644 index 000000000..ef42f4290 --- /dev/null +++ b/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java @@ -0,0 +1,76 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +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.testing.visual.PdfVisualRegression; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * The one thing geometry assertions cannot show: what a hanging indent looks + * like. Three markers of visibly different widths, each on an item long enough + * to wrap three or more times, so the alignment of the wrapped lines under their + * own first line is the picture rather than a number. + * + *

Deliberately a small page. A full-page baseline drifts across platforms by + * more than the signal it is supposed to carry; a tight one does not.

+ */ +class ListHangingIndentVisualTest { + + private static final DocumentColor INK = DocumentColor.rgb(28, 36, 52); + private static final DocumentColor PAPER = DocumentColor.rgb(253, 252, 250); + + @Test + void wrappedLinesHangUnderTheirOwnFirstLineWhateverTheMarkerWidth() throws Exception { + byte[] pdf = sheet(); + PdfVisualRegression.standard().assertMatchesBaseline("list-hanging-indent", pdf); + + Path out = Path.of("target/visual-tests/list-hanging-indent/list-hanging-indent.pdf"); + Files.createDirectories(out.getParent()); + Files.write(out, pdf); + } + + private static byte[] sheet() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(240, 260) + .pageBackground(PAPER) + .margin(DocumentInsets.of(16)) + .create()) { + document.pageFlow() + .name("HangingIndentSheet") + .spacing(10) + .addList(l -> l + .name("Bullet") + .bullet() + .hangingIndent(true) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(INK)) + .itemSpacing(4) + .items("Every wrapped line of this item begins directly beneath the first " + + "word of its own first line, not beneath the bullet.")) + .addList(l -> l + .name("Dash") + .dash() + .hangingIndent(true) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(INK)) + .itemSpacing(4) + .items("A dash is narrower than a bullet, so its content column starts " + + "further left, and its wrapped lines follow it there.")) + .addList(l -> l + .name("Wide") + .marker("=>") + .hangingIndent(true) + .markerGap(6) + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(INK)) + .itemSpacing(4) + .items("A wide marker pushes the whole content column right by exactly its " + + "own measured width plus the gap, and the wrapped lines move with it.")) + .build(); + return document.toPdfBytes(); + } + } +} diff --git a/qa/src/test/resources/visual-baselines/list-hanging-indent-page-0.png b/qa/src/test/resources/visual-baselines/list-hanging-indent-page-0.png new file mode 100644 index 0000000000000000000000000000000000000000..e13c2ca33c5b4bb6cd5ed36c30b0236ae53a4efa GIT binary patch literal 14062 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 Date: Thu, 10 Sep 2026 16:52:43 +0100 Subject: [PATCH 6/7] fix(list): measure a split list's own marker column, and stop an invisible row leaving a stale level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 4d68d0cd 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. --- CHANGELOG.md | 10 + .../readme/examples/nested-list-showcase.pdf | Bin 2374 -> 3071 bytes .../compose/document/dsl/ListBuilder.java | 26 ++- .../document/layout/ListItemNormalizer.java | 13 +- .../document/layout/ListMarkerGeometry.java | 12 +- .../document/layout/TextFlowSupport.java | 9 +- .../compose/document/node/ListNode.java | 14 +- .../architecture/backend-capability-matrix.md | 1 + docs/recipes/docx-export.md | 19 ++ docs/recipes/lists.md | 43 ++++ examples/README.md | 2 +- .../features/lists/NestedListExample.java | 43 +++- .../document/api/ListHangingIndentTest.java | 36 +++ .../layout/ListItemLayoutModelTest.java | 30 ++- .../layout/ListMarkerContentCostTest.java | 209 ++++++++++++++++++ .../docx/DocxHangingIndentIsIgnoredTest.java | 182 +++++++++++++++ .../fixed/pptx/PptxListHangingIndentTest.java | 85 +++++++ 17 files changed, 716 insertions(+), 18 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/layout/ListMarkerContentCostTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxListHangingIndentTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ba01e6b9d..91083c3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,16 @@ follow semantic versioning; release dates are ISO 8601. 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 diff --git a/assets/readme/examples/nested-list-showcase.pdf b/assets/readme/examples/nested-list-showcase.pdf index 6f5a970441714a34699a6707a1e67355579daa74..090b8725f4c8b0aba9eb0d4d513cf621e72facc9 100644 GIT binary patch delta 2455 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 amlnY8gQAMxNI@hGjEp6Applies 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.

+ * 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.

+ * + *

Fixed-layout only. 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.

* * @param hangingIndent whether items use marker/content geometry * @return this builder @@ -304,6 +322,12 @@ public ListBuilder hangingIndent(boolean hangingIndent) { *

Real geometry, never spaces. A markerless item takes no gap at all, * rather than an unexplained inset.

* + *

Fixed-layout only, 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.

+ * * @param markerGap gap in points; {@code 0} is allowed * @return this builder * @throws IllegalArgumentException when {@code markerGap} is negative, NaN or infinite diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java index b81c40e25..9fb9aa229 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java +++ b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java @@ -77,13 +77,18 @@ private static void normalizeNested(ListNode node, ? item.marker() : ListMarker.defaultForDepth(depth); String content = ListMarker.normalizeItemText(item.label(), node.normalizeMarkers()); - if (rendersSomething(marker, content)) { + boolean rendered = rendersSomething(marker, content); + if (rendered) { out.add(new ListItemSpec(depth, marker, content)); } - // Children are walked either way: an empty label is a reason to skip - // that one row, never a reason to lose the sub-tree hanging off it. + // 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(), depth + 1, out); + normalizeNested(node, item.children(), rendered ? depth + 1 : depth, out); } } } diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java index 0f3b03c91..a7316b130 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java +++ b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java @@ -8,7 +8,9 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static com.demcha.compose.document.layout.DocumentNodeAdapters.toTextStyle; @@ -57,6 +59,11 @@ static List resolve(List specs, // contentX of the most recent item at each depth, which is where a child // of that item hangs its own marker. double[] contentXByDepth = new double[8]; + // A list normally shows the same handful of markers over and over — one + // per depth — so each distinct one is measured once for the whole list + // rather than once per row. The measurement system caches too, but that + // still costs a lookup per item, and there is nothing to look up here. + Map markerWidths = new HashMap<>(); for (ListItemSpec spec : specs) { int depth = spec.depth(); @@ -68,7 +75,10 @@ static List resolve(List specs, double markerX = depth == 0 ? 0.0 : contentXByDepth[depth - 1]; boolean hasMarker = spec.hasMarker(); - double markerWidth = hasMarker ? measurement.textWidth(style, spec.markerText()) : 0.0; + double markerWidth = hasMarker + ? markerWidths.computeIfAbsent(spec.markerText(), + text -> measurement.textWidth(style, text)) + : 0.0; double gap = hasMarker ? node.markerGap() : 0.0; double contentX = markerX + markerWidth + gap; // Floored at the width the text pipeline already treats as its diff --git a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index eb2e26706..58a4a7345 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -582,7 +582,14 @@ private static PreparedNode sliceListPreparedNode(ListNode source, boolean keepTopInsets, boolean keepBottomInsets) { List safeItems = List.copyOf(items); - double maxLineWidth = maxListLineWidth(safeItems); + // A slice is the same list with fewer rows, so it measures the way the + // whole list did. Under marker/content that means counting the marker + // column the rows are placed into — measuring only their text would give + // the slice a box narrower than what it draws, and the text would hang + // past its own right edge as soon as a list paginated. + double maxLineWidth = safeItems.isEmpty() || safeItems.get(0).geometry() == null + ? maxListLineWidth(safeItems) + : markerContentMaxLineWidth(safeItems); double totalHeight = listItemsHeight(safeItems, source.itemSpacing()); DocumentInsets padding = new DocumentInsets( keepTopInsets ? source.padding().top() : 0.0, diff --git a/core/src/main/java/com/demcha/compose/document/node/ListNode.java b/core/src/main/java/com/demcha/compose/document/node/ListNode.java index 9afd1eb65..04730a3cb 100644 --- a/core/src/main/java/com/demcha/compose/document/node/ListNode.java +++ b/core/src/main/java/com/demcha/compose/document/node/ListNode.java @@ -24,11 +24,14 @@ * defaults set on {@link com.demcha.compose.document.dsl.ListBuilder}. * * - *

When {@code nestedItems} is non-empty, the layout pipeline - * flattens the tree depth-first into indent-prefixed paragraph - * fragments and the top-level {@code marker} / {@code items} fields - * are ignored. When {@code nestedItems} is empty, the node behaves - * exactly like the v1.5 flat list.

+ *

Both shapes are laid out one of two ways, chosen by + * {@code hangingIndent}. Left unset — the default — a nested tree is + * flattened depth-first into indent-prefixed paragraph fragments with the + * top-level {@code marker} / {@code items} fields ignored, and a flat list + * behaves exactly as it did in v1.5. Set, the item's marker, depth and + * content are kept apart and given real geometry: a marker column and a + * content column every visual line of the item shares. See + * {@link com.demcha.compose.document.dsl.ListBuilder#hangingIndent(boolean)}.

* * @param name optional semantic name used in snapshots and diagnostics * @param items item texts in source order — used when {@code nestedItems} is empty @@ -150,6 +153,7 @@ public ListNode(String name, * @param normalizeMarkers whether leading user-supplied bullets or dashes are stripped * @param padding inner list padding * @param margin outer list margin + * @since 2.4.0 */ public ListNode(String name, List items, diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index f7149226b..fdb1cc69d 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -55,6 +55,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; `linkTarget` is still dropped | +| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` writes one paragraph per item with the marker in its text, identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker — which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column | | Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 8bdb898cf..9c5b61c0b 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -64,6 +64,25 @@ subject, keywords) carry into the Word document as well. of a graphics-state path clip, so the container's layers are written inline, in source order, without the outline frame and without clipping — again with one warning per export. +- **`hangingIndent(true)` → the ordinary list form.** A list that opts + into marker/content geometry 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 — same paragraphs, same text, same + nesting — but wrapped lines align the way Word aligns them rather than + the way the PDF does, and `markerGap` has no effect here. + + This is a decision rather than an omission. Word places content at + absolute indents and has no way to be told "start the text one marker + width plus a gap from here", so every mechanism that looks like it + would — a hanging indent, a hanging indent with a tab stop, real Word + numbering — leaves a distance beside the marker equal to the column + minus the marker's own width, a number only Word knows. Honouring the + gap would mean measuring the marker, and this backend has no font + runtime to measure with: its dependencies are the core model and POI, + and keeping them that way is the point of a semantic backend. The + approximations were built and rendered through Word before being + rejected — a reserved column renders a gap that is not the one + configured, and a marker wider than the column misaligns outright. ## What is skipped diff --git a/docs/recipes/lists.md b/docs/recipes/lists.md index 4adc57a79..1decccada 100644 --- a/docs/recipes/lists.md +++ b/docs/recipes/lists.md @@ -57,6 +57,49 @@ space is added automatically. For markerless lists, `continuationIndent(" ")` sets the prefix used only on wrapped continuation lines, keeping hanging indents readable. +## Hanging indent + +By default the marker is part of the item's text, and a wrapped line is +indented 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 — about 2.9pt at the default style, which reads as +ragged in a CV or a report. + +`hangingIndent(true)` replaces that with a marker column and a content +column, so every visual line of an item starts at the same place — the +first, the ones it wraps onto, and the ones that continue on the next +page: + +```java +section.addList(list -> list + .bullet() + .hangingIndent(true) + .markerGap(6) // points between marker and text + .items("A long item whose wrapped lines all begin directly " + + "beneath the first word of its own first line.")); +``` + +``` +• A long item whose wrapped + lines all begin directly + beneath the first word… +``` + +The marker is measured, so a bullet, a dash and `=>` each get the column +they actually need; `markerGap` defaults to 4pt. A markerless item takes +no marker width and no gap — it starts flush, rather than at an inset +with nothing in it. Nested lists indent as an outline: a child's marker +starts where its parent's *text* starts. `CENTER` and `RIGHT` align text +inside the content column and leave the marker where it is. + +It is off by default and stays off; existing lists are untouched. + +> **Fixed-layout only.** PDF and PPTX honour this. The semantic DOCX +> export writes one Word paragraph per item and lets Word lay it out, so +> it keeps the marker in the text and exports the same either way — same +> paragraphs, same text, same nesting. See +> [DOCX export](docx-export.md). + ## Nested lists `addItem(label, body)` opens a child scope: every `addItem` inside the diff --git a/examples/README.md b/examples/README.md index 653beb19a..6652854de 100644 --- a/examples/README.md +++ b/examples/README.md @@ -122,7 +122,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Inline SVG icons](#inline-svg-icons) | `RichText.svgIcon(icon, size)` — a parsed multi-colour `SvgIcon` on the text baseline, crisp at any zoom and carrying its own colours | [PDF](../assets/readme/examples/inline-svg-icons.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java) | | [Colour emoji](#colour-emoji) | `RichText.emoji(":star:", size)` — GitHub-style shortcodes resolve to inline vector glyphs via the `graph-compose-emoji` artifact; unknown codes fall back to literal text | [PDF](../assets/readme/examples/emoji-shortcodes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java) | | [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) | -| Nested lists | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) | +| Nested lists | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring, plus `hangingIndent(true)` + `markerGap(...)` giving the marker its own measured column so wrapped lines hang under their own first line | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) | | Composed table cells | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables, sections and rows inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) | | [Inline-code column wrap](#inline-code-column-wrap) | A long `inlineCode(...)` coordinate breaks at its `. : / -` seams inside a narrow **fixed** column and an **auto** column grows to fit it on one line | [PDF](../assets/readme/examples/inline-code-column-wrap.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java) | | Canvas layer (free placement) | `CanvasLayerNode` — pixel-precise `(x, y)` placement of children inside a fixed bounding box, with `ClipPolicy` clipping | [PDF](../assets/readme/examples/canvas-layer-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java) | diff --git a/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java b/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java index 5a75f173a..d028e79d1 100644 --- a/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java +++ b/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java @@ -130,7 +130,7 @@ public static Path generate() throws Exception { .addItem("Ran mvnw verify locally")) .addItem("Closed bug: marker double-space rendering") .addItem("Triaged backlog", triage -> triage - .addItem("Deferred: hanging indent on wrapped items") + .addItem("Shipped: hanging indent on wrapped items") .addItem("CanvasLayerNode parked"))) // 4) Deep nesting (depth 4+) falls back to the · cascade. @@ -149,6 +149,47 @@ public static Path generate() throws Exception { .addItem("Step", l3 -> l3 .addItem("Sub-step (depth 4)") .addItem("Another sub-step at depth 4")))))) + + // 5) hangingIndent(true) — a marker column and a content column. + .addParagraph("5. hangingIndent(true) + markerGap(points)", sectionHeading) + .addParagraph( + "Off by default, a wrapped line is indented with spaces measured to " + + "clear the marker, which lands it a fraction of a space past the " + + "first line's text. Opting in gives the marker its own measured " + + "column, so every line of an item — including one that continues " + + "on the next page — starts at one content origin.", caption) + .addList(list -> list + .name("HangingOff") + .textStyle(body) + .itemSpacing(2) + .items("Default: this wrapped item indents its continuation lines with a " + + "run of spaces, so they sit slightly right of the first line's text.")) + .addList(list -> list + .name("HangingOn") + .textStyle(body) + .itemSpacing(2) + .hangingIndent(true) + .items("hangingIndent(true): every line of this item begins directly " + + "beneath the first word of its own first line, however many " + + "lines it takes and wherever the page break falls.")) + .addList(list -> list + .name("HangingWideMarker") + .textStyle(body) + .itemSpacing(2) + .marker("=>") + .hangingIndent(true) + .markerGap(6) + .items("A wider marker takes a wider column — measured, not guessed — " + + "and the wrapped lines follow it there.")) + .addList(list -> list + .name("HangingNested") + .textStyle(body) + .itemSpacing(2) + .hangingIndent(true) + .markerFor(1, ListMarker.dash()) + .addItem("Nested lists indent as an outline", child -> child + .addItem("A child's marker starts where its parent's text starts, " + + "and its own wrapped lines hang under its own text."))) .build(); document.buildPdf(); } diff --git a/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java b/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java index b55556693..c57f55a6b 100644 --- a/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java +++ b/qa/src/test/java/com/demcha/compose/document/api/ListHangingIndentTest.java @@ -250,6 +250,36 @@ void anItemCrossingThreePageBoundariesDrawsItsMarkerOnceAndNeverMovesItsContent( .allSatisfy(g -> assertThat(g.x()).isEqualTo(20.900, within(EPS))); } + @Test + void aSplitListStillMeasuresItselfWideEnoughForItsMarkerColumn() throws Exception { + // A slice is the same list with fewer rows, so it must measure the same + // way: its box has to include the marker column it draws into. Measuring + // only the text would leave the box narrower than the content it places, + // and the text would hang past its own right edge. + Rendered rendered = render(150, 70, l -> l.bullet().hangingIndent(true).items( + "Long item text should wrap across many visual lines so that it has to cross " + + "a page boundary and continue on the following page.")); + + assertThat(rendered.graph().totalPages()).isGreaterThan(1); + for (PlacedNode node : listNodes(rendered.graph())) { + // The right edge of the text actually drawn on this page — the box + // has to reach it, which it cannot if the slice measured the text + // alone and forgot the column it is placed into. + double drawnRight = onPage(rendered, node.startPage()).stream() + .mapToDouble(f -> f.x() + lineWidths(f).stream() + .mapToDouble(Double::doubleValue).max().orElse(0.0)) + .max() + .orElseThrow(); + assertThat(node.placementX() + node.placementWidth()) + .as("page %d: the list box must reach the right edge of its own text", + node.startPage()) + .isGreaterThanOrEqualTo(drawnRight - EPS); + assertThat(node.placementWidth()) + .as("page %d: and the box includes the marker column", node.startPage()) + .isGreaterThan(117.100); + } + } + // --- alignment ---------------------------------------------------------- @Test @@ -419,6 +449,12 @@ private static List texts(PlacedFragment fragment) { .toList(); } + private static List listNodes(LayoutGraph graph) { + return graph.nodes().stream() + .filter(n -> "ListNode".equals(n.nodeKind())) + .toList(); + } + private static PlacedNode listNode(LayoutGraph graph) { return graph.nodes().stream() .filter(n -> "ListNode".equals(n.nodeKind())) diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java index e07f145b9..22e7725e2 100644 --- a/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListItemLayoutModelTest.java @@ -175,11 +175,33 @@ void anEmptyItemWithNoMarkerDrawsNothingAndIsOmitted() { .extracting(ListItemSpec::content) .containsExactly("Java", "SQL"); - assertThat(ListItemNormalizer.normalize(list(l -> l + // ...and a row that drew nothing is not a level: its children hang where + // it would have hung, rather than inset under a row nobody can see. + List orphaned = ListItemNormalizer.normalize(list(l -> l .markerFor(0, ListMarker.none()) - .addItem("", c -> c.addItem("Child survives"))))) - .extracting(ListItemSpec::content) - .containsExactly("Child survives"); + .addItem("", c -> c.addItem("Child survives")))); + assertThat(orphaned).extracting(ListItemSpec::content).containsExactly("Child survives"); + assertThat(orphaned.get(0).depth()) + .as("promoted to the invisible parent's own level") + .isZero(); + } + + @Test + void anInvisibleRowDoesNotLeaveItsDepthPointingAtAnEarlierSubtree() { + // The depth cascade resolves a child's marker origin from the most recent + // row one level up. A row that contributes nothing must not leave that + // level holding a value from a different branch — a grandchild would + // inherit an unrelated sibling's origin and sit far to its right. + List specs = ListItemNormalizer.normalize(list(l -> l + .markerFor(1, ListMarker.none()) + .addItem("A", a -> a.addItem("A1")) + .addItem("B", b -> b.addItem("", hidden -> hidden.addItem("B1a"))))); + + assertThat(specs).extracting(ListItemSpec::content) + .containsExactly("A", "A1", "B", "B1a"); + assertThat(specs.get(3).depth()) + .as("B1a hangs under B, at the level the dropped row would have held") + .isEqualTo(1); } @Test diff --git a/qa/src/test/java/com/demcha/compose/document/layout/ListMarkerContentCostTest.java b/qa/src/test/java/com/demcha/compose/document/layout/ListMarkerContentCostTest.java new file mode 100644 index 000000000..aba00e524 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/layout/ListMarkerContentCostTest.java @@ -0,0 +1,209 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory; +import com.demcha.compose.document.dsl.ListBuilder; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.PreparedListLayout; +import com.demcha.compose.document.node.ListNode; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.components.geometry.ContentSize; +import com.demcha.compose.engine.components.style.Margin; +import com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem; +import com.demcha.compose.engine.measurement.TextMeasurementSystem; +import com.demcha.compose.engine.render.pdf.PdfFont; +import com.demcha.compose.font.FontLibrary; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What the marker/content layout costs, stated as counts rather than timings. + * + *

Two things are worth holding: the marker is measured once for the list, not + * once per row, and resolving a list of N items stays linear in N. Both are + * structural, so they can be asserted exactly instead of benchmarked and + * argued about.

+ */ +class ListMarkerContentCostTest { + + @Test + void theMarkerIsMeasuredOncePerDistinctMarkerNotOncePerItem() throws Exception { + try (Probe probe = new Probe()) { + probe.prepare(l -> l.bullet().hangingIndent(true).items(items(200))); + + assertThat(probe.measuredTexts("•")) + .as("200 rows, one bullet measurement") + .isEqualTo(1); + } + } + + @Test + void aNestedListMeasuresEachDepthsMarkerOnceNotOncePerRowAtThatDepth() throws Exception { + try (Probe probe = new Probe()) { + probe.prepare(l -> l.hangingIndent(true) + .addItem("Top", c -> { + for (int i = 0; i < 50; i++) { + c.addItem("Child " + i); + } + })); + + assertThat(probe.measuredTexts("•")).as("one top-level marker").isEqualTo(1); + assertThat(probe.measuredTexts("◦")).as("fifty children, one measurement").isEqualTo(1); + } + } + + @Test + void resolvingStaysLinearInTheNumberOfItems() throws Exception { + // A quadratic resolver — one that searched back for each item's parent, + // say — would show up here as a super-linear growth in measured work. + try (Probe small = new Probe(); Probe large = new Probe()) { + small.prepare(l -> l.bullet().hangingIndent(true).items(items(100))); + large.prepare(l -> l.bullet().hangingIndent(true).items(items(400))); + + double ratio = (double) large.totalMeasurements() / small.totalMeasurements(); + assertThat(ratio) + .as("4x the items should be about 4x the work, not 16x (was %s → %s)", + small.totalMeasurements(), large.totalMeasurements()) + .isLessThan(6.0); + } + } + + @Test + void aMarkedRowEmitsTwoFragmentsAndAMarkerlessRowEmitsOne() throws Exception { + // The cost of the layout, stated plainly: a marker column is a second + // fragment per row. It is not a second pagination unit and not a second + // measurement — but it is a fragment, and that is worth knowing. + assertThat(fragmentCount(l -> l.bullet().hangingIndent(true).items("A", "B", "C"))) + .isEqualTo(6); + assertThat(fragmentCount(l -> l.noMarker().hangingIndent(true).items("A", "B", "C"))) + .isEqualTo(3); + assertThat(fragmentCount(l -> l.bullet().items("A", "B", "C"))) + .as("the legacy layout is unchanged at one per row") + .isEqualTo(3); + } + + @Test + void theLegacyLayoutResolvesNoGeometryAtAll() throws Exception { + try (Probe probe = new Probe()) { + PreparedListLayout layout = probe.prepare(l -> l.bullet().items(items(50))); + assertThat(layout.markerContentItems()) + .as("nothing is normalized or measured for a list that did not ask") + .isEmpty(); + } + } + + // ------------------------------------------------------------------ + + private static List items(int count) { + List out = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + out.add("Item " + i); + } + return out; + } + + private static int fragmentCount(Consumer spec) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 800) + .margin(DocumentInsets.of(12)) + .create()) { + session.pageFlow().name("Root").addList(spec).build(); + return (int) session.layoutGraph().fragments().stream() + .filter(f -> f.payload() instanceof ParagraphFragmentPayload) + .count(); + } + } + + /** A prepare pass whose text measurements are counted. */ + private static final class Probe implements AutoCloseable { + private final PDDocument document = new PDDocument(); + private final FontLibrary fonts; + private final Counting measurement; + + private Probe() throws Exception { + this.fonts = PdfFontLibraryFactory.library(document); + this.measurement = new Counting(new FontLibraryTextMeasurementSystem(fonts, PdfFont.class)); + } + + private PreparedListLayout prepare(Consumer spec) { + ListBuilder builder = new ListBuilder().name("L"); + spec.accept(builder); + ListNode node = builder.build(); + return TextFlowSupport.prepareList(node, context(), new BoxConstraints(376.0, 776.0)) + .requirePreparedLayout(PreparedListLayout.class); + } + + private int measuredTexts(String text) { + return (int) measurement.seen.stream().filter(text::equals).count(); + } + + private int totalMeasurements() { + return measurement.seen.size(); + } + + private PrepareContext context() { + return new PrepareContext() { + @Override + public PreparedNode prepare( + E node, BoxConstraints constraints) { + throw new UnsupportedOperationException("a list leaf prepares no children"); + } + + @Override + public FontLibrary fonts() { + return fonts; + } + + @Override + public TextMeasurementSystem textMeasurement() { + return measurement; + } + + @Override + public LayoutCanvas canvas() { + return LayoutCanvas.from(400, 800, new Margin(12, 12, 12, 12)); + } + }; + } + + @Override + public void close() throws Exception { + document.close(); + } + } + + /** Records every text handed to the measurement system. */ + private static final class Counting implements TextMeasurementSystem { + private final TextMeasurementSystem delegate; + private final List seen = new ArrayList<>(); + + private Counting(TextMeasurementSystem delegate) { + this.delegate = delegate; + } + + @Override + public ContentSize measure(TextStyle style, String text) { + seen.add(text); + return delegate.measure(style, text); + } + + @Override + public double textWidth(TextStyle style, String text) { + seen.add(text); + return delegate.textWidth(style, text); + } + + @Override + public LineMetrics lineMetrics(TextStyle style) { + return delegate.lineMetrics(style); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java new file mode 100644 index 000000000..8019f0c48 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java @@ -0,0 +1,182 @@ +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.ListMarker; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code hangingIndent} is fixed-layout geometry, and the semantic DOCX export + * does not lay text out — so it exports a list exactly the same way whether the + * flag is set or not. + * + *

That is a decision rather than an omission, and it was made against + * measurements. Word places content at absolute indents and has no way to be told + * "start the text one marker width plus a gap from here"; every mechanism that + * looks like it would (a hanging indent, a hanging indent with a tab stop, real + * Word numbering) positions content absolutely, so the distance left beside the + * marker is always the column minus the marker's own width — a number only Word + * knows. Reproducing the geometry would mean measuring the marker, and the + * semantic backend has no font runtime to measure with, by design: its only + * dependencies are the core model and POI.

+ * + *

The approximations were built and rendered through Word before being + * rejected. A reserved-column approximation renders a gap that is not the + * configured one — a 0pt gap came out as 5.72pt, a 4pt gap as 9.68pt — and a + * marker wider than the column misaligns outright, its tab overshooting to Word's + * default half-inch grid while the wrapped lines stay at the paragraph indent. + * Shipping that would mean {@code markerGap(8)} rendering as something other + * than 8.

+ * + *

So what this test pins is that the DOCX output is unchanged, and + * that nothing is lost: same paragraphs, same text, same nesting, no stray + * indentation properties. If native DOCX list geometry is built later, this test + * is the one that has to be deliberately rewritten.

+ */ +class DocxHangingIndentIsIgnoredTest { + + @Test + void aFlatListExportsIdenticallyWithAndWithoutHangingIndent() throws Exception { + Consumer shape = list -> list.name("Flat").bullet() + .items("Alpha", "Beta", "Gamma"); + + assertThat(listTexts(shape.andThen(l -> l.hangingIndent(true).markerGap(16)))) + .isEqualTo(listTexts(shape)) + .containsExactly("• Alpha", "• Beta", "• Gamma"); + } + + @Test + void aMultiCharacterMarkerExportsIdenticallyAndKeepsItsMarkerInTheText() throws Exception { + Consumer shape = list -> list.name("Wide").marker("=>").items("Alpha"); + + assertThat(listTexts(shape.andThen(l -> l.hangingIndent(true).markerGap(8)))) + .isEqualTo(listTexts(shape)) + .containsExactly("=> Alpha"); + } + + @Test + void theGapIsNotRepresentedAtAllSoEveryValueExportsTheSame() throws Exception { + // If the gap ever leaked into DOCX as spaces or indentation, these would + // stop agreeing — which is the failure this test exists to catch. + List zero = listTexts(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha")); + for (double gap : List.of(4.0, 8.0, 16.0)) { + assertThat(listTexts(l -> l.bullet().hangingIndent(true).markerGap(gap).items("Alpha"))) + .as("gap %s", gap) + .isEqualTo(zero); + } + assertThat(zero).containsExactly("• Alpha"); + } + + @Test + void aLongItemStaysOneParagraphWithEveryCharacterOfItsText() throws Exception { + String text = "Long item text that the PDF backend wraps across several visual lines " + + "and that Word wraps for itself, so no character of it may be lost here."; + + List texts = listTexts(l -> l.bullet().hangingIndent(true).items(text)); + assertThat(texts).hasSize(1); + assertThat(texts.get(0)).isEqualTo("• " + text); + } + + @Test + void nestedListsKeepTheirCascadeAndTheirTwoAsciiSpacesPerLevel() throws Exception { + Consumer shape = list -> list.name("Outline") + .addItem("alpha", l1 -> l1.addItem("beta", l2 -> l2.addItem("gamma"))); + + assertThat(listTexts(shape.andThen(l -> l.hangingIndent(true).markerGap(12)))) + .isEqualTo(listTexts(shape)) + .containsExactly("• alpha", " ◦ beta", " ▪ gamma"); + } + + @Test + void aMarkerlessListAndAMarkerOnlyRowBothSurviveUnchanged() throws Exception { + assertThat(listTexts(l -> l.noMarker().hangingIndent(true).markerGap(16).items("Alpha"))) + .containsExactly("Alpha"); + + // The flat path drops a blank item whatever its marker — that is the + // legacy rule, and opting in does not change the DOCX side of it. + assertThat(listTexts(l -> l.bullet().hangingIndent(true).items("Alpha", " ", "Beta"))) + .containsExactly("• Alpha", "• Beta"); + + // A nested parent with an empty label keeps its marker row and children. + assertThat(listTexts(l -> l.hangingIndent(true) + .addItem("", c -> c.addItem("Child")))) + .containsExactly("• ", " ◦ Child"); + } + + @Test + void noIndentationOrNumberingPropertyIsWrittenEitherWay() throws Exception { + try (XWPFDocument document = export(flow -> flow.addList(list -> list + .name("Flat").bullet().hangingIndent(true).markerGap(16) + .items("Alpha", "Beta")))) { + + List paragraphs = document.getParagraphs().stream() + .filter(p -> !p.getText().isBlank()) + .toList(); + assertThat(paragraphs).hasSize(2); + for (XWPFParagraph paragraph : paragraphs) { + CTPPr properties = paragraph.getCTP().getPPr(); + assertThat(properties == null || !properties.isSetInd()) + .as("no w:ind — the geometry is not approximated here") + .isTrue(); + assertThat(properties == null || !properties.isSetNumPr()) + .as("no w:numPr") + .isTrue(); + assertThat(properties == null || !properties.isSetTabs()) + .as("no tab stops") + .isTrue(); + assertThat(paragraph.getRuns()).as("one run, as before").hasSize(1); + } + assertThat(document.getNumbering()).as("no numbering.xml").isNull(); + } + } + + @Test + void aMarkerGapNeverBecomesSpacesInTheRunText() throws Exception { + // The specific failure worth naming: a gap smuggled in as padding would + // still "look right" in a viewer and be wrong in the file. + List texts = listTexts(l -> l.bullet().hangingIndent(true).markerGap(16).items("Alpha")); + assertThat(texts.get(0)) + .isEqualTo("• Alpha") + .as("no padding run, no non-breaking spaces, no tab") + .doesNotContain(" ") + .doesNotContain(" ") + .doesNotContain("\t"); + } + + // ------------------------------------------------------------------ + + private static List listTexts(Consumer spec) throws Exception { + try (XWPFDocument document = export(flow -> flow.addList(spec))) { + return document.getParagraphs().stream() + .map(XWPFParagraph::getText) + .filter(text -> !text.isEmpty()) + .toList(); + } + } + + private static XWPFDocument export(Consumer author) throws Exception { + byte[] docxBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 842) + .margin(DocumentInsets.of(36)) + .create()) { + PageFlowBuilder flow = session.dsl().pageFlow().name("Flow"); + author.accept(flow); + flow.build(); + docxBytes = session.export(new DocxSemanticBackend()); + } + return new XWPFDocument(new ByteArrayInputStream(docxBytes)); + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxListHangingIndentTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxListHangingIndentTest.java new file mode 100644 index 000000000..4c4be9932 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxListHangingIndentTest.java @@ -0,0 +1,85 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFTextBox; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A list with {@code hangingIndent(true)} through the PPTX backend. + * + *

The marker/content geometry is resolved in core and reaches every + * fixed-layout backend as ordinary paragraph fragments, so PPTX needs no code of + * its own for it. That is a claim about a mechanism, though, and the docs state + * it as flatly as they state the PDF's — so it is asserted here against a real + * rendered deck rather than left as an inference.

+ */ +class PptxListHangingIndentTest { + + @Test + void theMarkerAndContentColumnsSurviveIntoTheRenderedDeck() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 240) + .margin(DocumentInsets.of(12)) + .create()) { + session.pageFlow().name("Lists") + .addList(list -> list + .name("Hanging") + .bullet() + .hangingIndent(true) + .items("Long item text that wraps across more than one visual line here.")) + .build(); + + LayoutGraph graph = session.render(new GraphCapturingBackend()); + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + + // The resolved geometry: a marker at the item start, content one + // measured marker width plus the gap further in. + PptxGeometryAssertions.assertTextGeometryMatches(graph, pptx); + + try (XMLSlideShow deck = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + List boxes = deck.getSlides().get(0).getShapes().stream() + .filter(XSLFTextBox.class::isInstance) + .map(XSLFTextBox.class::cast) + .sorted(Comparator.comparingDouble(b -> b.getAnchor().getY())) + .toList(); + + assertThat(boxes).as("a marker frame and the content lines").hasSizeGreaterThanOrEqualTo(2); + + XSLFTextBox marker = boxes.stream() + .filter(b -> "•".equals(b.getText().strip())) + .findFirst() + .orElseThrow(() -> new AssertionError("no marker frame in the deck")); + + List content = boxes.stream() + .filter(b -> !"•".equals(b.getText().strip()) && !b.getText().isBlank()) + .toList(); + assertThat(content).as("the item wrapped").hasSizeGreaterThan(1); + + assertThat(marker.getAnchor().getX()) + .as("the marker sits at the item start") + .isEqualTo(12.000, within(0.5)); + + // The claim that matters: every content line, not just the first, + // begins at the same x — and that x is past the marker. + double contentX = content.get(0).getAnchor().getX(); + assertThat(contentX) + .as("content clears the marker column") + .isGreaterThan(marker.getAnchor().getX()); + assertThat(content) + .allSatisfy(box -> assertThat(box.getAnchor().getX()) + .isEqualTo(contentX, within(0.5))); + } + } + } +} From 2c8338fa1eca1067dd47728fc192434b137f3da4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 10 Sep 2026 17:17:39 +0100 Subject: [PATCH 7/7] test(list): embed the visual baseline's font so it is not host-specific MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../visual/ListHangingIndentVisualTest.java | 49 ++++++++++++++++-- .../list-hanging-indent-page-0.png | Bin 14062 -> 14710 bytes 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java b/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java index ef42f4290..586c68fae 100644 --- a/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java +++ b/qa/src/test/java/com/demcha/testing/visual/ListHangingIndentVisualTest.java @@ -5,9 +5,17 @@ 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.font.FontName; import com.demcha.compose.testing.visual.PdfVisualRegression; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.font.PDFont; import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + import java.nio.file.Files; import java.nio.file.Path; @@ -25,9 +33,32 @@ class ListHangingIndentVisualTest { private static final DocumentColor INK = DocumentColor.rgb(28, 36, 52); private static final DocumentColor PAPER = DocumentColor.rgb(253, 252, 250); + /** + * A bundled face, deliberately — the baseline depends on it. + * + *

A Standard-14 font like Helvetica carries no font file into the PDF, so + * a renderer substitutes whatever the host provides and the same document + * rasterises differently on Windows and on Linux; a stored baseline then + * fails everywhere it was not recorded. A bundled face is embedded as a + * subset, so every platform draws the same outlines. Every other text-heavy + * baseline in this repo is bundled-font for the same reason.

+ */ + private static final DocumentTextStyle BODY = DocumentTextStyle.builder() + .fontName(FontName.LATO) + .size(9) + .color(INK) + .build(); + @Test void wrappedLinesHangUnderTheirOwnFirstLineWhateverTheMarkerWidth() throws Exception { byte[] pdf = sheet(); + + // The baseline is only portable while the font travels with the document. + // Asserted rather than assumed: a substituted face rasterises differently + // per host, and the failure would land on whichever machine did not + // record the baseline — which is a confusing way to learn it. + assertEveryFontIsEmbedded(pdf); + PdfVisualRegression.standard().assertMatchesBaseline("list-hanging-indent", pdf); Path out = Path.of("target/visual-tests/list-hanging-indent/list-hanging-indent.pdf"); @@ -35,6 +66,18 @@ void wrappedLinesHangUnderTheirOwnFirstLineWhateverTheMarkerWidth() throws Excep Files.write(out, pdf); } + private static void assertEveryFontIsEmbedded(byte[] pdf) throws Exception { + try (PDDocument document = Loader.loadPDF(pdf)) { + PDResources resources = document.getPage(0).getResources(); + for (COSName name : resources.getFontNames()) { + PDFont font = resources.getFont(name); + assertThat(font.isEmbedded()) + .as("%s must be embedded, or this baseline is host-specific", font.getName()) + .isTrue(); + } + } + } + private static byte[] sheet() throws Exception { try (DocumentSession document = GraphCompose.document() .pageSize(240, 260) @@ -48,7 +91,7 @@ private static byte[] sheet() throws Exception { .name("Bullet") .bullet() .hangingIndent(true) - .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(INK)) + .textStyle(BODY) .itemSpacing(4) .items("Every wrapped line of this item begins directly beneath the first " + "word of its own first line, not beneath the bullet.")) @@ -56,7 +99,7 @@ private static byte[] sheet() throws Exception { .name("Dash") .dash() .hangingIndent(true) - .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(INK)) + .textStyle(BODY) .itemSpacing(4) .items("A dash is narrower than a bullet, so its content column starts " + "further left, and its wrapped lines follow it there.")) @@ -65,7 +108,7 @@ private static byte[] sheet() throws Exception { .marker("=>") .hangingIndent(true) .markerGap(6) - .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(INK)) + .textStyle(BODY) .itemSpacing(4) .items("A wide marker pushes the whole content column right by exactly its " + "own measured width plus the gap, and the wrapped lines move with it.")) diff --git a/qa/src/test/resources/visual-baselines/list-hanging-indent-page-0.png b/qa/src/test/resources/visual-baselines/list-hanging-indent-page-0.png index e13c2ca33c5b4bb6cd5ed36c30b0236ae53a4efa..7388d276e53f77dec77b159b15ccf8e521f75bb5 100644 GIT binary patch literal 14710 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 literal 14062 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