Skip to content

Commit c543334

Browse files
committed
Added background highlight in richtext
1 parent 55ee5cc commit c543334

8 files changed

Lines changed: 421 additions & 3 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ A set of example files as created from the unit test can be found in folder [exa
4545

4646
# Rich text (markup) — module `ph-pdf-layout-richtext`
4747

48-
The optional sibling module **`ph-pdf-layout-richtext`** adds **multi-style runs inside a single paragraph** — i.e. a single text element can carry mixed bold/italic, per-segment colors, in-line hyperlinks, anchors, underlines, and sub/superscript.
48+
The optional sibling module **`ph-pdf-layout-richtext`** adds **multi-style runs inside a single paragraph** — i.e. a single text element can carry mixed bold/italic, per-segment colors, in-line hyperlinks, anchors, underlines, sub/superscript and background highlights.
4949
The base `PLText` is single-style by design; rich text fills that gap.
5050

5151
## Huge credit where it's due
@@ -72,6 +72,9 @@ Markup syntax (Markdown / CommonMark style for bold, italic and line breaks; the
7272
| `{_:0.5|0.2}foo{_}` | subscript with explicit fontScale and baselineOffset |
7373
| `{color:#rrggbb}` | switches the current colour to RGB (set, not toggle — reset with `{color:#000000}`) |
7474
| `{color_cmyk:C,M,Y,K}` | switches the current colour to CMYK (percent values 0..100, floats OK — e.g. `{color_cmyk:75,15,0,20}`). The RGB marker is unchanged. Originally requested at [ralfstuckert/pdfbox-layout#94](https://github.com/ralfstuckert/pdfbox-layout/issues/94). |
75+
| `{bg:#rrggbb}…{bg}` | fills a background rectangle behind the wrapped run (default `tight` extent — per-segment box that follows sub/superscript shifts) |
76+
| `{bg:tight:#rrggbb}…{bg}` | background with explicit `tight` extent — same as the default |
77+
| `{bg:line:#rrggbb}…{bg}` | background with `line` extent — uses the line's full slot so the highlight stays a single uniform rectangle across mixed sizes / sub-superscript, and is contiguous across wrapped lines |
7578
| `{link[uri]}…{link}` | wraps the inner text in an external hyperlink (default underline-decorated) |
7679
| `{link:none[uri]}…{link}` | hyperlink with no visual decoration |
7780
| `{link[#name]}…{link}` | internal link jumping to a named anchor declared elsewhere |
@@ -172,6 +175,13 @@ Between v4.0.0 and v5.2.2 the `artifactId` was called `ph-pdf-layout4`
172175

173176
# News and Noteworthy
174177

178+
v8.3.2 - in development
179+
* `ph-pdf-layout-richtext`: added inline **background-color markup**`{bg:#rrggbb}…{bg}` fills a rectangle behind the wrapped run. Two vertical-extent modes selected by an optional qualifier:
180+
* `{bg:#rrggbb}` / `{bg:tight:#rrggbb}`**tight** (default): per-segment box sized to the segment's own font, anchored on its (possibly sub/superscript-shifted) baseline; the highlight follows the visible glyphs.
181+
* `{bg:line:#rrggbb}`**line-height**: box sized to the line's full slot using the unshifted baseline, so the highlight stays a single uniform rectangle across sub/superscript and is contiguous across wrapped lines.
182+
* Backgrounds are painted *before* the glyphs and the existing post-text decorations (underline, hyperlink, anchor); they compose with bold / italic / colour / underline / sub-superscript inside the span.
183+
* Internally: new `PLBackgroundAnnotation` (an `IPLRichTextAnnotation`) plus `EPLBackgroundExtent` enum (`TIGHT`, `LINE_HEIGHT`); new `BackgroundFactory` in `PLMarkupCharacters`; pre-text fill pass added in `PLRichText`'s render loop.
184+
175185
v8.3.1 - 2026-05-30
176186
* **`ph-pdf-layout-richtext` markup syntax is now Markdown / CommonMark style** — breaking change vs v8.3.0:
177187
* Bold is `**bold**` (was `*bold*`).
2.52 KB
Binary file not shown.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright (C) 2026 Philip Helger (www.helger.com)
3+
* philip[at]helger[dot]com
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package com.helger.pdflayout.richtext.annotation;
18+
19+
/**
20+
* Vertical extent of the background fill rectangle behind a rich-text segment.
21+
*
22+
* @author Philip Helger
23+
*/
24+
public enum EPLBackgroundExtent
25+
{
26+
/**
27+
* Box sized to the segment's own font (its text height and descent). Sub-/superscript segments
28+
* follow their shifted baseline, so the highlight tracks the visible glyphs even when their
29+
* dimensions differ from the rest of the line. This is the HTML
30+
* <code>&lt;span style="background-color"&gt;</code> look.
31+
*/
32+
TIGHT,
33+
/**
34+
* Box sized to the full line slot of the enclosing rich-text element. All segments on a line
35+
* share identical Y bounds — adjacent segments meet without seams and the background extends
36+
* into the line-spacing gap so consecutive lines of a multi-line highlight stay visually
37+
* contiguous (Word "highlight" look).
38+
*/
39+
LINE_HEIGHT
40+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright (C) 2026 Philip Helger (www.helger.com)
3+
* philip[at]helger[dot]com
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package com.helger.pdflayout.richtext.annotation;
18+
19+
import org.jspecify.annotations.NonNull;
20+
21+
import com.helger.annotation.concurrent.Immutable;
22+
import com.helger.base.enforce.ValueEnforcer;
23+
import com.helger.base.tostring.ToStringGenerator;
24+
import com.helger.pdflayout.base.PLColor;
25+
26+
/**
27+
* Background-color annotation for rich text. Fills a rectangle behind the annotated run before the
28+
* glyphs are emitted. The vertical extent of the rectangle is selected via
29+
* {@link EPLBackgroundExtent}.
30+
*
31+
* @author Philip Helger
32+
*/
33+
@Immutable
34+
public final class PLBackgroundAnnotation implements IPLRichTextAnnotation
35+
{
36+
/** Default vertical extent: {@link EPLBackgroundExtent#TIGHT}. */
37+
public static final EPLBackgroundExtent DEFAULT_EXTENT = EPLBackgroundExtent.TIGHT;
38+
39+
private final PLColor m_aColor;
40+
private final EPLBackgroundExtent m_eExtent;
41+
42+
public PLBackgroundAnnotation (@NonNull final PLColor aColor)
43+
{
44+
this (aColor, DEFAULT_EXTENT);
45+
}
46+
47+
public PLBackgroundAnnotation (@NonNull final PLColor aColor, @NonNull final EPLBackgroundExtent eExtent)
48+
{
49+
ValueEnforcer.notNull (aColor, "Color");
50+
ValueEnforcer.notNull (eExtent, "Extent");
51+
m_aColor = aColor;
52+
m_eExtent = eExtent;
53+
}
54+
55+
@NonNull
56+
public PLColor getColor ()
57+
{
58+
return m_aColor;
59+
}
60+
61+
@NonNull
62+
public EPLBackgroundExtent getExtent ()
63+
{
64+
return m_eExtent;
65+
}
66+
67+
@Override
68+
public String toString ()
69+
{
70+
return new ToStringGenerator (this).append ("Color", m_aColor).append ("Extent", m_eExtent).getToString ();
71+
}
72+
}

ph-pdf-layout-richtext/src/main/java/com/helger/pdflayout/richtext/element/PLRichText.java

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@
4949
import com.helger.pdflayout.render.PageRenderContext;
5050
import com.helger.pdflayout.render.PreparationContext;
5151
import com.helger.pdflayout.render.PreparationContextGlobal;
52+
import com.helger.pdflayout.richtext.annotation.EPLBackgroundExtent;
5253
import com.helger.pdflayout.richtext.annotation.IPLRichTextAnnotation;
5354
import com.helger.pdflayout.richtext.annotation.PLAnchorAnnotation;
55+
import com.helger.pdflayout.richtext.annotation.PLBackgroundAnnotation;
5456
import com.helger.pdflayout.richtext.annotation.PLHyperlinkAnnotation;
5557
import com.helger.pdflayout.richtext.annotation.PLUnderlineAnnotation;
5658
import com.helger.pdflayout.richtext.run.PLFontFamily;
@@ -408,6 +410,19 @@ protected void onRender (@NonNull final PageRenderContext aCtx) throws IOExcepti
408410
final float fIndentX = getIndentX (fPreparedWidth, fLineWidth);
409411
// PDF user-space baseline of this line (Y grows upward).
410412
final float fBaselineY = fRenderTop - m_fTextHeight - m_fDescent - nLine * m_fTextHeight * m_fLineSpacing;
413+
final boolean bIsLastLine = nLine == nLineCount - 1;
414+
// Deepest descent (most-negative value) across the segments of THIS line.
415+
// We can't reuse the element-wide m_fDescent because onPrepare folds it
416+
// with Math.max starting at 0, which clamps negative descents to 0 — the
417+
// layout tolerates that for baseline positioning but a LINE_HEIGHT
418+
// highlight needs the actual depth so descenders are covered.
419+
float fLineDescent = 0f;
420+
for (final PLRichTextSegment aSeg : aLine.segments ())
421+
{
422+
final float fSegDescent = aSeg.getLoadedFont ().getDescent (aSeg.getFontSpec ().getFontSize ());
423+
if (fSegDescent < fLineDescent)
424+
fLineDescent = fSegDescent;
425+
}
411426

412427
// Compute character spacing for JUSTIFY/BLOCK. This matches how
413428
// AbstractPLText justifies — extra width is distributed across all
@@ -441,6 +456,27 @@ protected void onRender (@NonNull final PageRenderContext aCtx) throws IOExcepti
441456
final float fFontSize = aSeg.getFontSpec ().getFontSize ();
442457
final float fSegBaselineY = fBaselineY - fFontSize * aSeg.getBaselineOffsetScale ();
443458

459+
// Pre-text pass: backgrounds must paint BEFORE the glyphs so the text
460+
// stays on top. Width-zero or empty segments contribute nothing visible.
461+
if (nSegChars > 0 && fSegFullWidth > 0f)
462+
{
463+
for (final IPLRichTextAnnotation aAnn : aSeg.annotations ())
464+
{
465+
if (aAnn instanceof final PLBackgroundAnnotation aBg)
466+
{
467+
_drawBackground (aCS,
468+
aSeg,
469+
aBg,
470+
fSegStartX,
471+
fSegFullWidth,
472+
fSegBaselineY,
473+
fBaselineY,
474+
fLineDescent,
475+
bIsLastLine);
476+
}
477+
}
478+
}
479+
444480
if (nSegChars > 0)
445481
{
446482
aCS.beginText ();
@@ -451,7 +487,8 @@ protected void onRender (@NonNull final PageRenderContext aCtx) throws IOExcepti
451487
aCS.endText ();
452488
}
453489

454-
// Inline annotations: underline, hyperlink, anchor.
490+
// Post-text pass: underline, hyperlink, anchor (drawn over the glyphs
491+
// where applicable, or registered with the page).
455492
for (final IPLRichTextAnnotation aAnn : aSeg.annotations ())
456493
{
457494
if (aAnn instanceof final PLUnderlineAnnotation aUnderline)
@@ -487,6 +524,46 @@ private static int _countCharsInLine (@NonNull final PLRichTextLine aLine)
487524
return n;
488525
}
489526

527+
private void _drawBackground (@NonNull final PDPageContentStreamWithCache aCS,
528+
@NonNull final PLRichTextSegment aSeg,
529+
@NonNull final PLBackgroundAnnotation aBg,
530+
final float fStartX,
531+
final float fSegWidth,
532+
final float fSegBaselineY,
533+
final float fLineBaselineY,
534+
final float fLineDescent,
535+
final boolean bIsLastLine) throws IOException
536+
{
537+
// Note on the descent sign: PDFontDescriptor stores Descent as a NEGATIVE
538+
// value (the PDF spec convention — distance below the baseline). We follow
539+
// the same convention here: `baseline + descent` lands BELOW the baseline.
540+
final float fBottomY;
541+
final float fHeight;
542+
if (aBg.getExtent () == EPLBackgroundExtent.LINE_HEIGHT)
543+
{
544+
// Full line slot of the enclosing element. Anchor at the line's UNshifted
545+
// baseline (so sub/superscript segments don't perturb the box) and pull
546+
// the bottom down by the line's deepest descent so descenders are
547+
// covered. Height is the baseline-to-baseline distance for non-last
548+
// lines (which makes consecutive lines' boxes contiguous through the
549+
// leading gap) and the bare textHeight for the last line.
550+
fBottomY = fLineBaselineY + fLineDescent;
551+
fHeight = bIsLastLine ? m_fTextHeight : m_fTextHeight * m_fLineSpacing;
552+
}
553+
else
554+
{
555+
// Tight box around this segment's own font, anchored on the SHIFTED
556+
// baseline so sub/superscript highlights follow the visible glyphs.
557+
final float fFontSize = aSeg.getFontSpec ().getFontSize ();
558+
final float fSegDescent = aSeg.getLoadedFont ().getDescent (fFontSize);
559+
final float fSegTextHeight = aSeg.getLoadedFont ().getTextHeight (fFontSize);
560+
fBottomY = fSegBaselineY + fSegDescent;
561+
fHeight = fSegTextHeight;
562+
}
563+
aCS.setNonStrokingColor (aBg.getColor ());
564+
aCS.fillRect (fStartX, fBottomY, fSegWidth, fHeight);
565+
}
566+
490567
private static void _drawUnderline (@NonNull final PDPageContentStreamWithCache aCS,
491568
@NonNull final PLRichTextSegment aSeg,
492569
@NonNull final PLUnderlineAnnotation aUnderline,

ph-pdf-layout-richtext/src/main/java/com/helger/pdflayout/richtext/markup/PLMarkupCharacters.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
import org.jspecify.annotations.NonNull;
2323

2424
import com.helger.pdflayout.base.PLColor;
25+
import com.helger.pdflayout.richtext.annotation.EPLBackgroundExtent;
2526
import com.helger.pdflayout.richtext.annotation.EPLLinkStyle;
2627
import com.helger.pdflayout.richtext.color.PLCMYKColor;
2728
import com.helger.pdflayout.richtext.annotation.PLAnchorAnnotation;
29+
import com.helger.pdflayout.richtext.annotation.PLBackgroundAnnotation;
2830
import com.helger.pdflayout.richtext.annotation.PLHyperlinkAnnotation;
2931
import com.helger.pdflayout.richtext.annotation.PLUnderlineAnnotation;
3032

@@ -47,6 +49,11 @@
4749
* <li>{@code {^}sup{^}} — toggles superscript (font scaled, baseline shifted up)</li>
4850
* <li>{@code {_:0.5|0.2}sub{_}} — subscript with custom font / baseline scale</li>
4951
* <li>{@code {color:#rrggbb}} — switches the current color</li>
52+
* <li>{@code {bg:#rrggbb}text{bg}} — fills a background rectangle behind the text.
53+
* The extent defaults to {@link EPLBackgroundExtent#TIGHT tight} (the segment's
54+
* own font box); writing {@code {bg:line:#rrggbb}} selects
55+
* {@link EPLBackgroundExtent#LINE_HEIGHT line-height} (full line slot, contiguous
56+
* across adjacent segments and across wrapped lines).</li>
5057
* <li>{@code {link:ul[http://example.com]}} — wraps text in a hyperlink. The
5158
* style (<code>ul</code> = underline, <code>none</code> = no decoration) is
5259
* optional and defaults to underline.</li>
@@ -158,6 +165,9 @@ public final class PLMarkupCharacters
158165
/** Factory for the anchor marker {@code {anchor:name}}. */
159166
public static final IPLMarkupCharacterFactory ANCHOR = new AnchorFactory ();
160167

168+
/** Factory for the background marker {@code {bg:#rrggbb}}…{@code {bg}}. */
169+
public static final IPLMarkupCharacterFactory BACKGROUND = new BackgroundFactory ();
170+
161171
private PLMarkupCharacters ()
162172
{}
163173

@@ -670,6 +680,71 @@ public String unescape (@NonNull final String sText)
670680
}
671681
}
672682

683+
/**
684+
* Recognises the opening {@code {bg:#rrggbb}} and {@code {bg:line:#rrggbb}}
685+
* (the literal {@code line} selects {@link EPLBackgroundExtent#LINE_HEIGHT};
686+
* absent defaults to {@link EPLBackgroundExtent#TIGHT}) plus the closing bare
687+
* {@code {bg}}. Both shapes emit an {@link IPLMarkupToken.AnnotationToggle}
688+
* carrying a {@link PLBackgroundAnnotation}. Open/close pairing is by
689+
* ANNOTATION CLASS, not parameter — so two {@code {bg:#...}} with different
690+
* colours do NOT nest; the second one simply closes the first. The closing
691+
* bare {@code {bg}} carries a sentinel transparent color; the builder pops
692+
* the annotation by type so the sentinel is never actually painted.
693+
*
694+
* @author Philip Helger
695+
*/
696+
private static final class BackgroundFactory implements IPLMarkupCharacterFactory
697+
{
698+
/**
699+
* Regex: <code>(?&lt;!\\)(\\\\)*\{bg(:(tight|line))?(:#(\p{XDigit}{6}))?\}</code>
700+
* <ul>
701+
* <li><code>(?&lt;!\\)(\\\\)*</code> — escape guard.</li>
702+
* <li><code>\{bg</code> — literal <code>{bg</code>.</li>
703+
* <li><code>(:(tight|line))?</code> — OPTIONAL extent suffix. group 3
704+
* captures the extent literal. Absent defaults to
705+
* {@link EPLBackgroundExtent#TIGHT}.</li>
706+
* <li><code>(:#(\p{XDigit}{6}))?</code> — OPTIONAL colour. group 5 captures
707+
* the six hex digits. Absent → CLOSING marker {@code {bg}}.</li>
708+
* <li><code>\}</code> — literal <code>}</code>.</li>
709+
* </ul>
710+
*/
711+
private static final Pattern PATTERN = Pattern.compile ("(?<!\\\\)(\\\\\\\\)*\\{bg(:(tight|line))?(:#(\\p{XDigit}{6}))?\\}");
712+
private static final String MARKER = "{";
713+
714+
@Override
715+
@NonNull
716+
public Pattern getPattern ()
717+
{
718+
return PATTERN;
719+
}
720+
721+
@Override
722+
@NonNull
723+
public IPLMarkupToken createToken (@NonNull final String sText, @NonNull final Matcher aMatcher)
724+
{
725+
final String sExtent = aMatcher.group (3);
726+
final EPLBackgroundExtent eExtent = "line".equals (sExtent) ? EPLBackgroundExtent.LINE_HEIGHT
727+
: EPLBackgroundExtent.TIGHT;
728+
final String sHex = aMatcher.group (5);
729+
if (sHex == null)
730+
{
731+
// closing marker — emit a "neutral" toggle (matched by type)
732+
return new IPLMarkupToken.AnnotationToggle (new PLBackgroundAnnotation (PLColor.BLACK, eExtent));
733+
}
734+
final int nR = Integer.parseUnsignedInt (sHex.substring (0, 2), 16);
735+
final int nG = Integer.parseUnsignedInt (sHex.substring (2, 4), 16);
736+
final int nB = Integer.parseUnsignedInt (sHex.substring (4, 6), 16);
737+
return new IPLMarkupToken.AnnotationToggle (new PLBackgroundAnnotation (new PLColor (nR, nG, nB), eExtent));
738+
}
739+
740+
@Override
741+
@NonNull
742+
public String unescape (@NonNull final String sText)
743+
{
744+
return sText.replaceAll ("\\\\" + Pattern.quote (MARKER), MARKER);
745+
}
746+
}
747+
673748
/**
674749
* Recognises {@code {color_cmyk:C,M,Y,K}}. The four components are percent
675750
* values in {@code 0..100} (floats accepted). Like {@link ColorFactory} this

ph-pdf-layout-richtext/src/main/java/com/helger/pdflayout/richtext/markup/PLMarkupParser.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public final class PLMarkupParser
6363
* <li>COLOR (<code>{color:#xxxxxx}</code>)</li>
6464
* <li>HYPERLINK (<code>{link...}</code>)</li>
6565
* <li>ANCHOR (<code>{anchor:...}</code>)</li>
66+
* <li>BACKGROUND (<code>{bg:#xxxxxx}...{bg}</code>)</li>
6667
* </ol>
6768
*/
6869
public static final ICommonsList <IPLMarkupCharacterFactory> DEFAULT_FACTORIES = new CommonsArrayList <> (PLMarkupCharacters.HARD_BREAK,
@@ -75,7 +76,8 @@ public final class PLMarkupParser
7576
PLMarkupCharacters.ITALIC_UNDERSCORE,
7677
PLMarkupCharacters.COLOR,
7778
PLMarkupCharacters.HYPERLINK,
78-
PLMarkupCharacters.ANCHOR);
79+
PLMarkupCharacters.ANCHOR,
80+
PLMarkupCharacters.BACKGROUND);
7981

8082
private final ICommonsList <IPLMarkupCharacterFactory> m_aFactories;
8183

0 commit comments

Comments
 (0)