From da50250bcfb72e137c2bc6b4b98d1555d0b4aa75 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 22 Jun 2026 17:37:07 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20inline=20highlight=20chips=20?= =?UTF-8?q?=E2=80=94=20text=20on=20a=20rounded=20background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new sealed InlineRun variant, InlineHighlightRun, draws inline text on a rounded, padded background fill — the GitHub inline `code` look and inline status badges. RichText.highlight(text, style, bg, radius, padding) is the primitive; code(text) ships engine defaults (monospace + a light chip), chip(text, fg, bg) colours a badge, plus the matching ParagraphBuilder .inlineHighlight / inlineCode / inlineChip and a highlight overload taking DocumentLinkOptions so a chip can also be a link. It rides the existing text-span path: the run lowers to a text token carrying a new InlineBackground(fill, cornerRadius, padding), ParagraphTextSpan gains a nullable background, and the PDF handler paints a rounded rect (reusing PdfShapeGeometry.roundedRectPath) behind the glyphs. Horizontal padding widens the run's advance and counts toward wrapping; vertical padding overflows the line box without changing line metrics. Text-only backends keep the text and drop the fill. First cut: a chip is one atomic token (covers inline code and single-line badges); multi-word coalescing and wrapping a chip across line breaks land in a follow-up. Adds InlineHighlightRunTest + InlineHighlightRenderTest and InlineHighlightExample. --- CHANGELOG.md | 12 ++ .../demcha/examples/GenerateAllExamples.java | 2 + .../features/text/InlineHighlightExample.java | 143 ++++++++++++++++++ .../PdfParagraphFragmentRenderHandler.java | 54 +++++++ .../demcha/compose/document/dsl/CodeChip.java | 31 ++++ .../document/dsl/ParagraphBuilder.java | 92 +++++++++++ .../demcha/compose/document/dsl/RichText.java | 87 +++++++++++ .../document/layout/TextFlowSupport.java | 82 ++++++++-- .../layout/payloads/ParagraphTextSpan.java | 31 +++- .../document/node/InlineHighlightRun.java | 61 ++++++++ .../compose/document/node/InlineRun.java | 12 +- .../compose/document/node/ParagraphNode.java | 13 +- .../document/style/InlineBackground.java | 35 +++++ .../dsl/InlineHighlightRenderTest.java | 138 +++++++++++++++++ .../document/dsl/InlineHighlightRunTest.java | 93 ++++++++++++ 15 files changed, 860 insertions(+), 26 deletions(-) create mode 100644 examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java create mode 100644 src/main/java/com/demcha/compose/document/dsl/CodeChip.java create mode 100644 src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java create mode 100644 src/main/java/com/demcha/compose/document/style/InlineBackground.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 735b4b5d..88ef95fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,18 @@ PDF `GoTo` actions. External links are unchanged. A new sealed `InlineRun` variant (`InlineSvgRun`) joins text / image / shape; the inline render reuses the existing SVG paint pipeline (shared with the block path fragment), so flat-colour output stays byte-identical. +- **Inline highlight chips** (`@since 1.9.0`). An inline run can now sit on a + rounded, padded background fill — the GitHub inline `code` look and inline + status badges. `RichText.highlight(text, style, bg, radius, padding)` is the + primitive; `code(text)` ships engine defaults (a monospace font, a muted ink + and a light chip) and `chip(text, fg, bg)` colours a badge — with the matching + `ParagraphBuilder.inlineHighlight` / `inlineCode` / `inlineChip`. A `highlight` + overload takes `DocumentLinkOptions`, so a chip can also be a link. The fill is + 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.) - **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/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 7e78f28b..c546c72e 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -22,6 +22,7 @@ import com.demcha.examples.features.text.EmojiClipPathReportExample; import com.demcha.examples.features.text.InlineShapesExample; import com.demcha.examples.features.text.InlineSvgIconExample; +import com.demcha.examples.features.text.InlineHighlightExample; import com.demcha.examples.features.navigation.InPdfNavigationExample; import com.demcha.examples.features.text.RichTextShowcaseExample; import com.demcha.examples.features.text.SectionPresetsExample; @@ -150,6 +151,7 @@ public static void main(String[] args) throws Exception { // Text + sections System.out.println("Generated: " + InlineShapesExample.generate()); System.out.println("Generated: " + InlineSvgIconExample.generate()); + System.out.println("Generated: " + InlineHighlightExample.generate()); System.out.println("Generated: " + EmojiShortcodeExample.generate()); System.out.println("Generated: " + EmojiSvgVsPngExample.generate()); System.out.println("Generated: " + EmojiGalleryExample.generate()); 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 new file mode 100644 index 00000000..b8ad1d0a --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java @@ -0,0 +1,143 @@ +package com.demcha.examples.features.text; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.RichText; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.theme.BusinessTheme; +import com.demcha.compose.font.FontName; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; +import java.util.function.Consumer; + +/** + * Runnable showcase for inline highlight "chips" ({@code @since 1.9.0}). + * + *

Styled text drawn on a rounded, padded background fill, on the text + * baseline, flowing inside a paragraph — the GitHub inline {@code code} look and + * inline status badges. {@code RichText.code(text)} ships engine defaults + * (monospace + a light chip); {@code chip(text, fg, bg)} colours a badge; and + * {@code highlight(text, style, bg, radius, padding)} is the full primitive. + * On {@code ParagraphBuilder} the calls are {@code inlineCode} / {@code inlineChip} + * / {@code inlineHighlight}.

+ */ +public final class InlineHighlightExample { + private static final BusinessTheme THEME = BusinessTheme.modern(); + private static final DocumentColor MUTED = DocumentColor.rgb(112, 116, 128); + private static final DocumentColor BRAND = DocumentColor.rgb(20, 80, 95); + private static final DocumentColor PANEL = DocumentColor.rgb(248, 244, 234); + + // Status-badge palette: ink + a soft tinted fill. + private static final DocumentColor PAID_FG = DocumentColor.rgb(22, 101, 52); + private static final DocumentColor PAID_BG = DocumentColor.rgb(220, 252, 231); + private static final DocumentColor DUE_FG = DocumentColor.rgb(153, 27, 27); + private static final DocumentColor DUE_BG = DocumentColor.rgb(254, 226, 226); + private static final DocumentColor HOLD_FG = DocumentColor.rgb(146, 64, 14); + private static final DocumentColor HOLD_BG = DocumentColor.rgb(254, 243, 199); + + private InlineHighlightExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("features/text", "inline-highlight-chips.pdf"); + + try (DocumentSession document = GraphCompose.document(outputFile) + .pageSize(DocumentPageSize.A4) + .pageBackground(THEME.pageBackground()) + .margin(34, 34, 34, 34) + .create()) { + + document.pageFlow() + .name("InlineHighlightShowcase") + .spacing(14) + .addSection("Hero", section -> section + .softPanel(THEME.palette().surfaceMuted(), 10, 16) + .accentLeft(DocumentColor.rgb(97, 40, 217), 4) + .spacing(6) + .addParagraph(p -> p + .text("Inline highlight chips") + .textStyle(THEME.text().h1()) + .margin(DocumentInsets.zero())) + .addRich(rich -> rich + .plain("Text on a rounded background, on the baseline ") + .accent("— inline code and status badges", BRAND) + .plain(". Add the ") + .code("graph-compose") + .plain(" dependency from ") + .code("io.github.demchaav") + .plain("."))) + .addSection("Code", section -> labelledRow(section, + "code(text) — monospace on a light chip, engine defaults", + rich -> rich + .plain("Run ").code("./mvnw verify").plain(" then tag ") + .code("v1.9.0").plain(" to publish ").code("graph-compose-emoji"))) + .addSection("Badges", section -> labelledRow(section, + "chip(text, fg, bg) — a coloured status badge between words", + rich -> rich + .chip(" Paid ", PAID_FG, PAID_BG).plain(" ") + .chip(" Overdue ", DUE_FG, DUE_BG).plain(" ") + .chip(" On hold ", HOLD_FG, HOLD_BG))) + .addSection("Custom", section -> labelledRow(section, + "highlight(text, style, bg, radius, padding) — the full primitive", + rich -> rich + .plain("Pill ") + .highlight("rounded", chipText(), DocumentColor.rgb(224, 231, 255), + 8.0, DocumentInsets.symmetric(2, 8)) + .plain(" square ") + .highlight("sharp", chipText(), DocumentColor.rgb(255, 228, 230), + 0.0, DocumentInsets.symmetric(2, 6)))) + .addSection("Footer", section -> section + .accentTop(THEME.palette().rule(), 0.6) + .padding(new DocumentInsets(8, 0, 0, 0)) + .addRich(rich -> rich + .plain("Source: ") + .style("examples/.../InlineHighlightExample.java", + DocumentTextStyle.builder() + .fontName(FontName.COURIER) + .size(8) + .color(MUTED) + .build()))) + .build(); + + document.buildPdf(); + } + + return outputFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } + + private static void labelledRow(SectionBuilder section, String label, Consumer body) { + section + .softPanel(PANEL, 6, 12) + .spacing(4) + .addParagraph(p -> p + .text(label) + .textStyle(caption()) + .margin(DocumentInsets.zero())) + .addRich(body::accept); + } + + private static DocumentTextStyle chipText() { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(9) + .color(DocumentColor.rgb(55, 48, 163)) + .build(); + } + + private static DocumentTextStyle caption() { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(8.5) + .color(MUTED) + .build(); + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index 66b8c6bc..f95805e7 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -7,6 +7,8 @@ import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.document.node.TextVerticalAlign; import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.InlineBackground; import com.demcha.compose.document.style.ShapeOutline; import com.demcha.compose.engine.render.pdf.PdfFont; import com.demcha.compose.font.FontLibrary; @@ -117,6 +119,45 @@ private static double resolveInlineGraphicBottom(double graphicHeight, return base + baselineOffset; } + /** + * Draws a highlight "chip": a rounded fill behind the span's glyphs, then the + * glyphs in their own text block offset right by the chip's left padding. The + * fill band is the text cap/descent band expanded by vertical padding, so the + * chip overflows the line box like a browser highlight without enlarging it. + */ + private static void renderChip(PDPageContentStream stream, + FontLibrary fonts, + ParagraphTextSpan span, + double cursorX, + double baselineY, + ParagraphLine line, + TextRenderState textState) throws IOException { + InlineBackground background = span.background(); + DocumentInsets pad = background.padding(); + float chipWidth = (float) span.width(); // glyphs + left + right padding + float chipBottom = (float) (baselineY - line.baselineOffsetFromBottom() - pad.bottom()); + float chipHeight = (float) (line.textLineHeight() + pad.vertical()); + Color fill = background.fill() == null ? null : background.fill().color(); + if (fill != null && chipWidth > 0 && chipHeight > 0) { + float radius = (float) Math.min(background.cornerRadius(), Math.min(chipWidth, chipHeight) / 2.0f); + PdfShapeGeometry.fillAndStrokePath(stream, fill, null, s -> + PdfShapeFragmentRenderHandler.drawRoundedRectangle( + s, (float) cursorX, chipBottom, chipWidth, chipHeight, radius, radius, radius, radius)); + } + PdfFont font = fonts.getFont(span.textStyle().fontName(), PdfFont.class).orElseThrow(); + String text = font.sanitizeForRender(span.textStyle(), span.text()); + if (text.isEmpty()) { + return; + } + stream.beginText(); + stream.newLineAtOffset((float) (cursorX + pad.left()), (float) baselineY); + textState.invalidate(); + textState.applyFont(stream, font.fontType(span.textStyle().decoration()), (float) span.textStyle().size()); + textState.applyColor(stream, span.textStyle().color()); + stream.showText(text); + stream.endText(); + } + private static void renderShape(PDPageContentStream stream, ParagraphShapeSpan span, double cursorX, @@ -284,6 +325,19 @@ private void renderLine(PDPageContentStream stream, try { for (ParagraphSpan span : spans) { if (span instanceof ParagraphTextSpan textSpan) { + if (textSpan.background() != null) { + // A chip span paints a rounded fill plus its glyphs in an + // isolated text block (offset by the left padding), so it + // closes any open run first and is a fragment boundary. + if (inTextBlock) { + stream.endText(); + inTextBlock = false; + } + renderChip(stream, fonts, textSpan, cursorX, baselineY, line, textState); + textState.invalidate(); + cursorX += textSpan.width(); + continue; + } PdfFont font = fonts.getFont(textSpan.textStyle().fontName(), PdfFont.class).orElseThrow(); // Font-aware sanitization keeps width measurement // (PdfFont.getTextWidth) and the bytes emitted here diff --git a/src/main/java/com/demcha/compose/document/dsl/CodeChip.java b/src/main/java/com/demcha/compose/document/dsl/CodeChip.java new file mode 100644 index 00000000..7f0cf20c --- /dev/null +++ b/src/main/java/com/demcha/compose/document/dsl/CodeChip.java @@ -0,0 +1,31 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.InlineBackground; +import com.demcha.compose.font.FontName; + +/** + * Default styling for the {@code code(...)} inline-chip sugar, single-sourced so + * {@link RichText} and {@link ParagraphBuilder} stay in lockstep. GitHub-ish: + * a monospace glyph in a muted ink on a light, translucent rounded fill. + */ +final class CodeChip { + + /** Muted code ink (GitHub light {@code #24292f}). */ + static final DocumentColor TEXT_COLOR = DocumentColor.rgb(36, 41, 47); + + /** Light translucent chip fill (GitHub {@code rgba(175,184,193,.2)}). */ + static final InlineBackground BACKGROUND = new InlineBackground( + DocumentColor.rgb(175, 184, 193).withOpacity(0.20), 3.0, DocumentInsets.symmetric(1.0, 4.0)); + + /** Monospace code glyph style. */ + static final DocumentTextStyle STYLE = DocumentTextStyle.builder() + .fontName(FontName.COURIER) + .color(TEXT_COLOR) + .build(); + + private CodeChip() { + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java index 1326c66a..303f3412 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java @@ -10,6 +10,7 @@ import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.document.node.InlineShapeRun; import com.demcha.compose.document.node.InlineSvgRun; +import com.demcha.compose.document.node.InlineHighlightRun; import com.demcha.compose.document.node.InlineImageRun; import com.demcha.compose.document.node.ShapeLayer; import com.demcha.compose.document.node.InlineRun; @@ -19,6 +20,7 @@ import com.demcha.compose.document.node.TextVerticalAlign; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.InlineBackground; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextAutoSize; import com.demcha.compose.document.style.DocumentTextIndent; @@ -271,6 +273,96 @@ public ParagraphBuilder inlineText(String text, DocumentTextStyle textStyle, Doc return this; } + /** + * Adds an inline run drawn on a rounded background "chip" — styled text on a + * padded fill, on the text baseline (e.g. a GitHub-style inline {@code code} + * span). The chip wraps with the line; its background is a PDF decoration + * (text-only backends keep the text and drop the fill). + * + * @param text visible text + * @param textStyle glyph style; falls back to the paragraph style when {@code null} + * @param background chip fill colour; must not be {@code null} + * @param cornerRadius corner radius in points, clamped to half the chip height + * @param padding inset between the glyphs and the chip edges + * @return this builder + * @throws IllegalArgumentException if {@code cornerRadius} is negative or non-finite + * @since 1.9.0 + */ + public ParagraphBuilder inlineHighlight(String text, DocumentTextStyle textStyle, + DocumentColor background, double cornerRadius, DocumentInsets padding) { + return inlineHighlight(text, textStyle, background, cornerRadius, padding, null); + } + + /** + * Adds a clickable highlight chip: as {@link #inlineHighlight(String, + * DocumentTextStyle, DocumentColor, double, DocumentInsets)}, but the whole + * chip becomes an external link. + * + * @param text visible text + * @param textStyle glyph style; falls back to the paragraph style when {@code null} + * @param background chip fill colour; must not be {@code null} + * @param cornerRadius corner radius in points, clamped to half the chip height + * @param padding inset between the glyphs and the chip edges + * @param link external link metadata, or {@code null} for no link + * @return this builder + * @throws IllegalArgumentException if {@code cornerRadius} is negative or non-finite + * @since 1.9.0 + */ + public ParagraphBuilder inlineHighlight(String text, DocumentTextStyle textStyle, DocumentColor background, + double cornerRadius, DocumentInsets padding, DocumentLinkOptions link) { + this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, textStyle, + new InlineBackground(background, cornerRadius, padding), link)); + this.text = ""; + return this; + } + + /** + * Adds an inline code chip with engine defaults — a monospace font, a muted + * code ink and a light rounded background. + * + * @param text the code text + * @return this builder + * @since 1.9.0 + */ + public ParagraphBuilder inlineCode(String text) { + this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, CodeChip.STYLE, CodeChip.BACKGROUND)); + this.text = ""; + return this; + } + + /** + * Adds an inline code chip with an explicit glyph style (e.g. to match the + * paragraph size), keeping the default chip fill and padding. + * + * @param text the code text + * @param textStyle the glyph style (typically a monospace font) + * @return this builder + * @since 1.9.0 + */ + public ParagraphBuilder inlineCode(String text, DocumentTextStyle textStyle) { + this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, textStyle, CodeChip.BACKGROUND)); + this.text = ""; + return this; + } + + /** + * Adds a coloured chip: {@code text} in {@code fg} on a {@code bg} fill, with + * the default code radius and padding. + * + * @param text the text + * @param fg the text colour + * @param bg the chip fill colour; must not be {@code null} + * @return this builder + * @since 1.9.0 + */ + public ParagraphBuilder inlineChip(String text, DocumentColor fg, DocumentColor bg) { + this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, + DocumentTextStyle.builder().color(fg).build(), + new InlineBackground(bg, CodeChip.BACKGROUND.cornerRadius(), CodeChip.BACKGROUND.padding()))); + this.text = ""; + return this; + } + /** * Adds an inline image run measured on the same baseline as the * surrounding text. Width and height are explicit and uniform across diff --git a/src/main/java/com/demcha/compose/document/dsl/RichText.java b/src/main/java/com/demcha/compose/document/dsl/RichText.java index 2382bfb6..7055b23a 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RichText.java +++ b/src/main/java/com/demcha/compose/document/dsl/RichText.java @@ -192,6 +192,93 @@ public RichText style(String text, DocumentTextStyle style) { return appendRun(text, style, null); } + /** + * Appends an inline run drawn on a rounded background "chip" — styled text on + * a padded fill, on the text baseline (e.g. a GitHub-style inline {@code code} + * span or a status badge). The chip wraps with the line; its background is a + * PDF decoration (text-only backends keep the text and drop the fill). + * + * @param text visible text + * @param textStyle glyph style; falls back to the paragraph style when {@code null} + * @param background chip fill colour; must not be {@code null} + * @param cornerRadius corner radius in points, clamped to half the chip height + * @param padding inset between the glyphs and the chip edges + * @return this builder + * @throws IllegalArgumentException if {@code cornerRadius} is negative or non-finite + * @since 1.9.0 + */ + public RichText highlight(String text, DocumentTextStyle textStyle, + DocumentColor background, double cornerRadius, DocumentInsets padding) { + return highlight(text, textStyle, background, cornerRadius, padding, null); + } + + /** + * Appends a clickable highlight chip: as {@link #highlight(String, + * DocumentTextStyle, DocumentColor, double, DocumentInsets)}, but the whole + * chip (glyphs and padding) becomes an external link. + * + * @param text visible text + * @param textStyle glyph style; falls back to the paragraph style when {@code null} + * @param background chip fill colour; must not be {@code null} + * @param cornerRadius corner radius in points, clamped to half the chip height + * @param padding inset between the glyphs and the chip edges + * @param link external link metadata, or {@code null} for no link + * @return this builder + * @throws IllegalArgumentException if {@code cornerRadius} is negative or non-finite + * @since 1.9.0 + */ + public RichText highlight(String text, DocumentTextStyle textStyle, DocumentColor background, + double cornerRadius, DocumentInsets padding, DocumentLinkOptions link) { + runs.add(new InlineHighlightRun(text == null ? "" : text, textStyle, + new InlineBackground(background, cornerRadius, padding), link)); + return this; + } + + /** + * Appends an inline code chip with engine defaults — a monospace font, a + * muted code ink and a light rounded background (e.g. + * {@code code("io.github.demchaav")}). + * + * @param text the code text + * @return this builder + * @since 1.9.0 + */ + public RichText code(String text) { + runs.add(new InlineHighlightRun(text == null ? "" : text, CodeChip.STYLE, CodeChip.BACKGROUND)); + return this; + } + + /** + * Appends an inline code chip with an explicit glyph style (e.g. to match the + * paragraph size), keeping the default chip fill and padding. + * + * @param text the code text + * @param textStyle the glyph style (typically a monospace font) + * @return this builder + * @since 1.9.0 + */ + public RichText code(String text, DocumentTextStyle textStyle) { + runs.add(new InlineHighlightRun(text == null ? "" : text, textStyle, CodeChip.BACKGROUND)); + return this; + } + + /** + * Appends a coloured chip: {@code text} in {@code fg} on a {@code bg} fill, + * with the default code radius and padding. + * + * @param text the text + * @param fg the text colour + * @param bg the chip fill colour; must not be {@code null} + * @return this builder + * @since 1.9.0 + */ + public RichText chip(String text, DocumentColor fg, DocumentColor bg) { + runs.add(new InlineHighlightRun(text == null ? "" : text, + DocumentTextStyle.builder().color(fg).build(), + new InlineBackground(bg, CodeChip.BACKGROUND.cornerRadius(), CodeChip.BACKGROUND.padding()))); + return this; + } + /** * Appends a clickable link run with default link styling. * 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 f13de0b3..e83c25d1 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentPaint; +import com.demcha.compose.document.style.InlineBackground; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextAutoSize; import com.demcha.compose.document.style.DocumentTextIndent; @@ -572,6 +573,9 @@ private static boolean paragraphFitsSingleLine(ParagraphNode node, width += shapeRun.width(); } else if (run instanceof InlineSvgRun svgRun) { width += svgRun.width(); + } else if (run instanceof InlineHighlightRun highlight) { + width += measurement.textWidth(engineStyle, highlight.text()) + + highlight.background().padding().horizontal(); } } return width <= innerWidth; @@ -919,7 +923,7 @@ private static List wrapInlineParagraph(List runs, continue; } - double tokenWidth = sanitizedToken.width(); + double tokenWidth = sanitizedToken.wrapWidth(); if (currentLine.isEmpty() || currentWidth + tokenWidth <= maxWidth + EPS) { currentLine.add(sanitizedToken); currentWidth += tokenWidth; @@ -939,17 +943,19 @@ private static List wrapInlineParagraph(List runs, if (sanitizedToken == null) { continue; } - tokenWidth = sanitizedToken.width(); + tokenWidth = sanitizedToken.wrapWidth(); if (currentWidth + tokenWidth <= maxWidth + EPS) { currentLine.add(sanitizedToken); currentWidth += tokenWidth; continue; } - if (!(sanitizedToken instanceof InlineTextToken textToken)) { - // Atomic image runs that exceed the available width are - // emitted on their own line; further breaking is not - // possible. + 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). currentLine.add(sanitizedToken); currentWidth += tokenWidth; continue; @@ -1257,6 +1263,22 @@ private static List> tokenizeInlineRuns(List currentLine.add(InlineShapeToken.of(shapeRun)); } else if (run instanceof InlineSvgRun svgRun) { currentLine.add(InlineSvgToken.of(svgRun)); + } else if (run instanceof InlineHighlightRun highlight) { + if (highlight.text().isEmpty()) { + continue; + } + 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. + 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)); } } @@ -1317,14 +1339,18 @@ private static ParagraphLine toInlineParagraphLine(List token double width = 0.0; for (InlineLayoutToken token : trimmedTokens) { 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(); spans.add(new ParagraphTextSpan( textToken.text(), textToken.textStyle(), - textToken.width(), + spanWidth, measurement.lineMetrics(textToken.textStyle()).lineHeight(), - textToken.linkTarget())); + textToken.linkTarget(), + textToken.background())); text.append(textToken.text()); - width += textToken.width(); + width += spanWidth; } else if (token instanceof InlineImageToken imageToken) { spans.add(new ParagraphImageSpan( imageToken.imageData(), @@ -1368,7 +1394,7 @@ private static ParagraphLine toInlineParagraphLine(List token private static double inlineLineWidth(List tokens) { double width = 0.0; for (InlineLayoutToken token : tokens) { - width += token.width(); + width += token.wrapWidth(); } return width; } @@ -1564,6 +1590,15 @@ private static int maxLinesThatFit(List lines, double lineGap, do private sealed interface InlineLayoutToken permits InlineTextToken, InlineImageToken, InlineShapeToken, InlineSvgToken { double width(); + + /** + * Width used for line-wrap accounting. Equals {@link #width()} except for + * a highlight chip token, which reserves its outer horizontal padding here + * so wrapping accounts for the chip's full advance. + */ + default double wrapWidth() { + return width(); + } } private record ParagraphIndentSpec(String firstLinePrefix, String continuationPrefix) { @@ -1586,13 +1621,22 @@ private record InlineTextToken( String text, TextStyle textStyle, DocumentLinkTarget linkTarget, - double width + double width, + InlineBackground background, + Object highlightGroup, + double leadPad, + double trailPad ) implements InlineLayoutToken { private InlineTextToken { text = text == null ? "" : text; textStyle = textStyle == null ? TextStyle.DEFAULT_STYLE : textStyle; } + @Override + public double wrapWidth() { + return width + leadPad + trailPad; + } + private static InlineTextToken of(String text, TextStyle style, DocumentLinkTarget linkTarget, @@ -1600,7 +1644,21 @@ private static InlineTextToken of(String text, String safeText = text == null ? "" : text; TextStyle safeStyle = style == null ? TextStyle.DEFAULT_STYLE : style; double width = safeText.isEmpty() ? 0.0 : measurement.textWidth(safeStyle, safeText); - return new InlineTextToken(safeText, safeStyle, linkTarget, width); + return new InlineTextToken(safeText, safeStyle, linkTarget, width, null, null, 0.0, 0.0); + } + + private static InlineTextToken ofHighlight(String text, + TextStyle style, + DocumentLinkTarget linkTarget, + InlineBackground background, + Object highlightGroup, + 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()); } } diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphTextSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphTextSpan.java index fdb89211..fad81de3 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphTextSpan.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphTextSpan.java @@ -1,23 +1,26 @@ package com.demcha.compose.document.layout.payloads; import com.demcha.compose.document.node.DocumentLinkTarget; +import com.demcha.compose.document.style.InlineBackground; import com.demcha.compose.engine.components.content.text.TextStyle; /** * Measured text span inside a paragraph line. * - * @param text visible text for the span - * @param textStyle resolved text style - * @param width measured span width - * @param height font line height contribution + * @param text visible text for the span + * @param textStyle resolved text style + * @param width measured span width (includes the chip's horizontal padding when {@code background} is set) + * @param height font line height contribution * @param linkTarget optional link metadata for the span + * @param background optional rounded background "chip" painted behind the glyphs, or {@code null} */ public record ParagraphTextSpan( String text, TextStyle textStyle, double width, double height, - DocumentLinkTarget linkTarget + DocumentLinkTarget linkTarget, + InlineBackground background ) implements ParagraphSpan { /** * Creates a normalized measured paragraph text span. @@ -28,7 +31,21 @@ public record ParagraphTextSpan( } /** - * Convenience constructor without link metadata. + * Convenience constructor without a background chip. + * + * @param text visible text for the span + * @param textStyle resolved text style + * @param width measured span width + * @param height font line height contribution + * @param linkTarget optional link metadata for the span + */ + public ParagraphTextSpan(String text, TextStyle textStyle, double width, double height, + DocumentLinkTarget linkTarget) { + this(text, textStyle, width, height, linkTarget, null); + } + + /** + * Convenience constructor without link metadata or background. * * @param text visible text for the span * @param textStyle resolved text style @@ -36,6 +53,6 @@ public record ParagraphTextSpan( * @param height font line height contribution */ public ParagraphTextSpan(String text, TextStyle textStyle, double width, double height) { - this(text, textStyle, width, height, null); + this(text, textStyle, width, height, null, null); } } diff --git a/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java b/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java new file mode 100644 index 00000000..b3676c92 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/InlineHighlightRun.java @@ -0,0 +1,61 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.InlineBackground; + +import java.util.Objects; + +/** + * One inline text run drawn on a rounded background "chip" — styled text on a + * padded fill, seated on the text baseline and flowing inside a paragraph, e.g. + * a GitHub-style inline {@code code} span or a status badge. + * + *

Unlike the image/shape/SVG runs it is text: it wraps with the + * surrounding line. The background is a PDF decoration — text-only backends keep + * the text and drop the fill (see {@link ParagraphNode#inlineTextRuns()}).

+ * + * @param text visible text for the run + * @param textStyle style for the glyphs; falls back to the paragraph style when {@code null} + * @param background the chip fill, corner radius and padding; must not be {@code null} + * @param linkTarget optional link target (external URI or internal anchor) scoped to this run + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public record InlineHighlightRun( + String text, + DocumentTextStyle textStyle, + InlineBackground background, + DocumentLinkTarget linkTarget +) implements InlineRun { + /** + * Normalizes null text to an empty run and validates the background. + */ + public InlineHighlightRun { + text = text == null ? "" : text; + Objects.requireNonNull(background, "background"); + } + + /** + * Creates a highlight run with external link metadata. + * + * @param text visible text + * @param textStyle style for this run + * @param background chip fill / radius / padding + * @param linkOptions external link metadata, wrapped into an {@link ExternalLinkTarget} + */ + public InlineHighlightRun(String text, DocumentTextStyle textStyle, InlineBackground background, + DocumentLinkOptions linkOptions) { + this(text, textStyle, background, linkOptions == null ? null : new ExternalLinkTarget(linkOptions)); + } + + /** + * Creates a highlight run without link metadata. + * + * @param text visible text + * @param textStyle style for this run + * @param background chip fill / radius / padding + */ + public InlineHighlightRun(String text, DocumentTextStyle textStyle, InlineBackground background) { + this(text, textStyle, background, (DocumentLinkTarget) null); + } +} diff --git a/src/main/java/com/demcha/compose/document/node/InlineRun.java b/src/main/java/com/demcha/compose/document/node/InlineRun.java index 0bce616e..dce05796 100644 --- a/src/main/java/com/demcha/compose/document/node/InlineRun.java +++ b/src/main/java/com/demcha/compose/document/node/InlineRun.java @@ -4,12 +4,14 @@ * Marker for a single inline run inside a {@link ParagraphNode}. * *

An inline paragraph is a sequence of runs measured and rendered on the - * same baseline. Today there are four kinds of run: text, image, shape and - * SVG icon. All participate in the wrapping algorithm so callers can mix small - * icons, badges, vector glyphs (e.g. colour emoji) or geometric figures (dots, - * diamonds, stars, …) with styled text without resorting to nested layouts.

+ * same baseline. Today there are five kinds of run: text, image, shape, SVG + * icon and highlight (text on a background chip). All participate in the + * wrapping algorithm so callers can mix small icons, badges, vector glyphs + * (e.g. colour emoji) or geometric figures (dots, diamonds, stars, …) with + * styled text without resorting to nested layouts.

* * @author Artem Demchyshyn */ -public sealed interface InlineRun permits InlineTextRun, InlineImageRun, InlineShapeRun, InlineSvgRun { +public sealed interface InlineRun + permits InlineTextRun, InlineImageRun, InlineShapeRun, InlineSvgRun, InlineHighlightRun { } diff --git a/src/main/java/com/demcha/compose/document/node/ParagraphNode.java b/src/main/java/com/demcha/compose/document/node/ParagraphNode.java index 8e0db33e..72fcb24e 100644 --- a/src/main/java/com/demcha/compose/document/node/ParagraphNode.java +++ b/src/main/java/com/demcha/compose/document/node/ParagraphNode.java @@ -61,6 +61,8 @@ public record ParagraphNode( for (InlineRun run : inlineRuns) { if (run instanceof InlineTextRun textRun) { concatenated.append(textRun.text()); + } else if (run instanceof InlineHighlightRun highlight) { + concatenated.append(highlight.text()); } } text = concatenated.toString(); @@ -272,6 +274,9 @@ private static List normalizeInlineRuns(List runs) { if (run instanceof InlineTextRun textRun && textRun.text().isEmpty()) { continue; } + if (run instanceof InlineHighlightRun highlight && highlight.text().isEmpty()) { + continue; + } normalized.add(run); } return List.copyOf(normalized); @@ -281,8 +286,10 @@ private static List normalizeInlineRuns(List runs) { * Returns inline text runs in source order, filtering out image runs. * *

Provided for callers that only consume textual content (e.g. the - * DOCX semantic backend or text-only tests). Image runs are silently - * dropped — use {@link #inlineRuns()} to access the full mixed list.

+ * DOCX semantic backend or text-only tests). Image / shape / SVG runs are + * silently dropped; a highlight chip degrades to a plain text run (its + * background is a PDF-only decoration) so the text survives. Use + * {@link #inlineRuns()} to access the full mixed list.

* * @return inline text runs in source order */ @@ -294,6 +301,8 @@ public List inlineTextRuns() { for (InlineRun run : inlineRuns) { if (run instanceof InlineTextRun textRun) { textRuns.add(textRun); + } else if (run instanceof InlineHighlightRun highlight) { + textRuns.add(new InlineTextRun(highlight.text(), highlight.textStyle(), highlight.linkTarget())); } } return List.copyOf(textRuns); diff --git a/src/main/java/com/demcha/compose/document/style/InlineBackground.java b/src/main/java/com/demcha/compose/document/style/InlineBackground.java new file mode 100644 index 00000000..d98f2306 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/InlineBackground.java @@ -0,0 +1,35 @@ +package com.demcha.compose.document.style; + +import java.util.Objects; + +/** + * Background "chip" behind an inline run: a rounded, padded fill drawn beneath + * the glyphs on the text baseline — e.g. a GitHub-style inline {@code code} + * highlight. Backend-neutral: the PDF backend paints it as a filled rounded + * rectangle; a future text backend (DOCX) keeps the text and drops the fill. + * + *

Horizontal padding widens the run's advance (it reserves space and counts + * toward line wrapping). Vertical padding expands the chip outside the + * line box (the chip overflows like a browser highlight) so it never perturbs + * line metrics or pagination.

+ * + * @param fill background fill colour; must not be {@code null} + * @param cornerRadius corner radius in points, clamped to half the chip height + * at paint; must be finite and {@code >= 0} + * @param padding inset between the glyph box and the chip edges + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public record InlineBackground(DocumentColor fill, double cornerRadius, DocumentInsets padding) { + /** + * Validates the fill/radius and normalizes a null padding to + * {@link DocumentInsets#zero()}. + */ + public InlineBackground { + Objects.requireNonNull(fill, "fill"); + padding = padding == null ? DocumentInsets.zero() : padding; + if (cornerRadius < 0 || !Double.isFinite(cornerRadius)) { + throw new IllegalArgumentException("cornerRadius must be finite and >= 0: " + cornerRadius); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java new file mode 100644 index 00000000..a6b00085 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java @@ -0,0 +1,138 @@ +package com.demcha.compose.document.dsl; + +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.ParagraphTextSpan; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * End-to-end coverage for the inline highlight "chip": the layout reserves the + * chip's horizontal padding, and the PDF render paints a rounded fill behind the + * glyphs without dropping the text. + */ +class InlineHighlightRenderTest { + + private static final DocumentInsets PAD = DocumentInsets.symmetric(1.0, 4.0); + private static final DocumentTextStyle MONO = + DocumentTextStyle.builder().fontName(FontName.COURIER).size(11).build(); + /** Vivid yellow — distinct from black text on a white page. */ + private static final DocumentColor FILL = DocumentColor.rgb(255, 235, 59); + + @Test + void chipSpanReservesHorizontalPaddingInItsWidth() throws Exception { + List plain = textSpans(p -> p.inlineText("ABCDEF", MONO)); + List chip = textSpans(p -> p.inlineHighlight("ABCDEF", MONO, DocumentColor.GRAY, 3.0, PAD)); + assertThat(plain).hasSize(1); + assertThat(chip).hasSize(1); + assertThat(plain.get(0).background()).isNull(); + assertThat(chip.get(0).background()).isNotNull(); + assertThat(chip.get(0).width()) + .as("chip width = glyph width + horizontal padding") + .isCloseTo(plain.get(0).width() + PAD.horizontal(), within(0.5)); + } + + @Test + void chipFillPaintsBehindTheGlyphsAndKeepsTheText() throws Exception { + byte[] pdf = render(p -> p.inlineText("Status ").inlineHighlight("OK", MONO, FILL, 3.0, PAD)); + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(document.getNumberOfPages()).isEqualTo(1); + String text = new PDFTextStripper().getText(document); + assertThat(text).contains("Status").contains("OK").doesNotContain("?"); + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); + assertThat(containsColorNear(image, 255, 235, 59, 40)) + .as("the chip fill must paint behind the glyphs") + .isTrue(); + } + } + + @Test + void hugeCornerRadiusClampsInsteadOfThrowing() throws Exception { + byte[] pdf = render(p -> p.inlineHighlight("X", MONO, FILL, 999.0, PAD)); + try (PDDocument document = Loader.loadPDF(pdf)) { + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); + assertThat(containsColorNear(image, 255, 235, 59, 40)).isTrue(); + } + } + + @Test + void linkedChipEmitsAClickableAnnotationAndKeepsText() throws Exception { + byte[] pdf = render(p -> p.inlineText("See ").inlineHighlight( + "docs", MONO, FILL, 3.0, PAD, new DocumentLinkOptions("https://example.com"))); + try (PDDocument document = Loader.loadPDF(pdf)) { + boolean hasLink = document.getPage(0).getAnnotations().stream() + .anyMatch(PDAnnotationLink.class::isInstance); + assertThat(hasLink).as("a linked chip emits a clickable annotation").isTrue(); + assertThat(new PDFTextStripper().getText(document)).contains("docs").doesNotContain("?"); + } + } + + @Test + void inlineCodeRendersOnAChipWithoutGlyphSubstitution() throws Exception { + List spans = textSpans(p -> p.inlineText("Pkg ").inlineCode("io.github.demchaav")); + assertThat(spans.stream().filter(s -> s.background() != null).count()) + .as("inline code is exactly one chip span") + .isEqualTo(1); + byte[] pdf = render(p -> p.inlineText("Pkg ").inlineCode("io.github.demchaav")); + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(new PDFTextStripper().getText(document)) + .contains("io.github.demchaav").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(); + return session.toPdfBytes(); + } + } + + private static List textSpans(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(); + LayoutGraph graph = session.layoutGraph(); + return graph.fragments().stream() + .map(PlacedFragment::payload) + .filter(ParagraphFragmentPayload.class::isInstance) + .map(ParagraphFragmentPayload.class::cast) + .flatMap(payload -> payload.lines().stream()) + .flatMap(line -> line.spans().stream()) + .filter(ParagraphTextSpan.class::isInstance) + .map(ParagraphTextSpan.class::cast) + .toList(); + } + } + + private static boolean containsColorNear(BufferedImage image, int r, int g, int b, int tolerance) { + 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) { + return true; + } + } + } + return false; + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java new file mode 100644 index 00000000..8a82a031 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java @@ -0,0 +1,93 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.node.InlineHighlightRun; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.InlineBackground; +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import java.awt.Color; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +/** + * Model coverage for the inline highlight "chip": the {@link InlineBackground} + * value type, the {@link InlineHighlightRun}, and the {@link RichText} + * {@code highlight}/{@code code}/{@code chip} DSL sugar. + */ +class InlineHighlightRunTest { + + @Test + void backgroundRejectsNullFillAndNonFiniteOrNegativeRadius() { + assertThatThrownBy(() -> new InlineBackground(null, 2.0, DocumentInsets.zero())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new InlineBackground(DocumentColor.GRAY, -1.0, DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cornerRadius"); + assertThatThrownBy(() -> new InlineBackground(DocumentColor.GRAY, Double.NaN, DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void backgroundNormalizesNullPaddingToZero() { + InlineBackground background = new InlineBackground(DocumentColor.GRAY, 3.0, null); + assertThat(background.padding()).isEqualTo(DocumentInsets.zero()); + } + + @Test + void runRejectsNullBackgroundAndNormalizesNullText() { + assertThatThrownBy(() -> new InlineHighlightRun("x", null, null)) + .isInstanceOf(NullPointerException.class); + InlineHighlightRun run = new InlineHighlightRun(null, null, + new InlineBackground(DocumentColor.GRAY, 2.0, DocumentInsets.of(2))); + assertThat(run.text()).isEmpty(); + assertThat(run.linkTarget()).isNull(); + } + + @Test + void codeProducesAMonospaceRunWithTheDefaultChip() { + InlineHighlightRun run = onlyHighlight(RichText.text("").code("io.github.demchaav")); + assertThat(run.text()).isEqualTo("io.github.demchaav"); + assertThat(run.textStyle().fontName()).isEqualTo(FontName.COURIER); + // symmetric(1, 4) -> 4 pt each side -> 8 pt horizontal. + assertThat(run.background().padding().horizontal()).isEqualTo(8.0, within(1e-9)); + assertThat(run.background().cornerRadius()).isEqualTo(3.0); + assertThat(run.background().fill()).isNotNull(); + } + + @Test + void chipUsesTheGivenForegroundAndFill() { + InlineHighlightRun run = onlyHighlight( + RichText.text("").chip("Paid", DocumentColor.rgb(0, 100, 0), DocumentColor.rgb(220, 255, 220))); + assertThat(run.text()).isEqualTo("Paid"); + assertThat(run.textStyle().color().color()).isEqualTo(new Color(0, 100, 0)); + assertThat(run.background().fill().color()).isEqualTo(new Color(220, 255, 220)); + } + + @Test + void highlightCarriesTheExplicitChipAndOptionalLink() { + DocumentInsets padding = DocumentInsets.symmetric(2, 6); + InlineHighlightRun plain = onlyHighlight(RichText.text("").highlight( + "x", DocumentTextStyle.DEFAULT, DocumentColor.GRAY, 4.0, padding)); + assertThat(plain.background().cornerRadius()).isEqualTo(4.0); + assertThat(plain.background().padding()).isEqualTo(padding); + assertThat(plain.linkTarget()).isNull(); + + InlineHighlightRun linked = onlyHighlight(RichText.text("").highlight( + "x", DocumentTextStyle.DEFAULT, DocumentColor.GRAY, 4.0, padding, + new com.demcha.compose.document.node.DocumentLinkOptions("https://example.com"))); + assertThat(linked.linkTarget()).isNotNull(); + } + + private static InlineHighlightRun onlyHighlight(RichText rich) { + return rich.runs().stream() + .filter(InlineHighlightRun.class::isInstance) + .map(InlineHighlightRun.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("no InlineHighlightRun in " + rich.runs())); + } +} From 45f446870d59de8043ccf504cb01ef9e0b41fac4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 22 Jun 2026 18:02:42 +0100 Subject: [PATCH 2/2] fix(layout): keep the chip background when a chip starts a line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A highlight chip first-on-line whose text begins with whitespace was rebuilt by the leading-whitespace trimmer via the plain token factory, dropping the background, padding and group marker — so the chip rendered as plain text with no fill (the chip(" Paid ", ...) badge idiom hit it). Exempt chip tokens (highlightGroup != null) from trimLeadingIfInlineLineStart, trimTrailingWhitespaceTokens and the visible-content check, mirroring the long-token guard. Also hardens the new surface: the DSL highlight/chip adders reject a null background at the boundary with a param-named message; renderChip sanitizes before the fill so an unencodable chip never paints a glyph-less rect (and its band doc is corrected); the text-only degrade path collapses newlines like the PDF tokenizer. Adds regression tests for the leading-space chip, a mid-line chip, the atomic/no-wrap invariant (one span + newline collapse), and an over-wide chip. --- .../PdfParagraphFragmentRenderHandler.java | 14 ++-- .../document/dsl/ParagraphBuilder.java | 2 + .../demcha/compose/document/dsl/RichText.java | 2 + .../document/layout/TextFlowSupport.java | 13 ++++ .../compose/document/node/ParagraphNode.java | 5 +- .../dsl/InlineHighlightRenderTest.java | 65 +++++++++++++++++++ 6 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index f95805e7..97ff4db9 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -122,8 +122,8 @@ private static double resolveInlineGraphicBottom(double graphicHeight, /** * Draws a highlight "chip": a rounded fill behind the span's glyphs, then the * glyphs in their own text block offset right by the chip's left padding. The - * fill band is the text cap/descent band expanded by vertical padding, so the - * chip overflows the line box like a browser highlight without enlarging it. + * fill band is the line's text box expanded by vertical padding, so the chip + * overflows the line box like a browser highlight without enlarging it. */ private static void renderChip(PDPageContentStream stream, FontLibrary fonts, @@ -132,6 +132,11 @@ private static void renderChip(PDPageContentStream stream, double baselineY, ParagraphLine line, TextRenderState textState) throws IOException { + PdfFont font = fonts.getFont(span.textStyle().fontName(), PdfFont.class).orElseThrow(); + String text = font.sanitizeForRender(span.textStyle(), span.text()); + if (text.isEmpty()) { + return; // nothing to paint — no glyph-less fill + } InlineBackground background = span.background(); DocumentInsets pad = background.padding(); float chipWidth = (float) span.width(); // glyphs + left + right padding @@ -144,11 +149,6 @@ private static void renderChip(PDPageContentStream stream, PdfShapeFragmentRenderHandler.drawRoundedRectangle( s, (float) cursorX, chipBottom, chipWidth, chipHeight, radius, radius, radius, radius)); } - PdfFont font = fonts.getFont(span.textStyle().fontName(), PdfFont.class).orElseThrow(); - String text = font.sanitizeForRender(span.textStyle(), span.text()); - if (text.isEmpty()) { - return; - } stream.beginText(); stream.newLineAtOffset((float) (cursorX + pad.left()), (float) baselineY); textState.invalidate(); diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java index 303f3412..7af1f9a0 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java @@ -310,6 +310,7 @@ public ParagraphBuilder inlineHighlight(String text, DocumentTextStyle textStyle */ public ParagraphBuilder inlineHighlight(String text, DocumentTextStyle textStyle, DocumentColor background, double cornerRadius, DocumentInsets padding, DocumentLinkOptions link) { + Objects.requireNonNull(background, "background"); this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, textStyle, new InlineBackground(background, cornerRadius, padding), link)); this.text = ""; @@ -356,6 +357,7 @@ public ParagraphBuilder inlineCode(String text, DocumentTextStyle textStyle) { * @since 1.9.0 */ public ParagraphBuilder inlineChip(String text, DocumentColor fg, DocumentColor bg) { + Objects.requireNonNull(bg, "bg"); this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, DocumentTextStyle.builder().color(fg).build(), new InlineBackground(bg, CodeChip.BACKGROUND.cornerRadius(), CodeChip.BACKGROUND.padding()))); diff --git a/src/main/java/com/demcha/compose/document/dsl/RichText.java b/src/main/java/com/demcha/compose/document/dsl/RichText.java index 7055b23a..b25b963a 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RichText.java +++ b/src/main/java/com/demcha/compose/document/dsl/RichText.java @@ -229,6 +229,7 @@ public RichText highlight(String text, DocumentTextStyle textStyle, */ public RichText highlight(String text, DocumentTextStyle textStyle, DocumentColor background, double cornerRadius, DocumentInsets padding, DocumentLinkOptions link) { + Objects.requireNonNull(background, "background"); runs.add(new InlineHighlightRun(text == null ? "" : text, textStyle, new InlineBackground(background, cornerRadius, padding), link)); return this; @@ -273,6 +274,7 @@ public RichText code(String text, DocumentTextStyle textStyle) { * @since 1.9.0 */ public RichText chip(String text, DocumentColor fg, DocumentColor bg) { + Objects.requireNonNull(bg, "bg"); runs.add(new InlineHighlightRun(text == null ? "" : text, DocumentTextStyle.builder().color(fg).build(), new InlineBackground(bg, CodeChip.BACKGROUND.cornerRadius(), CodeChip.BACKGROUND.padding()))); 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 e83c25d1..c1cb4f43 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -1408,6 +1408,7 @@ private static List trimTrailingWhitespaceTokens(List token continue; } if (token instanceof InlineTextToken textToken) { + if (textToken.highlightGroup() != null) { + // A chip is visible content (it carries a fill) even when its + // text is blank — e.g. a colour-swatch badge. + return true; + } if (textToken.text() != null && !textToken.text().isBlank()) { return true; } diff --git a/src/main/java/com/demcha/compose/document/node/ParagraphNode.java b/src/main/java/com/demcha/compose/document/node/ParagraphNode.java index 72fcb24e..6204a094 100644 --- a/src/main/java/com/demcha/compose/document/node/ParagraphNode.java +++ b/src/main/java/com/demcha/compose/document/node/ParagraphNode.java @@ -302,7 +302,10 @@ public List inlineTextRuns() { if (run instanceof InlineTextRun textRun) { textRuns.add(textRun); } else if (run instanceof InlineHighlightRun highlight) { - textRuns.add(new InlineTextRun(highlight.text(), highlight.textStyle(), highlight.linkTarget())); + // Collapse newlines to spaces to match how the PDF tokenizer + // lowers a chip (it stays one line), so both text surfaces agree. + String chipText = highlight.text().replace("\r\n", " ").replace('\r', ' ').replace('\n', ' '); + textRuns.add(new InlineTextRun(chipText, highlight.textStyle(), highlight.linkTarget())); } } return List.copyOf(textRuns); 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 a6b00085..1c726216 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java @@ -99,6 +99,71 @@ void inlineCodeRendersOnAChipWithoutGlyphSubstitution() throws Exception { } } + @Test + void chipFirstOnLineWithLeadingSpaceKeepsItsBackground() throws Exception { + // Regression: a leading-space chip as the FIRST run on a line must not be + // rebuilt by the whitespace trimmer (which would null its background and + // render it as plain text). The `chip(" Paid ", ...)` badge idiom hits this. + List spans = textSpans(p -> p.inlineChip(" Paid ", DocumentColor.rgb(22, 101, 52), FILL)); + ParagraphTextSpan chip = spans.stream().filter(s -> s.background() != null).findFirst() + .orElseThrow(() -> new AssertionError("leading-space chip lost its background: " + spans)); + assertThat(chip.text()).contains("Paid"); + byte[] pdf = render(p -> p.inlineChip(" Paid ", DocumentColor.rgb(22, 101, 52), FILL)); + try (PDDocument document = Loader.loadPDF(pdf)) { + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); + assertThat(containsColorNear(image, 255, 235, 59, 40)) + .as("a first-on-line leading-space chip must still paint its fill") + .isTrue(); + } + } + + @Test + void chipMidLineKeepsTheTextOnBothSides() throws Exception { + // Text before AND after the chip: the chip's isolated text-block / cursorX + // advance must let the trailing plain span resume correctly. + byte[] pdf = render(p -> p.inlineText("a ").inlineHighlight("CHIP", MONO, FILL, 3.0, PAD).inlineText(" b")); + try (PDDocument document = Loader.loadPDF(pdf)) { + String text = new PDFTextStripper().getText(document).replaceAll("\\s+", " ").trim(); + assertThat(text).isEqualTo("a CHIP b"); + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); + assertThat(containsColorNear(image, 255, 235, 59, 40)).isTrue(); + } + } + + @Test + void multiWordChipStaysOneSpanAndCollapsesNewlines() throws Exception { + // PR-1 invariant: a chip is one atomic token — a multi-word chip is a + // single span (not split per word), and an embedded newline collapses to + // a space (unlike a plain text run, which would split into lines). + List badge = textSpans(p -> p.inlineChip(" On hold ", DocumentColor.rgb(146, 64, 14), FILL)); + assertThat(badge.stream().filter(s -> s.background() != null).count()) + .as("a multi-word chip is one span, not split per word") + .isEqualTo(1); + assertThat(badge.stream().filter(s -> s.background() != null).findFirst().orElseThrow().text()) + .contains("On hold"); + + List code = textSpans(p -> p.inlineCode("a\nb")); + assertThat(code.stream().filter(s -> s.background() != null).findFirst().orElseThrow().text()) + .isEqualTo("a b") + .doesNotContain("\n"); + } + + @Test + void overWideAtomicChipRendersWithoutThrowing() throws Exception { + // A chip wider than the column is emitted on its own line (PR-1 atomic); + // it must still render the text on one page without throwing. + 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(); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(document.getNumberOfPages()).isEqualTo(1); + assertThat(new PDFTextStripper().getText(document)).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();