From fcb6ed0af91a7a16df1b03ae699a841c2c990a86 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 29 Aug 2026 14:24:39 +0100 Subject: [PATCH] fix(api): size the inline chip from the paragraph it sits in ParagraphBuilder.inlineChip(text, fg, bg) built its glyph style from scratch, so a chip rendered at the 14 pt Helvetica default whatever the paragraph said: in a 9 pt footer the words came out at 9 pt and the badge between them half again as large. It now derives the run style from the paragraph's textStyle and replaces only the colour -- which is how the rest of the builder already behaves, since inlineText, inlineLink and inlineLinkTo pass a null run style the layout resolves to the paragraph's. The style is read at the call, so textStyle(...) has to come first. inlineStyledChip(text, textStyle, bg) is the escape hatch for a chip meant to differ from its paragraph: an explicit glyph style on a custom fill, keeping the default chip radius and padding that inlineHighlight cannot reach. It carries its own name rather than overloading inlineChip on the second parameter, which would have stopped inlineChip(text, null, bg) compiling. RichText.chip(...) is unchanged -- a rich-text builder is assembled with no paragraph to read -- and now says so, pointing at highlight(...) instead. Tests: three model pins in InlineHighlightRunTest (inheritance, explicit style, the call-time ordering caveat) and two end-to-end pins in InlineHighlightRenderTest (the resolved span size and the measured width follow the paragraph; an explicit style overrides it). The inheritance pins were confirmed red against the previous behaviour. --- CHANGELOG.md | 38 +++++++++++++ .../document/dsl/ParagraphBuilder.java | 28 +++++++++- .../demcha/compose/document/dsl/RichText.java | 7 +++ .../document/dsl/InlineHighlightRunTest.java | 55 +++++++++++++++++++ .../dsl/InlineHighlightRenderTest.java | 38 +++++++++++++ 5 files changed, 163 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbb7978f..a8b474965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ follow semantic versioning; release dates are ISO 8601. ## v2.3.0 — Planned +### Public API + +- **A chip is sized like the text around it.** `ParagraphBuilder.inlineChip(text, fg, bg)` + built its glyph style from scratch — `DocumentTextStyle.builder().color(fg)` — so the + badge came out at the 14 pt Helvetica default whatever the paragraph said. In a 9 pt + footer the words rendered at 9 pt and the chip between them rendered half again as + large, which reads as a rendering fault rather than a choice. + + The colour-only overload now derives its style from the paragraph's `textStyle` and + replaces only the colour: + + ```java + paragraph.textStyle(DocumentTextStyle.builder().size(9).build()) + .inlineText("Build ") + .inlineChip(" Paid ", GREEN_INK, GREEN_FILL); // a 9 pt chip + ``` + + That is how the rest of the builder already behaves. `inlineText`, `inlineLink` and + `inlineLinkTo` pass a `null` run style, which the layout resolves to the paragraph's, + and `inlineHighlight` documents that fallback on its `textStyle` parameter. The chip + sugar was the one call that opted out of it. + + **This moves rendered output** wherever a chip sits in a paragraph that is not at the + default style: the glyphs — and with them the chip's measured width — now follow the + paragraph. The paragraph style is read at the point of the call, so `textStyle(...)` + has to come before the chip. + +- **`inlineStyledChip(text, textStyle, bg)`** styles a chip that is meant to differ from + its paragraph: an explicit glyph style on a custom fill, keeping the default chip + radius and padding, which previously meant restating both through `inlineHighlight`. + It carries its own name rather than overloading `inlineChip` on the second parameter, + which would have made a literal `inlineChip(text, null, bg)` ambiguous and stopped it + compiling. + + `RichText.chip(...)` is unchanged and still draws at the default size: a rich-text + builder is assembled without a paragraph to read. Its documentation now says so, and + points at `highlight(...)` for an explicitly sized chip. + ### Templates - **Monogram Sidebar draws the employer.** Its experience entries rendered the position, diff --git a/core/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java index 35f75add7..b9e928e9e 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java @@ -372,16 +372,38 @@ public ParagraphBuilder inlineCode(String text, DocumentTextStyle textStyle) { * Adds a coloured chip: {@code text} in {@code fg} on a {@code bg} fill, with * the default code radius and padding. * + *

The glyphs take the paragraph's {@linkplain #textStyle(DocumentTextStyle) + * text style} — family, size and decoration — with only the colour replaced by + * {@code fg}, so a chip in a 9 pt paragraph is a 9 pt chip. The paragraph style is + * read when this method is called, so set it before the chip; to size a chip + * independently of the paragraph, pass the style explicitly with + * {@link #inlineStyledChip(String, DocumentTextStyle, DocumentColor)}.

+ * * @param text the text - * @param fg the text colour + * @param fg the text colour; {@code null} leaves the glyphs black * @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) { + return inlineStyledChip(text, this.textStyle.withColor(fg), bg); + } + + /** + * Adds a chip with an explicit glyph style on a {@code bg} fill, keeping the + * default code radius and padding — the escape hatch from + * {@link #inlineChip(String, DocumentColor, DocumentColor)} for a chip that is + * meant to differ from the paragraph around it. + * + * @param text the text + * @param textStyle the glyph style; falls back to the paragraph style when {@code null} + * @param bg the chip fill colour; must not be {@code null} + * @return this builder + * @since 2.3.0 + */ + public ParagraphBuilder inlineStyledChip(String text, DocumentTextStyle textStyle, DocumentColor bg) { Objects.requireNonNull(bg, "bg"); - this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, - DocumentTextStyle.builder().color(fg).build(), + this.inlineRuns.add(new InlineHighlightRun(text == null ? "" : text, textStyle, new InlineBackground(bg, CodeChip.BACKGROUND.cornerRadius(), CodeChip.BACKGROUND.padding()))); this.text = ""; return this; diff --git a/core/src/main/java/com/demcha/compose/document/dsl/RichText.java b/core/src/main/java/com/demcha/compose/document/dsl/RichText.java index f3c70d583..4731cdd53 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/RichText.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/RichText.java @@ -282,6 +282,13 @@ public RichText code(String text, DocumentTextStyle textStyle) { * Appends a coloured chip: {@code text} in {@code fg} on a {@code bg} fill, * with the default code radius and padding. * + *

A rich-text builder is assembled without a paragraph, so — unlike + * {@link ParagraphBuilder#inlineChip(String, DocumentColor, DocumentColor)}, + * which reads the paragraph it is called on — these glyphs are drawn at the + * default family and size. In a paragraph styled to another size, reach for + * {@link #highlight(String, DocumentTextStyle, DocumentColor, double, DocumentInsets)} + * with an explicit style instead.

+ * * @param text the text * @param fg the text colour * @param bg the chip fill colour; must not be {@code null} diff --git a/core/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java b/core/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java index 8a82a0317..68f6d7905 100644 --- a/core/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java +++ b/core/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRunTest.java @@ -3,6 +3,7 @@ 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.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.style.InlineBackground; import com.demcha.compose.font.FontName; @@ -83,6 +84,60 @@ void highlightCarriesTheExplicitChipAndOptionalLink() { assertThat(linked.linkTarget()).isNotNull(); } + @Test + void paragraphChipInheritsTheParagraphStyleAndOverridesOnlyTheColour() { + DocumentTextStyle paragraphStyle = DocumentTextStyle.builder() + .fontName(FontName.TIMES_ROMAN) + .size(9) + .decoration(DocumentTextDecoration.BOLD) + .build(); + InlineHighlightRun run = onlyHighlight(new ParagraphBuilder() + .textStyle(paragraphStyle) + .inlineChip(" Paid ", DocumentColor.rgb(0, 100, 0), DocumentColor.rgb(220, 255, 220))); + + // Everything but the colour is the paragraph's, compared as a whole so a + // component added to DocumentTextStyle later is covered too. Normalized to + // one shared colour because DocumentColor compares by identity. + assertThat(run.textStyle().withColor(DocumentColor.BLACK)) + .isEqualTo(paragraphStyle.withColor(DocumentColor.BLACK)); + assertThat(run.textStyle().size()).isEqualTo(9.0, within(1e-9)); + assertThat(run.textStyle().fontName()).isEqualTo(FontName.TIMES_ROMAN); + assertThat(run.textStyle().decoration()).isEqualTo(DocumentTextDecoration.BOLD); + assertThat(run.textStyle().color().color()).isEqualTo(new Color(0, 100, 0)); + // The fill and the chip geometry are the caller's, not the paragraph's. + assertThat(run.background().fill().color()).isEqualTo(new Color(220, 255, 220)); + assertThat(run.background().cornerRadius()).isEqualTo(3.0); + } + + @Test + void paragraphChipWithAnExplicitStyleIgnoresTheParagraph() { + DocumentTextStyle chipStyle = DocumentTextStyle.builder().size(20).build(); + InlineHighlightRun run = onlyHighlight(new ParagraphBuilder() + .textStyle(DocumentTextStyle.builder().size(9).build()) + .inlineStyledChip("BIG", chipStyle, DocumentColor.GRAY)); + assertThat(run.textStyle().size()).isEqualTo(20.0, within(1e-9)); + } + + @Test + void paragraphChipReadsTheStyleThatWasSetWhenItWasCalled() { + // The documented ordering caveat, pinned: the chip snapshots the paragraph + // style at the call, so a textStyle(...) that lands afterwards does not + // reach it. Change this test deliberately if the resolution ever moves to + // build() -- do not let it drift silently. + InlineHighlightRun run = onlyHighlight(new ParagraphBuilder() + .inlineChip("x", DocumentColor.rgb(0, 100, 0), DocumentColor.GRAY) + .textStyle(DocumentTextStyle.builder().size(9).build())); + assertThat(run.textStyle().size()).isEqualTo(DocumentTextStyle.DEFAULT.size(), within(1e-9)); + } + + private static InlineHighlightRun onlyHighlight(ParagraphBuilder paragraph) { + return paragraph.build().inlineRuns().stream() + .filter(InlineHighlightRun.class::isInstance) + .map(InlineHighlightRun.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError("no InlineHighlightRun in the paragraph")); + } + private static InlineHighlightRun onlyHighlight(RichText rich) { return rich.runs().stream() .filter(InlineHighlightRun.class::isInstance) diff --git a/qa/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java b/qa/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java index f96287b68..cded4e938 100644 --- a/qa/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java +++ b/qa/src/test/java/com/demcha/compose/document/dsl/InlineHighlightRenderTest.java @@ -19,6 +19,7 @@ import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; +import java.awt.Color; import java.awt.image.BufferedImage; import java.util.List; import java.util.function.Consumer; @@ -322,6 +323,43 @@ void wrappedChipMidParagraphKeepsItsFullTextAcrossTheBreak() throws Exception { } } + @Test + void chipFollowsTheParagraphSizeInsteadOfTheDefault() throws Exception { + // A chip is glyphs on a fill, so it has to be sized like the glyphs around + // it: in a 9 pt paragraph the badge measures 9 pt, not the 14 pt default. + DocumentTextStyle small = DocumentTextStyle.builder().size(9).build(); + List spans = textSpans(p -> p + .textStyle(small) + .inlineText("Build ") + .inlineChip(" Paid ", DocumentColor.rgb(22, 101, 52), FILL)); + + ParagraphTextSpan plain = spans.stream().filter(s -> s.background() == null).findFirst().orElseThrow(); + ParagraphTextSpan chip = spans.stream().filter(s -> s.background() != null).findFirst().orElseThrow(); + assertThat(plain.textStyle().size()).isEqualTo(9.0, within(1e-9)); + assertThat(chip.textStyle().size()) + .as("the chip is drawn at the paragraph size, not the 14 pt default") + .isEqualTo(9.0, within(1e-9)); + assertThat(chip.textStyle().color()).isEqualTo(new Color(22, 101, 52)); + + // Measured, not just declared: the same chip in a default-styled paragraph + // is wider, because its glyphs really are bigger. + List defaultSized = textSpans(p -> p + .inlineChip(" Paid ", DocumentColor.rgb(22, 101, 52), FILL)); + double wide = defaultSized.stream().filter(s -> s.background() != null).findFirst().orElseThrow().width(); + assertThat(chip.width()).as("a 9 pt chip measures narrower than a 14 pt one").isLessThan(wide); + } + + @Test + void explicitlyStyledChipKeepsItsOwnSize() throws Exception { + List spans = textSpans(p -> p + .textStyle(DocumentTextStyle.builder().size(9).build()) + .inlineStyledChip("BIG", DocumentTextStyle.builder().size(18).build(), FILL)); + ParagraphTextSpan chip = spans.stream().filter(s -> s.background() != null).findFirst().orElseThrow(); + assertThat(chip.textStyle().size()) + .as("an explicit chip style overrides the paragraph, both ways") + .isEqualTo(18.0, within(1e-9)); + } + @Test void twoAdjacentDifferentChipsStaySeparateSpans() throws Exception { List spans = textSpans(p -> p