From c0aabe0f6731a0b922f69b27a5688b5638581267 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 22 Jun 2026 20:50:07 +0100 Subject: [PATCH 1/2] feat(api): wrap inline highlight chips across lines (multi-word coalescing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-word highlight chip now tokenizes into words tagged with a shared group; the words wrap with the surrounding line via the normal fit loop, and toInlineParagraphLine coalesces the same-group tokens on each visual line into one ParagraphTextSpan — so a wrapped chip paints one continuous rounded fill per line-fragment, not a box per word. Horizontal padding sits on the run's outer edges (lead on the first word, trail on the last), so a continuation fragment is open on the inner break; the soft-wrap space at a break is collapsed so the fill ends at the last visible glyph. A single over-wide chip word stays atomic on its own line; non-chip text rendering is unchanged. Tests: a multi-word chip wraps to one coalesced fill per fragment (slice padding); the wrapped fill spans an extra line vs a single-line chip; a wrapped chip keeps its full text across the break with plain text on both sides; two adjacent distinct chips stay separate; a wrapped linked chip is clickable per fragment. The example gains a wrapping section; CHANGELOG updated. --- CHANGELOG.md | 6 +- .../features/text/InlineHighlightExample.java | 11 ++ .../document/layout/TextFlowSupport.java | 100 ++++++++++--- .../dsl/InlineHighlightRenderTest.java | 138 ++++++++++++++++++ 4 files changed, 236 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ef95fd..f5f9ebe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,8 +59,10 @@ PDF `GoTo` actions. External links are unchanged. a new `InlineBackground(fill, cornerRadius, padding)` carried by a new sealed `InlineRun` variant, `InlineHighlightRun`; horizontal padding widens the run's advance, vertical padding overflows the line box without changing line metrics. - Text-only backends (DOCX) keep the text and drop the fill. (A follow-up adds - multi-word coalescing and wrapping the chip across line breaks.) + A multi-word chip wraps with the surrounding line, painting one continuous + rounded fill per visual-line fragment (its horizontal padding sits on the run's + outer edges, so a wrapped fragment is open on the inner break). Text-only + backends (DOCX) keep the text and drop the fill. - **Colour emoji by shortcode** (`@since 1.9.0`). `RichText.emoji(":star:", size)` and `ParagraphBuilder.inlineEmoji(...)` resolve a GitHub-style shortcode to an inline vector colour glyph. Resolution is lenient — an unknown shortcode (or no emoji diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java index b8ad1d0a..c983e8ee 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java @@ -91,6 +91,17 @@ public static Path generate() throws Exception { .plain(" square ") .highlight("sharp", chipText(), DocumentColor.rgb(255, 228, 230), 0.0, DocumentInsets.symmetric(2, 6)))) + .addSection("Wrapping", section -> labelledRow(section, + "a multi-word highlight wraps across lines — one continuous fill per fragment", + rich -> rich + .plain("Reviewer note: ") + .highlight("this longer highlighted phrase is intentionally verbose so that it " + + "spans more than one visual line, and the engine still paints a " + + "continuous rounded chip on each line fragment rather than one " + + "box per word", + chipText(), DocumentColor.rgb(254, 249, 195), + 4.0, DocumentInsets.symmetric(2, 6)) + .plain(" — done."))) .addSection("Footer", section -> section .accentTop(THEME.palette().rule(), 0.6) .padding(new DocumentInsets(8, 0, 0, 0)) 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 c1cb4f43..b26d1c22 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -952,10 +952,10 @@ private static List wrapInlineParagraph(List runs, 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 — in PR-1 — a highlight chip, which stays - // one token until multi-word coalescing lands). + // 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). currentLine.add(sanitizedToken); currentWidth += tokenWidth; continue; @@ -1269,16 +1269,27 @@ private static List> tokenizeInlineRuns(List } TextStyle style = highlight.textStyle() == null ? defaultStyle : toTextStyle(highlight.textStyle()); - // PR-1: a highlight chip is one atomic token (no word-split / wrap - // across lines yet — a follow-up adds multi-word coalescing), so - // newlines collapse to spaces and the whole run stays one token. + // A chip stays on one logical line (newlines collapse to spaces) but + // its text tokenizes into words, all tagged with the same group, so + // it wraps with the surrounding line. Horizontal padding sits on the + // run's outer edges — lead pad on the first word, trail pad on the + // last — and toInlineParagraphLine coalesces the same-group tokens on + // each visual line back into one rounded fill. String normalized = BlockText.sanitizeText( highlight.text().replace("\r\n", " ").replace('\r', ' ').replace('\n', ' ')); if (normalized.isEmpty()) { continue; } - currentLine.add(InlineTextToken.ofHighlight( - normalized, style, highlight.linkTarget(), highlight.background(), highlight, measurement)); + List words = tokenize(normalized); + DocumentInsets pad = highlight.background().padding(); + for (int wordIndex = 0; wordIndex < words.size(); wordIndex++) { + currentLine.add(InlineTextToken.ofHighlight( + words.get(wordIndex), style, highlight.linkTarget(), + highlight.background(), highlight, + wordIndex == 0 ? pad.left() : 0.0, + wordIndex == words.size() - 1 ? pad.right() : 0.0, + measurement)); + } } } @@ -1337,8 +1348,56 @@ private static ParagraphLine toInlineParagraphLine(List token List spans = new ArrayList<>(trimmedTokens.size()); StringBuilder text = new StringBuilder(); double width = 0.0; - for (InlineLayoutToken token : trimmedTokens) { - if (token instanceof InlineTextToken textToken) { + int tokenIndex = 0; + while (tokenIndex < trimmedTokens.size()) { + InlineLayoutToken token = trimmedTokens.get(tokenIndex); + if (token instanceof InlineTextToken chipStart && chipStart.highlightGroup() != null) { + // Coalesce every consecutive token of the same chip run on this + // visual line into ONE span, so a multi-word (or wrapped) chip paints + // a single rounded fill per line-fragment. Padding sits on the + // fragment's outer edges — the lead pad of the first token consumed + // and the trail pad of the last — so a wrapped fragment is open on + // the inner break edge. + Object group = chipStart.highlightGroup(); + InlineBackground source = chipStart.background(); + List parts = new ArrayList<>(); + while (tokenIndex < trimmedTokens.size() + && trimmedTokens.get(tokenIndex) instanceof InlineTextToken part + && part.highlightGroup() == group) { + parts.add(part); + tokenIndex++; + } + // Collapse a soft-wrap space: when the run continues onto the next + // line the trailing whitespace token carries no trail pad, so drop it + // — the fill ends at the last visible glyph and the seam space stays + // out of line width. The run's authored trailing space keeps its trail + // pad and is preserved. + int end = parts.size(); + while (end > 1 && parts.get(end - 1).text().isBlank() && parts.get(end - 1).trailPad() == 0.0) { + end--; + } + double leftPad = parts.get(0).leadPad(); + double trailPad = parts.get(end - 1).trailPad(); + double glyphs = 0.0; + StringBuilder chip = new StringBuilder(); + for (int partIndex = 0; partIndex < end; partIndex++) { + chip.append(parts.get(partIndex).text()); + glyphs += parts.get(partIndex).width(); + } + double spanWidth = leftPad + glyphs + trailPad; + DocumentInsets basePad = source.padding(); + InlineBackground fragment = new InlineBackground(source.fill(), source.cornerRadius(), + new DocumentInsets(basePad.top(), trailPad, basePad.bottom(), leftPad)); + spans.add(new ParagraphTextSpan( + chip.toString(), + chipStart.textStyle(), + spanWidth, + measurement.lineMetrics(chipStart.textStyle()).lineHeight(), + chipStart.linkTarget(), + fragment)); + text.append(chip); + width += spanWidth; + } else if (token instanceof InlineTextToken textToken) { // wrapWidth folds in the chip's horizontal padding (zero for plain // text), so the span width and the line width both account for it. double spanWidth = textToken.wrapWidth(); @@ -1351,6 +1410,7 @@ private static ParagraphLine toInlineParagraphLine(List token textToken.background())); text.append(textToken.text()); width += spanWidth; + tokenIndex++; } else if (token instanceof InlineImageToken imageToken) { spans.add(new ParagraphImageSpan( imageToken.imageData(), @@ -1360,6 +1420,7 @@ private static ParagraphLine toInlineParagraphLine(List token imageToken.baselineOffset(), imageToken.linkTarget())); width += imageToken.width(); + tokenIndex++; } else if (token instanceof InlineShapeToken shapeToken) { spans.add(new ParagraphShapeSpan( shapeToken.layers(), @@ -1369,6 +1430,7 @@ private static ParagraphLine toInlineParagraphLine(List token shapeToken.baselineOffset(), shapeToken.linkTarget())); width += shapeToken.width(); + tokenIndex++; } else if (token instanceof InlineSvgToken svgToken) { spans.add(new ParagraphSvgSpan( svgToken.layers(), @@ -1378,6 +1440,9 @@ private static ParagraphLine toInlineParagraphLine(List token svgToken.baselineOffset(), svgToken.linkTarget())); width += svgToken.width(); + tokenIndex++; + } else { + tokenIndex++; } } @@ -1428,10 +1493,10 @@ private static InlineLayoutToken trimLeadingIfInlineLineStart(InlineLayoutToken return token; } if (textToken.highlightGroup() != null) { - // A chip is one atomic token carrying its own background/padding; - // never strip its leading whitespace or rebuild it via the plain - // factory (that would silently drop the fill — see the sibling guard - // in wrapInlineParagraph's long-token branch). + // A chip token carries the run's background/group/padding; never strip + // its leading whitespace or rebuild it via the plain factory (that would + // drop the fill). The chip's words reassemble in toInlineParagraphLine, + // which collapses the soft-wrap space at a wrap seam. return textToken; } if (!inlineLineHasVisibleContent(currentLine)) { @@ -1665,13 +1730,14 @@ private static InlineTextToken ofHighlight(String text, DocumentLinkTarget linkTarget, InlineBackground background, Object highlightGroup, + double leadPad, + double trailPad, TextMeasurementSystem measurement) { String safeText = text == null ? "" : text; TextStyle safeStyle = style == null ? TextStyle.DEFAULT_STYLE : style; double width = safeText.isEmpty() ? 0.0 : measurement.textWidth(safeStyle, safeText); - DocumentInsets pad = background.padding(); return new InlineTextToken(safeText, safeStyle, linkTarget, width, - background, highlightGroup, pad.left(), pad.right()); + background, highlightGroup, leadPad, trailPad); } } 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 1c726216..fa2b1577 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java @@ -5,6 +5,7 @@ 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.DocumentLinkOptions; import com.demcha.compose.document.style.DocumentColor; @@ -164,6 +165,143 @@ void overWideAtomicChipRendersWithoutThrowing() throws Exception { } } + @Test + void multiWordChipWrapsToOneCoalescedFillPerLineFragment() throws Exception { + // A multi-word chip too wide for the column wraps; each visual line carries + // exactly ONE coalesced chip span (one rounded fill per fragment, not per + // word), and a continuation fragment opens on its inner edge (no lead pad). + List lines; + try (DocumentSession session = GraphCompose.document().pageSize(130, 220).margin(12, 12, 12, 12).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineHighlight("alpha beta gamma delta epsilon", MONO, FILL, 3.0, PAD)) + .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 multi-word chip wraps to >=2 line fragments").hasSizeGreaterThanOrEqualTo(2); + for (ParagraphLine line : chipLines) { + long chipSpans = line.spans().stream() + .filter(s -> s instanceof ParagraphTextSpan ts && ts.background() != null).count(); + assertThat(chipSpans).as("each fragment is one coalesced span, not split per word").isEqualTo(1); + } + ParagraphTextSpan first = chipSpan(chipLines.get(0)); + ParagraphTextSpan continuation = chipSpan(chipLines.get(1)); + assertThat(first.background().padding().left()).as("first fragment keeps the lead pad").isGreaterThan(0.0); + assertThat(continuation.background().padding().left()) + .as("continuation fragment is open on the inner edge (slice)").isEqualTo(0.0); + } + + @Test + void wrappedChipPaintsAcrossBothFragments() throws Exception { + // The same chip on a wide column (one line) vs a narrow one (wrapped): the + // wrapped fill spans more vertical extent — proof the fill paints on every + // fragment, not just the first. (The fragments' fills overlap vertically via + // the vertical padding, like a browser highlight, so they read as one band.) + String text = "alpha beta gamma delta epsilon"; + int single = fillYExtent(renderHighlightAt(420, text), 255, 235, 59, 40); + int wrapped = fillYExtent(renderHighlightAt(130, text), 255, 235, 59, 40); + assertThat(single).as("the single-line chip paints").isGreaterThan(0); + assertThat(wrapped).as("the wrapped chip fill spans an extra line vs the single-line chip") + .isGreaterThan(single + 15); + } + + private static ParagraphTextSpan chipSpan(ParagraphLine line) { + return (ParagraphTextSpan) line.spans().stream() + .filter(s -> s instanceof ParagraphTextSpan ts && ts.background() != null) + .findFirst().orElseThrow(); + } + + private static List paragraphLines(LayoutGraph graph) { + return graph.fragments().stream() + .map(PlacedFragment::payload) + .filter(ParagraphFragmentPayload.class::isInstance) + .map(ParagraphFragmentPayload.class::cast) + .flatMap(payload -> payload.lines().stream()) + .toList(); + } + + private static BufferedImage renderHighlightAt(double pageWidth, String text) throws Exception { + byte[] pdf; + try (DocumentSession session = GraphCompose.document().pageSize(pageWidth, 220).margin(12, 12, 12, 12).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineHighlight(text, MONO, FILL, 3.0, PAD)) + .build(); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFRenderer(document).renderImageWithDPI(0, 144); + } + } + + /** Vertical extent (maxY - minY, px) of pixels matching the colour; 0 if none present. */ + private static int fillYExtent(BufferedImage image, int r, int g, int b, int tolerance) { + int minY = Integer.MAX_VALUE; + int maxY = -1; + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int rgb = image.getRGB(x, y); + if (Math.abs(((rgb >> 16) & 0xFF) - r) <= tolerance + && Math.abs(((rgb >> 8) & 0xFF) - g) <= tolerance + && Math.abs((rgb & 0xFF) - b) <= tolerance) { + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + break; + } + } + } + return maxY < 0 ? 0 : maxY - minY; + } + + @Test + void wrappedChipMidParagraphKeepsItsFullTextAcrossTheBreak() throws Exception { + // A chip with plain text on BOTH sides that wraps: every chip word survives + // in order (not dropped or duplicated at the wrap seam), and the surrounding + // text is intact on both sides. + String chip = "alpha beta gamma delta epsilon"; + byte[] pdf; + try (DocumentSession session = GraphCompose.document().pageSize(130, 220).margin(12, 12, 12, 12).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineText("Tags: ").inlineHighlight(chip, MONO, FILL, 3.0, PAD).inlineText(" end")) + .build(); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + String rendered = new PDFTextStripper().getText(document).replaceAll("\\s+", " ").trim(); + assertThat(rendered).isEqualTo("Tags: alpha beta gamma delta epsilon end"); + } + } + + @Test + void twoAdjacentDifferentChipsStaySeparateSpans() throws Exception { + List spans = textSpans(p -> p + .inlineChip("A", DocumentColor.rgb(0, 100, 0), DocumentColor.rgb(220, 255, 220)) + .inlineChip("B", DocumentColor.rgb(100, 0, 0), DocumentColor.rgb(255, 220, 220))); + assertThat(spans.stream().filter(s -> s.background() != null).count()) + .as("two distinct chips do not coalesce into one fill") + .isEqualTo(2); + } + + @Test + void wrappedLinkedChipEmitsAClickableRectPerFragment() throws Exception { + byte[] pdf; + try (DocumentSession session = GraphCompose.document().pageSize(130, 220).margin(12, 12, 12, 12).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineHighlight("alpha beta gamma delta epsilon", 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 wrapped linked chip is clickable on each visual line fragment") + .isGreaterThanOrEqualTo(2); + assertThat(new PDFTextStripper().getText(document)).contains("alpha").contains("epsilon").doesNotContain("?"); + } + } + 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(); From 3d792c1ac21961520872d2e7b3108e6144e897c4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 22 Jun 2026 21:21:21 +0100 Subject: [PATCH 2/2] fix(layout): collapse the leading soft-wrap space on a wrapped chip too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrapped highlight chip whose break lands so the inter-word space leads the continuation line kept that space (chip tokens are exempt from the leading trimmer) — the mirror of the already-collapsed trailing case — so the continuation fill started a space-width left of the first glyph. Make the seam-space collapse symmetric: drop a leading inter-word space (leadPad == 0) as well as a trailing one. Adds a width-sweep test asserting no chip fragment leads or trails with a soft-wrap space, and locks the final fragment's closed-right pad. --- .../document/layout/TextFlowSupport.java | 22 +++++++----- .../dsl/InlineHighlightRenderTest.java | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) 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 b26d1c22..e553f88e 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -1367,20 +1367,26 @@ private static ParagraphLine toInlineParagraphLine(List token parts.add(part); tokenIndex++; } - // Collapse a soft-wrap space: when the run continues onto the next - // line the trailing whitespace token carries no trail pad, so drop it - // — the fill ends at the last visible glyph and the seam space stays - // out of line width. The run's authored trailing space keeps its trail - // pad and is preserved. + // Collapse a soft-wrap space at a wrap seam: a continuation fragment + // can begin or end with an inter-word space token (which carries no + // lead/trail pad). Drop those so the fill hugs the visible glyphs and + // the seam space stays out of line width. The run's AUTHORED outer + // spaces keep their pad (leadPad/trailPad > 0) and are preserved. + // tokenize() coalesces consecutive whitespace, so at most one token is + // trimmed per side; the guard keeps at least one token regardless. + int start = 0; int end = parts.size(); - while (end > 1 && parts.get(end - 1).text().isBlank() && parts.get(end - 1).trailPad() == 0.0) { + while (end - start > 1 && parts.get(end - 1).text().isBlank() && parts.get(end - 1).trailPad() == 0.0) { end--; } - double leftPad = parts.get(0).leadPad(); + while (end - start > 1 && parts.get(start).text().isBlank() && parts.get(start).leadPad() == 0.0) { + start++; + } + double leftPad = parts.get(start).leadPad(); double trailPad = parts.get(end - 1).trailPad(); double glyphs = 0.0; StringBuilder chip = new StringBuilder(); - for (int partIndex = 0; partIndex < end; partIndex++) { + for (int partIndex = start; partIndex < end; partIndex++) { chip.append(parts.get(partIndex).text()); glyphs += parts.get(partIndex).width(); } 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 fa2b1577..400dd345 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java @@ -188,9 +188,12 @@ void multiWordChipWrapsToOneCoalescedFillPerLineFragment() throws Exception { } ParagraphTextSpan first = chipSpan(chipLines.get(0)); ParagraphTextSpan continuation = chipSpan(chipLines.get(1)); + ParagraphTextSpan last = chipSpan(chipLines.get(chipLines.size() - 1)); assertThat(first.background().padding().left()).as("first fragment keeps the lead pad").isGreaterThan(0.0); assertThat(continuation.background().padding().left()) .as("continuation fragment is open on the inner edge (slice)").isEqualTo(0.0); + assertThat(last.background().padding().right()).as("final fragment keeps the trail pad (closed-right)") + .isGreaterThan(0.0); } @Test @@ -254,6 +257,37 @@ private static int fillYExtent(BufferedImage image, int r, int g, int b, int tol return maxY < 0 ? 0 : maxY - minY; } + @Test + void wrappedChipHasNoSoftWrapSeamSpaceAtAnyWidth() throws Exception { + // Across a sweep of column widths (so the break lands at many different + // points), no chip fragment may begin or end with a soft-wrap space: a + // fragment leads/trails with a space only where it carries the authored + // outer pad (leftPad/rightPad > 0). Guards both the leading and trailing + // seam-collapse. + String text = "alpha beta gamma delta epsilon zeta eta theta"; + for (int width = 78; width <= 220; width += 6) { + int w = width; + List lines; + try (DocumentSession session = GraphCompose.document().pageSize(w, 300).margin(10, 10, 10, 10).create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p.inlineHighlight(text, MONO, FILL, 3.0, PAD)).build(); + lines = paragraphLines(session.layoutGraph()); + } + lines.stream() + .flatMap(l -> l.spans().stream()) + .filter(s -> s instanceof ParagraphTextSpan ts && ts.background() != null) + .map(ParagraphTextSpan.class::cast) + .forEach(f -> { + if (f.background().padding().left() == 0.0) { + assertThat(f.text()).as("no leading soft-wrap space at width " + w).doesNotStartWith(" "); + } + if (f.background().padding().right() == 0.0) { + assertThat(f.text()).as("no trailing soft-wrap space at width " + w).doesNotEndWith(" "); + } + }); + } + } + @Test void wrappedChipMidParagraphKeepsItsFullTextAcrossTheBreak() throws Exception { // A chip with plain text on BOTH sides that wraps: every chip word survives