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..e553f88e 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,62 @@ 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 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 - start > 1 && parts.get(end - 1).text().isBlank() && parts.get(end - 1).trailPad() == 0.0) { + end--; + } + 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 = start; 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 +1416,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 +1426,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 +1436,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 +1446,9 @@ private static ParagraphLine toInlineParagraphLine(List token svgToken.baselineOffset(), svgToken.linkTarget())); width += svgToken.width(); + tokenIndex++; + } else { + tokenIndex++; } } @@ -1428,10 +1499,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 +1736,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..400dd345 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,177 @@ 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)); + 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 + 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 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 + // 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();