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 000000000..c4f1ee395 Binary files /dev/null and b/assets/readme/examples/inline-code-column-wrap.pdf differ 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()); + } +} 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/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/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); + } + } +} 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 + } + } ] +}