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();