From be2077f4d9a2384f511339417daaf341626d2df5 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 6 Jul 2026 00:40:32 +0100 Subject: [PATCH 1/3] fix(engine): break over-wide inline-code tokens within their column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long code tokens with no whitespace — package coordinates, FQCNs, URLs like org.junit.jupiter:junit-jupiter:5.10.2 — overflowed a paragraph or table cell instead of wrapping, drawing over the neighbouring content. All three paragraph wrap paths placed a lone over-wide token as-is, and highlight-chip words were never broken at all. Route an over-wide token through a new breakLongToken helper on the plain, inline, and markdown wrap paths. It breaks at soft seams (. : / -) so coordinates and URLs split at readable boundaries, and char-splits any segment still too wide as a last resort. Highlight-chip words break the same way while keeping one rounded fill per visual fragment: the split pieces retain the run's highlight group and background, with the outer padding on the first and last fragment only and the break seams open. Tokens that already fit follow the unchanged fast path, so existing prose and layouts are byte-identical (full suite green, no snapshot drift). --- .../document/layout/TextFlowSupport.java | 239 +++++++++++++----- .../dsl/InlineHighlightRenderTest.java | 128 +++++++++- .../table/TableCellInlineCodeWrapTest.java | 110 ++++++++ .../tables/inline_code_wrap_narrow_cell.json | 47 ++++ 4 files changed, 453 insertions(+), 71 deletions(-) create mode 100644 src/test/java/com/demcha/compose/document/table/TableCellInlineCodeWrapTest.java create mode 100644 src/test/resources/layout-snapshots/tables/inline_code_wrap_narrow_cell.json diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index f18ec44da..583939518 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -827,32 +827,38 @@ private static List wrapParagraph(List logicalLines, } double nextTokenWidth = measurement.textWidth(style, nextToken); - if (!hasContent || currentWidth + nextTokenWidth <= maxWidth + EPS) { + if (currentWidth + nextTokenWidth <= maxWidth + EPS) { currentLine.append(nextToken); currentWidth += nextTokenWidth; hasContent = true; continue; } - result.add(trimTrailingSpaces(currentLine.toString())); - currentPrefix = continuationPrefix; - currentLine.setLength(0); - currentLine.append(continuationPrefix); - currentWidth = measurement.textWidth(style, continuationPrefix); - hasContent = false; - - double availableWidth = availableWidthForPrefix(maxWidth, currentPrefix, style, measurement); + // Does not fit. If the line already has content, flush it and retry + // the token on a fresh line before resorting to a break. String strippedToken = nextToken.stripLeading(); double strippedTokenWidth = measurement.textWidth(style, strippedToken); - if (currentWidth + strippedTokenWidth <= maxWidth + EPS) { + if (hasContent) { + result.add(trimTrailingSpaces(currentLine.toString())); + currentPrefix = continuationPrefix; currentLine.setLength(0); - currentLine.append(currentPrefix).append(strippedToken); - currentWidth += strippedTokenWidth; - hasContent = true; - continue; + currentLine.append(continuationPrefix); + currentWidth = measurement.textWidth(style, continuationPrefix); + hasContent = false; + + if (currentWidth + strippedTokenWidth <= maxWidth + EPS) { + currentLine.setLength(0); + currentLine.append(currentPrefix).append(strippedToken); + currentWidth += strippedTokenWidth; + hasContent = true; + continue; + } } - List chunks = splitLongToken(strippedToken, style, availableWidth, measurement); + // Over-wide on a fresh (or already empty) line: break it at soft seams, + // char-splitting as a last resort. + double availableWidth = availableWidthForPrefix(maxWidth, currentPrefix, style, measurement); + List chunks = breakLongToken(strippedToken, style, availableWidth, measurement); if (chunks.isEmpty()) { continue; } @@ -938,62 +944,81 @@ private static List wrapInlineParagraph(List runs, } double tokenWidth = sanitizedToken.wrapWidth(); - if (currentLine.isEmpty() || currentWidth + tokenWidth <= maxWidth + EPS) { + if (currentWidth + tokenWidth <= maxWidth + EPS) { currentLine.add(sanitizedToken); currentWidth += tokenWidth; continue; } + // Does not fit. If the line already has content, flush it and retry + // the token on a fresh line before resorting to a break. if (!currentLine.isEmpty()) { result.add(toInlineParagraphLine(currentLine, defaultMetrics, measurement)); - } - currentLine = new ArrayList<>(); - if (!continuationPrefix.isEmpty()) { - currentLine.add(InlineTextToken.of(continuationPrefix, defaultStyle, null, measurement)); - } - currentWidth = inlineLineWidth(currentLine); + currentLine = new ArrayList<>(); + if (!continuationPrefix.isEmpty()) { + currentLine.add(InlineTextToken.of(continuationPrefix, defaultStyle, null, measurement)); + } + currentWidth = inlineLineWidth(currentLine); - sanitizedToken = trimLeadingIfInlineLineStart(token, currentLine, measurement); - if (sanitizedToken == null) { - continue; - } - tokenWidth = sanitizedToken.wrapWidth(); - if (currentWidth + tokenWidth <= maxWidth + EPS) { - currentLine.add(sanitizedToken); - currentWidth += tokenWidth; - continue; + sanitizedToken = trimLeadingIfInlineLineStart(token, currentLine, measurement); + if (sanitizedToken == null) { + continue; + } + tokenWidth = sanitizedToken.wrapWidth(); + if (currentWidth + tokenWidth <= maxWidth + EPS) { + currentLine.add(sanitizedToken); + currentWidth += tokenWidth; + continue; + } } - if (!(sanitizedToken instanceof InlineTextToken textToken) - || textToken.highlightGroup() != null) { - // Atomic runs that exceed the available width are emitted on their - // own line; further breaking is not possible (image / shape / SVG, - // and a single highlight-chip word — a chip wraps between its words - // but a lone over-wide chip word is not char-split). + // Still over-wide on a fresh (or already empty) line. Text tokens — + // plain OR highlight chip — are broken within the column; a graphic + // (image / shape / SVG) has no break point and is emitted as-is. + if (!(sanitizedToken instanceof InlineTextToken textToken)) { currentLine.add(sanitizedToken); - currentWidth += tokenWidth; + currentWidth += sanitizedToken.wrapWidth(); continue; } - List chunks = splitLongToken( + boolean chip = textToken.highlightGroup() != null; + // A chip fragment paints leadPad + glyphs + trailPad, but the break + // budgets glyphs only, so reserve the run's padding here to keep the + // coalesced fill inside the column. Interior fragments under-fill by + // at most that padding — cosmetic, and only on an over-wide chip. + double reserve = chip ? textToken.leadPad() + textToken.trailPad() : 0.0; + List pieces = breakLongToken( textToken.text(), textToken.textStyle(), - Math.max(1.0, maxWidth - currentWidth), + Math.max(1.0, maxWidth - currentWidth - reserve), measurement); - for (int chunkIndex = 0; chunkIndex < chunks.size(); chunkIndex++) { - String chunk = chunks.get(chunkIndex); - if (chunk.isEmpty()) { + for (int pieceIndex = 0; pieceIndex < pieces.size(); pieceIndex++) { + String piece = pieces.get(pieceIndex); + if (piece.isEmpty()) { continue; } - InlineTextToken chunkToken = InlineTextToken.of( - chunk, - textToken.textStyle(), - textToken.linkTarget(), - measurement); + // Chip pieces keep the run's group + background so the coalescer + // paints one fill per fragment; the run's outer pad sits on the + // first/last piece only, leaving the break seams open. + InlineTextToken chunkToken = chip + ? InlineTextToken.ofHighlight( + piece, + textToken.textStyle(), + textToken.linkTarget(), + textToken.background(), + textToken.highlightGroup(), + pieceIndex == 0 ? textToken.leadPad() : 0.0, + pieceIndex == pieces.size() - 1 ? textToken.trailPad() : 0.0, + measurement) + : InlineTextToken.of( + piece, + textToken.textStyle(), + textToken.linkTarget(), + measurement); currentLine.add(chunkToken); - currentWidth += chunkToken.width(); + currentWidth += chunkToken.wrapWidth(); - if (chunkIndex < chunks.size() - 1) { + if (pieceIndex < pieces.size() - 1) { result.add(toInlineParagraphLine(currentLine, defaultMetrics, measurement)); currentLine = new ArrayList<>(); if (!continuationPrefix.isEmpty()) { @@ -1064,33 +1089,35 @@ private static List wrapMarkdownParagraph(List logicalLin } double tokenWidth = measurement.textWidth(sanitizedToken.textStyle(), sanitizedToken.text()); - if (currentLine.isEmpty() || currentWidth + tokenWidth <= maxWidth + EPS) { + if (currentWidth + tokenWidth <= maxWidth + EPS) { currentLine.add(sanitizedToken); currentWidth += tokenWidth; continue; } + // Does not fit. If the line already has content, flush it and retry + // the token on a fresh line before resorting to a break. if (!currentLine.isEmpty()) { result.add(toParagraphLine(currentLine, metrics, measurement)); - } - currentLine = new ArrayList<>(); - if (!continuationPrefix.isEmpty()) { - currentLine.add(new TextDataBody(continuationPrefix, style)); - } - currentWidth = lineWidth(currentLine, measurement); + currentLine = new ArrayList<>(); + if (!continuationPrefix.isEmpty()) { + currentLine.add(new TextDataBody(continuationPrefix, style)); + } + currentWidth = lineWidth(currentLine, measurement); - sanitizedToken = trimLeadingIfLineStart(token, currentLine); - if (sanitizedToken == null || sanitizedToken.text().isEmpty()) { - continue; - } - tokenWidth = measurement.textWidth(sanitizedToken.textStyle(), sanitizedToken.text()); - if (currentWidth + tokenWidth <= maxWidth + EPS) { - currentLine.add(sanitizedToken); - currentWidth += tokenWidth; - continue; + sanitizedToken = trimLeadingIfLineStart(token, currentLine); + if (sanitizedToken == null || sanitizedToken.text().isEmpty()) { + continue; + } + tokenWidth = measurement.textWidth(sanitizedToken.textStyle(), sanitizedToken.text()); + if (currentWidth + tokenWidth <= maxWidth + EPS) { + currentLine.add(sanitizedToken); + currentWidth += tokenWidth; + continue; + } } - List chunks = splitLongToken( + List chunks = breakLongToken( sanitizedToken.text(), sanitizedToken.textStyle(), Math.max(1.0, maxWidth - currentWidth), @@ -1604,6 +1631,84 @@ private static double lineWidth(List bodies, return width; } + /** + * Characters after which a long, otherwise-unbreakable token may wrap. These + * are the joiners inside package coordinates, fully-qualified class names and + * URLs ({@code org.junit.jupiter:junit-jupiter:5.10.2}, {@code a/b/c}), so an + * over-wide code token breaks at a readable seam instead of mid-identifier. + */ + private static final String SOFT_BREAK_AFTER_CHARS = ".:/-"; + + /** + * Breaks a single over-wide token into line-pieces that each fit + * {@code maxWidth}. Prefers breaking at {@link #SOFT_BREAK_AFTER_CHARS soft + * seams} (after {@code . : / -}) so coordinates and URLs split naturally, and + * falls back to character-level {@link #splitLongToken splitting} for any lone + * segment still too wide (e.g. a long dot-less identifier). The returned pieces + * have the same shape the wrap loops already consume from {@code splitLongToken} + * — for a token with no soft seams the two are identical. + */ + private static List breakLongToken(String token, + TextStyle style, + double maxWidth, + TextMeasurementSystem measurement) { + if (token == null || token.isEmpty()) { + return List.of(); + } + List segments = softBreakSegments(token); + List pieces = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + double currentWidth = 0.0; + for (String segment : segments) { + double segmentWidth = measurement.textWidth(style, segment); + if (current.length() > 0 && currentWidth + segmentWidth > maxWidth + EPS) { + pieces.add(current.toString()); + current.setLength(0); + currentWidth = 0.0; + } + if (current.length() == 0 && segmentWidth > maxWidth + EPS) { + // A single seam-delimited segment is itself too wide: char-split it. + // Every char-chunk but the last is a finished piece; the tail seeds + // the current piece so a following short segment can still join it. + List chars = splitLongToken(segment, style, maxWidth, measurement); + for (int index = 0; index < chars.size() - 1; index++) { + pieces.add(chars.get(index)); + } + String tail = chars.get(chars.size() - 1); + current.append(tail); + currentWidth = measurement.textWidth(style, tail); + } else { + current.append(segment); + currentWidth += segmentWidth; + } + } + if (current.length() > 0) { + pieces.add(current.toString()); + } + return List.copyOf(pieces); + } + + /** + * Splits a token after each {@link #SOFT_BREAK_AFTER_CHARS soft-break + * character}, keeping the delimiter with the preceding segment + * ({@code org.junit.jupiter:} then {@code junit-jupiter:} then {@code 5.10.2}). + * A token with no such characters yields a single segment (itself). + */ + private static List softBreakSegments(String token) { + List segments = new ArrayList<>(); + int start = 0; + for (int index = 0; index < token.length(); index++) { + if (SOFT_BREAK_AFTER_CHARS.indexOf(token.charAt(index)) >= 0) { + segments.add(token.substring(start, index + 1)); + start = index + 1; + } + } + if (start < token.length()) { + segments.add(token.substring(start)); + } + return segments; + } + private static List splitLongToken(String token, TextStyle style, double maxWidth, diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java index a504b94c2..f96287b68 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java @@ -150,18 +150,33 @@ void multiWordChipStaysOneSpanAndCollapsesNewlines() throws Exception { } @Test - void overWideAtomicChipRendersWithoutThrowing() throws Exception { - // A chip wider than the column is emitted on its own line (atomic); - // it must still render the text on one page without throwing. + void overWideChipBreaksWithinColumnWithoutThrowing() throws Exception { + // A single-word chip wider than the column breaks within it (at soft seams, + // char-split as a last resort): every visual fragment stays inside the inner + // width, keeps its rounded fill, and the coordinate survives end-to-end. + double innerWidth = 90 - 20; + List lines; byte[] pdf; try (DocumentSession session = GraphCompose.document().pageSize(90, 140).margin(10, 10, 10, 10).create()) { session.dsl().pageFlow().name("Flow") .addParagraph(p -> p.inlineCode("io.github.demchaav.a.very.long.package.name")).build(); + lines = paragraphLines(session.layoutGraph()); pdf = session.toPdfBytes(); } + List chipLines = lines.stream() + .filter(l -> l.spans().stream().anyMatch(s -> s instanceof ParagraphTextSpan ts && ts.background() != null)) + .toList(); + assertThat(chipLines).as("the over-wide chip breaks to >= 2 fragments").hasSizeGreaterThanOrEqualTo(2); + for (ParagraphLine line : chipLines) { + ParagraphTextSpan fragment = chipSpan(line); + assertThat(fragment.width()).as("each fragment stays inside the inner width") + .isLessThanOrEqualTo(innerWidth + 0.5); + assertThat(fragment.background()).as("each fragment keeps its fill").isNotNull(); + } try (PDDocument document = Loader.loadPDF(pdf)) { assertThat(document.getNumberOfPages()).isEqualTo(1); - assertThat(new PDFTextStripper().getText(document)).doesNotContain("?"); + String text = new PDFTextStripper().getText(document).replaceAll("\\s+", ""); + assertThat(text).contains("io.github.demchaav.a.very.long.package.name").doesNotContain("?"); } } @@ -336,6 +351,111 @@ void wrappedLinkedChipEmitsAClickableRectPerFragment() throws Exception { } } + @Test + void overWideCodeChipBreaksAtSoftSeams() throws Exception { + // A coordinate breaks at its . : / - joiners, not mid-identifier: at least + // one non-final fragment ends with a soft-break character. + List lines; + try (DocumentSession session = GraphCompose.document().pageSize(120, 200).margin(10, 10, 10, 10).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineCode("org.junit.jupiter:junit-jupiter:5.10.2")).build(); + lines = paragraphLines(session.layoutGraph()); + } + List chipLines = lines.stream() + .filter(l -> l.spans().stream().anyMatch(s -> s instanceof ParagraphTextSpan ts && ts.background() != null)) + .toList(); + assertThat(chipLines).hasSizeGreaterThanOrEqualTo(2); + boolean seamBreak = chipLines.subList(0, chipLines.size() - 1).stream() + .map(l -> chipSpan(l).text()) + .anyMatch(t -> !t.isEmpty() && ".:/-".indexOf(t.charAt(t.length() - 1)) >= 0); + assertThat(seamBreak).as("the coordinate breaks at a . : / - seam, not mid-identifier").isTrue(); + } + + @Test + void overWidePlainTokenBreaksWithinColumn() throws Exception { + // The plain wrap path (no inline runs, no markdown): a lone over-wide first + // token char-splits within the column instead of overflowing it. + double innerWidth = 90 - 20; + String token = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"; + List lines; + try (DocumentSession session = GraphCompose.document().pageSize(90, 160).margin(10, 10, 10, 10).create()) { + session.dsl().pageFlow().name("Flow").addParagraph(p -> p.text(token)).build(); + lines = paragraphLines(session.layoutGraph()); + } + assertThat(lines).as("the over-wide plain token breaks to >= 2 lines").hasSizeGreaterThanOrEqualTo(2); + for (ParagraphLine line : lines) { + assertThat(line.width()).as("each line stays inside the inner width").isLessThanOrEqualTo(innerWidth + 0.5); + } + String joined = lines.stream().flatMap(l -> l.spans().stream()) + .filter(ParagraphTextSpan.class::isInstance).map(s -> ((ParagraphTextSpan) s).text()) + .reduce("", String::concat).replaceAll("\\s+", ""); + assertThat(joined).isEqualTo(token); + } + + @Test + void overWideTokenInMarkdownBreaksWithinColumn() throws Exception { + // The markdown wrap path (markdown enabled + markdown syntax present): a lone + // over-wide token still breaks within the column. + double innerWidth = 90 - 20; + String token = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"; + List lines; + try (DocumentSession session = GraphCompose.document().pageSize(90, 160).margin(10, 10, 10, 10).create()) { + session.markdown(true); + session.dsl().pageFlow().name("Flow").addParagraph(p -> p.text("**b** " + token)).build(); + lines = paragraphLines(session.layoutGraph()); + } + assertThat(lines).hasSizeGreaterThanOrEqualTo(2); + for (ParagraphLine line : lines) { + assertThat(line.width()).as("each line stays inside the inner width").isLessThanOrEqualTo(innerWidth + 0.5); + } + String joined = lines.stream().flatMap(l -> l.spans().stream()) + .filter(ParagraphTextSpan.class::isInstance).map(s -> ((ParagraphTextSpan) s).text()) + .reduce("", String::concat).replaceAll("\\s+", ""); + assertThat(joined).as("the long token survives the break").contains(token); + } + + @Test + void nonFirstOverWideChipBreaksWithinColumn() throws Exception { + // The over-wide chip is NOT the first token: leading prose is flushed, then + // the chip breaks on the following lines (it still fits none of them whole). + double innerWidth = 120 - 20; + List lines; + try (DocumentSession session = GraphCompose.document().pageSize(120, 200).margin(10, 10, 10, 10).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineText("Use ").inlineCode("io.github.demchaav.a.very.long.package.name")) + .build(); + lines = paragraphLines(session.layoutGraph()); + } + List chipLines = lines.stream() + .filter(l -> l.spans().stream().anyMatch(s -> s instanceof ParagraphTextSpan ts && ts.background() != null)) + .toList(); + assertThat(chipLines).as("the trailing chip breaks to >= 2 fragments").hasSizeGreaterThanOrEqualTo(2); + for (ParagraphLine line : chipLines) { + assertThat(chipSpan(line).width()).isLessThanOrEqualTo(innerWidth + 0.5); + } + } + + @Test + void overWideLinkedChipEmitsAClickableRectPerFragment() throws Exception { + // Char-split (single-word) linked chip: each visual fragment is independently + // clickable, mirroring the multi-word wrapped-chip guarantee. + byte[] pdf; + try (DocumentSession session = GraphCompose.document().pageSize(120, 200).margin(10, 10, 10, 10).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineHighlight( + "io.github.demchaav.a.very.long.package.name", MONO, FILL, 3.0, PAD, + new DocumentLinkOptions("https://example.com"))) + .build(); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + long links = document.getPage(0).getAnnotations().stream() + .filter(PDAnnotationLink.class::isInstance).count(); + assertThat(links).as("a char-split linked chip is clickable on each fragment") + .isGreaterThanOrEqualTo(2); + } + } + private static byte[] render(Consumer body) throws Exception { try (DocumentSession session = GraphCompose.document().pageSize(320, 140).margin(16, 16, 16, 16).create()) { session.dsl().pageFlow().name("Flow").addParagraph(body::accept).build(); diff --git a/src/test/java/com/demcha/compose/document/table/TableCellInlineCodeWrapTest.java b/src/test/java/com/demcha/compose/document/table/TableCellInlineCodeWrapTest.java new file mode 100644 index 000000000..c286f8d3a --- /dev/null +++ b/src/test/java/com/demcha/compose/document/table/TableCellInlineCodeWrapTest.java @@ -0,0 +1,110 @@ +package com.demcha.compose.document.table; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphLine; +import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.testing.layout.LayoutSnapshotAssertions; +import com.demcha.testing.VisualTestOutputs; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression for the reported "long inline-code chip overflows its table column": + * a composed cell holding a single over-wide {@code inlineCode(...)} coordinate in a + * narrow fixed column must break the chip inside the cell (fill intact per + * fragment), never draw past the cell box, and never throw. + */ +class TableCellInlineCodeWrapTest { + + /** Fixed column widths: a deliberately narrow middle column holds the chip. */ + private static final double MIDDLE_CELL_WIDTH = 60.0; + private static final String COORDINATE = "org.junit.jupiter:junit-jupiter:5.10.2"; + + private static TableNode narrowCodeTable(DocumentSession session) { + DocumentTableCell left = DocumentTableCell.text("L"); + DocumentTableCell mid = DocumentTableCell.node( + session.dsl().paragraph().inlineCode(COORDINATE).build()); + DocumentTableCell right = DocumentTableCell.text("R"); + return new TableNode( + "CodeTable", + List.of(DocumentTableColumn.fixed(40), + DocumentTableColumn.fixed(MIDDLE_CELL_WIDTH), + DocumentTableColumn.fixed(40)), + List.of(List.of(left, mid, right)), + DocumentTableStyle.empty(), + 140.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + } + + @Test + void narrowCellInlineCodeBreaksWithinCell() throws Exception { + byte[] pdf; + List chipLines; + try (DocumentSession session = GraphCompose.document() + .pageSize(220, 200) + .margin(DocumentInsets.of(10)) + .create()) { + session.add(narrowCodeTable(session)); + + LayoutGraph graph = session.layoutGraph(); + chipLines = graph.fragments().stream() + .map(PlacedFragment::payload) + .filter(ParagraphFragmentPayload.class::isInstance) + .map(ParagraphFragmentPayload.class::cast) + .flatMap(payload -> payload.lines().stream()) + .filter(l -> l.spans().stream() + .anyMatch(s -> s instanceof ParagraphTextSpan ts && ts.background() != null)) + .toList(); + pdf = session.toPdfBytes(); + } + + assertThat(chipLines).as("the over-wide chip breaks to >= 2 fragments in the cell") + .hasSizeGreaterThanOrEqualTo(2); + for (ParagraphLine line : chipLines) { + ParagraphTextSpan fragment = (ParagraphTextSpan) line.spans().stream() + .filter(s -> s instanceof ParagraphTextSpan ts && ts.background() != null) + .findFirst().orElseThrow(); + assertThat(fragment.width()).as("no chip fragment draws past the cell's inner width") + .isLessThanOrEqualTo(MIDDLE_CELL_WIDTH + 0.5); + assertThat(fragment.background()).as("each fragment keeps its fill").isNotNull(); + } + + // Human-inspectable artifact: the chip wraps inside the middle column. + Path output = VisualTestOutputs.preparePdf("inline_code_wrap_narrow_cell", "tables"); + Files.write(output, pdf); + assertThat(new String(pdf, 0, 8, StandardCharsets.US_ASCII)).startsWith("%PDF-"); + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(document.getNumberOfPages()).isEqualTo(1); + String text = new PDFTextStripper().getText(document).replaceAll("\\s+", ""); + assertThat(text).contains(COORDINATE).doesNotContain("?"); + } + } + + @Test + void narrowCellInlineCodeLayoutSnapshot() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(220, 200) + .margin(DocumentInsets.of(10)) + .create()) { + session.add(narrowCodeTable(session)); + LayoutSnapshotAssertions.assertMatches(session, "tables/inline_code_wrap_narrow_cell"); + } + } +} diff --git a/src/test/resources/layout-snapshots/tables/inline_code_wrap_narrow_cell.json b/src/test/resources/layout-snapshots/tables/inline_code_wrap_narrow_cell.json new file mode 100644 index 000000000..a12bf8cb6 --- /dev/null +++ b/src/test/resources/layout-snapshots/tables/inline_code_wrap_narrow_cell.json @@ -0,0 +1,47 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 220.0, + "pageHeight" : 200.0, + "innerWidth" : 200.0, + "innerHeight" : 180.0, + "margin" : { + "top" : 10.0, + "right" : 10.0, + "bottom" : 10.0, + "left" : 10.0 + } + }, + "totalPages" : 1, + "nodes" : [ { + "path" : "CodeTable[0]", + "entityName" : "CodeTable", + "entityKind" : "TableNode", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 10.0, + "computedY" : 71.96, + "placementX" : 10.0, + "placementY" : 71.96, + "placementWidth" : 140.0, + "placementHeight" : 118.04, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 140.0, + "contentHeight" : 118.04, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +} From 34325f0952ae7cef9b9d8bf463e90be6b35c1abf Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 6 Jul 2026 00:55:58 +0100 Subject: [PATCH 2/3] feat(engine): size AUTO table columns to composed cell content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A composed table cell (DocumentTableCell.node(...)) contributed no natural width, so an AUTO column holding only composed content collapsed toward zero and its child — an inline-code chip, say — was laid out at a near-zero width. Measure a composed cell's intrinsic content width in resolveNaturalColumnWidths and feed it into the AUTO column's natural width, reusing the same prepare-to-measure pattern as RowSlots.intrinsicColumnWidths. The child is measured against the table's inner width, so one cell can never demand more than the table can give. Only single-column composed cells in AUTO columns are measured; plain-text cells, FIXED columns and spanning cells are unchanged, so a chip in a FIXED column keeps its declared width and breaks inside it. As with plain-text AUTO columns, a table too narrow for the summed intrinsic width of its AUTO columns still reports "exceeds available width". --- .../document/layout/TableLayoutSupport.java | 46 ++++++++++-- .../TableAutoColumnComposedWidthTest.java | 74 +++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/demcha/compose/document/table/TableAutoColumnComposedWidthTest.java diff --git a/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java b/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java index 1529d0f55..93be462fb 100644 --- a/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TableLayoutSupport.java @@ -47,12 +47,13 @@ static ResolvedTableLayoutWithContents resolveTableLayout(TableNode node, List> logicalRows = buildLogicalRows(node, columnCount); List normalizedSpecs = normalizeSpecs(node, columnCount); TableCellLayoutStyle[][] stylesGrid = buildStylesGrid(node, logicalRows, columnCount); - double[] naturalWidths = resolveNaturalColumnWidths(node, normalizedSpecs, logicalRows, stylesGrid, columnCount, measurement); + double innerAvailableWidth = Math.max(0.0, availableWidth - node.padding().horizontal()); + double[] naturalWidths = resolveNaturalColumnWidths(node, normalizedSpecs, logicalRows, stylesGrid, + columnCount, measurement, prepareContext, innerAvailableWidth); double naturalWidth = sum(naturalWidths); double[] finalWidths = resolveFinalColumnWidths(node, normalizedSpecs, naturalWidths, naturalWidth); double finalWidth = sum(finalWidths); - double innerAvailableWidth = Math.max(0.0, availableWidth - node.padding().horizontal()); if (finalWidth > innerAvailableWidth + EPS) { throw new IllegalStateException("Table '" + displayName(node) + "' width " + finalWidth + " exceeds available width " + innerAvailableWidth + "."); @@ -488,7 +489,9 @@ private static double[] resolveNaturalColumnWidths(TableNode node, List> logicalRows, TableCellLayoutStyle[][] stylesGrid, int columnCount, - TextMeasurementSystem measurement) { + TextMeasurementSystem measurement, + PrepareContext prepareContext, + double innerAvailableWidth) { double[] widths = new double[columnCount]; double[] singleCellRequired = new double[columnCount]; @@ -498,8 +501,12 @@ private static double[] resolveNaturalColumnWidths(TableNode node, continue; } int col = logical.startColumn(); - singleCellRequired[col] = Math.max(singleCellRequired[col], - cellNaturalWidth(logical.sanitizedLines(), stylesGrid[rowIndex][col], measurement)); + double required = composedAutoCellNaturalWidth(logical, normalizedSpecs.get(col), + stylesGrid[rowIndex][col], prepareContext, innerAvailableWidth); + if (required < 0.0) { + required = cellNaturalWidth(logical.sanitizedLines(), stylesGrid[rowIndex][col], measurement); + } + singleCellRequired[col] = Math.max(singleCellRequired[col], required); } } @@ -596,6 +603,35 @@ private static double[] resolveFinalColumnWidths(TableNode node, return finalWidths; } + /** + * Natural width contribution of a composed cell that sits in an AUTO column: + * the child's intrinsic content width plus the cell padding, so the AUTO + * column grows to fit composed content (e.g. an inline-code chip) instead of + * being sized to zero by its plain-text siblings. The child is measured + * against the table's inner width, so the result is already clamped to what + * the table can give — it never forces the table past its available width. + * + *

Returns a negative sentinel when this is not a composed cell in an AUTO + * column, so the caller falls back to the plain-text {@link #cellNaturalWidth} + * formula. Composed cells in FIXED columns keep contributing nothing: the + * column width is fixed and an over-wide child wraps/breaks inside it.

+ */ + private static double composedAutoCellNaturalWidth(LogicalCell logical, + TableColumnLayout spec, + TableCellLayoutStyle style, + PrepareContext prepareContext, + double innerAvailableWidth) { + DocumentTableCell src = logical.source(); + if (src == null || !src.hasComposedContent() || !spec.isAuto() || prepareContext == null) { + return -1.0; + } + Padding padding = style.padding() == null ? Padding.zero() : style.padding(); + double childInner = Math.max(0.0, innerAvailableWidth - padding.horizontal()); + double natural = prepareContext.prepare(src.content(), BoxConstraints.natural(childInner)) + .measureResult().width(); + return natural + padding.horizontal(); + } + private static double cellNaturalWidth(List sanitizedLines, TableCellLayoutStyle style, TextMeasurementSystem measurement) { diff --git a/src/test/java/com/demcha/compose/document/table/TableAutoColumnComposedWidthTest.java b/src/test/java/com/demcha/compose/document/table/TableAutoColumnComposedWidthTest.java new file mode 100644 index 000000000..66afd93c5 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/table/TableAutoColumnComposedWidthTest.java @@ -0,0 +1,74 @@ +package com.demcha.compose.document.table; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An AUTO column sizes to fit a composed cell's intrinsic content (e.g. an + * inline-code chip) instead of collapsing to zero because the composed cell + * contributed no natural width. A FIXED column keeps its declared width, so an + * over-wide chip breaks inside it (handled by the paragraph wrap engine). + */ +class TableAutoColumnComposedWidthTest { + + private static final String CODE = "io.github.demchaav"; + + /** Number of visual chip fragments (rounded-fill spans) rendered by the composed cell. */ + private static long chipFragmentCount(DocumentSession session) { + return session.layoutGraph().fragments().stream() + .map(PlacedFragment::payload) + .filter(ParagraphFragmentPayload.class::isInstance) + .map(ParagraphFragmentPayload.class::cast) + .flatMap(payload -> payload.lines().stream()) + .filter(line -> line.spans().stream() + .anyMatch(s -> s instanceof ParagraphTextSpan ts && ts.background() != null)) + .count(); + } + + private static TableNode chipTable(DocumentSession session, DocumentTableColumn column) { + return new TableNode( + "ChipTable", + List.of(column), + List.of(List.of(DocumentTableCell.node(session.dsl().paragraph().inlineCode(CODE).build()))), + DocumentTableStyle.empty(), + null, + DocumentInsets.zero(), + DocumentInsets.zero()); + } + + @Test + void autoColumnGrowsToFitComposedChipOnOneFragment() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 160) + .margin(DocumentInsets.of(10)) + .create()) { + session.add(chipTable(session, DocumentTableColumn.auto())); + // The AUTO column sizes to the chip's natural width (the page is wide + // enough), so the chip renders on ONE fragment instead of collapsing to + // zero width and breaking character by character. + assertThat(chipFragmentCount(session)).isEqualTo(1); + } + } + + @Test + void fixedNarrowColumnStillBreaksTheComposedChip() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 160) + .margin(DocumentInsets.of(10)) + .create()) { + session.add(chipTable(session, DocumentTableColumn.fixed(40))); + // A FIXED column is never grown; the over-wide chip breaks inside it. + assertThat(chipFragmentCount(session)).isGreaterThanOrEqualTo(2); + } + } +} From b065404750efc40bfccf79d81dd63cb4f384abba Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 6 Jul 2026 01:45:19 +0100 Subject: [PATCH 3/3] docs(examples): add inline-code column-wrap example + CHANGELOG entry InlineCodeColumnWrapExample renders a long inlineCode(...) coordinate breaking at its . : / - seams inside a narrow fixed column and fitting on one line in an auto column, registered in GenerateAllExamples with a committed preview PDF and a README quick-reference row. CHANGELOG v1.9.1 documents the chip-overflow fix and the auto-column sizing. --- CHANGELOG.md | 23 ++++ .../examples/inline-code-column-wrap.pdf | Bin 0 -> 3752 bytes examples/README.md | 19 +++ .../demcha/examples/GenerateAllExamples.java | 2 + .../tables/InlineCodeColumnWrapExample.java | 121 ++++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 assets/readme/examples/inline-code-column-wrap.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a7cddcfe..4c11b5502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. +## v1.9.1 — Planned + +Table columns now contain long inline-code content instead of letting it spill +over the next column. + +### Bug fixes + +- **Long inline-code chips no longer overflow their column.** A long + `inlineCode(...)` / `inlineHighlight(...)` token with no spaces — a package + coordinate, fully-qualified class name or URL — now breaks *within* its + paragraph or table cell instead of drawing over the neighbouring content. It + breaks at `.` `:` `/` `-` seams and char-splits only when a segment is still + too wide, keeping the rounded chip fill intact on every wrapped fragment. + Applies to all three paragraph wrap paths (plain, inline, markdown); text that + already fits is laid out exactly as before. + +- **`auto()` table columns grow to fit composed cell content.** A composed cell + (`DocumentTableCell.node(...)`) in an `auto()` column now contributes its + intrinsic content width, so the column sizes to the content (e.g. an + inline-code chip) instead of collapsing toward zero. `fixed(...)` columns are + unchanged. As with plain-text auto columns, a table too narrow for the summed + intrinsic width of its auto columns reports `exceeds available width`. + ## v1.9.0 — 2026-06-29 In-document navigation. Rendered PDFs can now declare named **anchors** and diff --git a/assets/readme/examples/inline-code-column-wrap.pdf b/assets/readme/examples/inline-code-column-wrap.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c4f1ee39511c107695124a466dc0993a5d7a9b79 GIT binary patch literal 3752 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 literal 0 HcmV?d00001 diff --git a/examples/README.md b/examples/README.md index e87e62dbf..a732b8aad 100644 --- a/examples/README.md +++ b/examples/README.md @@ -75,6 +75,7 @@ are with the canonical DSL, then jump to its detailed section below. | [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](#nested-lists-v16) | `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) | | [Composed table cells](#composed-table-cells-v16) | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables 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)](#canvas-layer-v16) | `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) | | [Transforms](#transforms) | `rotate`, `scale`, and per-layer `zIndex` swap | [PDF](../assets/readme/examples/transforms.pdf) · [Source](src/main/java/com/demcha/examples/features/transforms/TransformsExample.java) | | [Block alignment](#block-alignment) | `addAligned(align, node)` / `addSvgIcon(icon, w, align)` — seat any fixed-size node left / centre / right across the content width | [PDF](../assets/readme/examples/block-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java) | @@ -577,6 +578,24 @@ table.columns(...) [📄 View PDF](../assets/readme/examples/table-advanced.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java) +### Inline-code column wrap + +A composed cell holding one long `inlineCode(...)` token — a Maven coordinate, +fully-qualified class name or URL — stays inside its column. In a narrow +**fixed** column the chip breaks at its `. : / -` seams (char-splitting only +when a segment is still too wide), with the rounded fill intact on every +fragment; in an **auto** column the column grows to fit the coordinate on one +line instead of collapsing. + +```java +DocumentTableCell.node(document.dsl().paragraph() + .inlineCode("org.junit.jupiter:junit-jupiter:5.10.2").build()) +// fixed(104) column -> wraps at seams; auto() column -> grows to one line +``` + +[📄 View PDF](../assets/readme/examples/inline-code-column-wrap.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java) + ### Custom Business Theme Build a `BusinessTheme` from raw `DocumentPalette` / `SpacingScale` / diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 2a19aa455..3a57b1132 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -24,6 +24,7 @@ import com.demcha.examples.features.streaming.HttpStreamingExample; import com.demcha.examples.features.svg.SvgIconGalleryExample; import com.demcha.examples.features.tables.ComposedTableCellExample; +import com.demcha.examples.features.tables.InlineCodeColumnWrapExample; import com.demcha.examples.features.tables.TableAdvancedExample; import com.demcha.examples.features.text.EmojiGalleryExample; import com.demcha.examples.features.text.EmojiShortcodeExample; @@ -151,6 +152,7 @@ public static void main(String[] args) throws Exception { // v1.6 layout primitives System.out.println("Generated: " + NestedListExample.generate()); System.out.println("Generated: " + ComposedTableCellExample.generate()); + System.out.println("Generated: " + InlineCodeColumnWrapExample.generate()); System.out.println("Generated: " + CanvasLayerExample.generate()); // v1.5 visual primitives diff --git a/examples/src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java b/examples/src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java new file mode 100644 index 000000000..37df23ee9 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java @@ -0,0 +1,121 @@ +package com.demcha.examples.features.tables; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.font.FontName; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; +import java.util.List; + +/** + * Runnable showcase for long inline-code coordinates inside table columns. + * + *

A composed cell holding a single long {@code inlineCode(...)} token — a + * Maven coordinate, fully-qualified class name or URL — no longer overflows its + * column. In a narrow fixed column the chip breaks inside the + * cell, preferring its {@code . : / -} seams and char-splitting only as a last + * resort, with the rounded fill intact on every fragment. In an auto + * column the column grows to fit the coordinate on one line instead of + * collapsing.

+ * + * @author Artem Demchyshyn + */ +public final class InlineCodeColumnWrapExample { + + private static final DocumentColor INK = DocumentColor.rgb(34, 38, 50); + private static final DocumentColor MUTED = DocumentColor.rgb(102, 106, 118); + private static final DocumentColor RULE = DocumentColor.rgb(180, 188, 200); + private static final DocumentColor HEADER_FILL = DocumentColor.rgb(20, 60, 75); + private static final DocumentColor ROW_TINT = DocumentColor.rgb(244, 247, 252); + + private static final List COORDINATES = List.of( + "org.junit.jupiter:junit-jupiter:5.10.2", + "io.github.demchaav:graph-compose:1.9.0", + "https://repo1.maven.org/maven2/"); + + private InlineCodeColumnWrapExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("features/tables", "inline-code-column-wrap.pdf"); + + DocumentTextStyle title = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD).size(20).color(INK) + .decoration(DocumentTextDecoration.BOLD).build(); + DocumentTextStyle caption = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_OBLIQUE).size(10).color(MUTED).build(); + DocumentTextStyle body = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA).size(10.5).color(INK).build(); + DocumentTextStyle headerText = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD).size(11).color(DocumentColor.WHITE) + .decoration(DocumentTextDecoration.BOLD).build(); + + DocumentTableStyle headerStyle = DocumentTableStyle.builder() + .fillColor(HEADER_FILL).stroke(DocumentStroke.of(RULE, 0.6)) + .padding(DocumentInsets.of(8)).textStyle(headerText).build(); + DocumentTableStyle bodyCellStyle = DocumentTableStyle.builder() + .stroke(DocumentStroke.of(RULE, 0.6)).padding(DocumentInsets.of(8)).textStyle(body).build(); + DocumentTableStyle tintedCellStyle = DocumentTableStyle.builder() + .fillColor(ROW_TINT).stroke(DocumentStroke.of(RULE, 0.6)) + .padding(DocumentInsets.of(8)).textStyle(body).build(); + + try (DocumentSession document = GraphCompose.document(outputFile) + .pageSize(DocumentPageSize.A4) + .margin(36, 36, 36, 36) + .create()) { + + document.pageFlow() + .name("CodeWrapShowcase") + .spacing(8) + .addParagraph("Long inline-code coordinates stay inside the column", title) + .addParagraph( + "A composed cell with one long inlineCode(...) token used to spill over the " + + "next column. It now breaks at its . : / - seams inside a narrow FIXED " + + "column (char-splitting only when a segment is still too wide), and an " + + "AUTO column grows to fit it on one line.", caption) + .build(); + + List> rows = new java.util.ArrayList<>(); + rows.add(List.of( + DocumentTableCell.text("Fixed 104pt").withStyle(headerStyle), + DocumentTableCell.text("Auto").withStyle(headerStyle))); + for (int i = 0; i < COORDINATES.size(); i++) { + String coordinate = COORDINATES.get(i); + DocumentTableStyle cellStyle = i % 2 == 0 ? bodyCellStyle : tintedCellStyle; + rows.add(List.of( + DocumentTableCell.node(document.dsl().paragraph().inlineCode(coordinate).build()) + .withStyle(cellStyle), + DocumentTableCell.node(document.dsl().paragraph().inlineCode(coordinate).build()) + .withStyle(cellStyle))); + } + + document.add(new TableNode( + "CodeWrapTable", + List.of(DocumentTableColumn.fixed(104), + DocumentTableColumn.auto()), + rows, + bodyCellStyle, + null, + DocumentInsets.zero(), + DocumentInsets.zero())); + + document.buildPdf(); + } + return outputFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +}