LabelStyleInstallerUtility.java
package swingtree.style;
import org.jspecify.annotations.Nullable;
import swingtree.UI;
import javax.swing.*;
import javax.swing.plaf.basic.BasicHTML;
import java.awt.*;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*
We achieve HTML-label styling and UI scaling by injecting a <head><style>...</style></head>
block into the label's HTML text.
Why text rewriting instead of something more
surgical (modifying the HTMLDocument's StyleSheet, mutating element
AttributeSets, ...)?
- HTMLDocument resolves CSS at *parse time* into each element's AttributeSet.
Stylesheet rules added afterwards do not propagate to existing elements
(verified empirically: addRule, setCharacterAttributes via either
StyleConstants.Foreground or CSS.Attribute.COLOR all leave rendering
unchanged).
- Setting only the renderer via BasicHTML.updateRenderer is also unreliable:
BasicLabelUI's PropertyChangeHandler can re-derive the renderer from
label.getText() on any "text"/"font"/"foreground" change, replacing ours
with one built from the unstyled text.
- Going through label.setText makes the styled HTML the source of truth, so
subsequent re-derivations stay styled.
So we need to modify the HTML directly:
- To apply FontConf settings (colour, family, size, decoration) we splice
a `<head><style data-swingtree="injected">...</style></head>` block in
front of the user's body. HTMLDocument resolves CSS at *parse time*
into each element's AttributeSet, so stylesheet rules added afterwards
(addRule, setCharacterAttributes via either StyleConstants.Foreground
or CSS.Attribute.COLOR) leave rendering unchanged. Setting only the
renderer via BasicHTML.updateRenderer is also unreliable: BasicLabelUI's
PropertyChangeHandler can re-derive the renderer from label.getText() on
any "text"/"font"/"foreground" change. Going through label.setText makes
the styled HTML the source of truth, so subsequent re-derivations stay
styled.
- To apply the {@link UI#scale()} factor to user-authored inline CSS we
rewrite `font-size:NNpx|pt` in place. The scale factor is also applied
to FontConf elsewhere in the pipeline, so user CSS and SwingTree CSS
grow at the same rate.
- For HTML that uses bare structural tags (`<h1>`, `<h2>`, ..., `<p>`)
without inline sizes the inline rewrite has nothing to match, and
`HTMLEditorKit`'s default stylesheet pins heading sizes to absolute
keyword values that don't track UI.scale() at all. So when the scale
differs from 1 we additionally inject a `body{font-size:Npt}` (derived
from the JLabel's font when no FontConf size was supplied) and
`h1..h6` overrides at HTML5-default multiples, so headings grow with
the rest of the UI.
Both transformations are reversible: the un-scaled, un-injected text is
stashed on a client property so each cycle can recompute from scratch.
Combined with a "last installed" snapshot, the property change listener
can tell our own setText apart from external ones — and treat the latter
as a fresh original.
To make the rewrite robust we:
- Anchor the splice on the literal 6-char "<html>" prefix that
BasicHTML.isHTMLString already validated, preserving the user's case
("<HTML>" vs "<html>" etc.).
- Wrap our injection in a unique marker so we can detect and strip our
previous block on the next cycle, leaving the user's original text
recoverable even if the client-property cache is missing (e.g. after
a copy/paste that round-trips the text through a JComponent of a
different type).
- Quote and escape user-provided values (font-family) so a malicious or
quirky string cannot break out of the CSS declaration.
Known caveats from Swing's CSS implementation (not fixable here):
- letter-spacing is not honored at all.
- User-provided <style> blocks within the original HTML still apply, and
because their source order is later than ours, they win on conflicts.
That matches the spirit of "user's HTML overrides framework defaults".
- We deliberately do not scale relative units (em, rem, %): they are
computed against another value that is itself already scaled
(the parent's font-size, which we will have rewritten if it carried
a px/pt declaration). Scaling them too would compound.
*/
final class LabelStyleInstallerUtility {
private static final String _HTML_INJECTION_OPEN = "<head><style data-swingtree=\"injected\">";
private static final String _HTML_INJECTION_CLOSE = "</style></head>";
private static final String _HTML_TEXT_LISTENER_KEY = "swingtree.style.htmlTextListenerInstalled";
private static final String _HTML_LAST_INSTALLED_KEY = "swingtree.style.htmlLastInstalled";
private static final String _HTML_ORIGINAL_KEY = "swingtree.style.htmlOriginal";
private static final int _HTML_OPEN_TAG_LEN = 6; // "<html>" — guaranteed by BasicHTML.isHTMLString
/*
Match `font-size: NN(px|pt)` in any context — inline `style="..."`
attributes, embedded `<style>` blocks, etc. Case-insensitive on the
property name and unit. Numbers may carry a fractional part.
*/
private static final Pattern _INLINE_FONT_SIZE_PATTERN = Pattern.compile(
"(font-size\\s*:\\s*)(\\d+(?:\\.\\d+)?)\\s*(px|pt)",
Pattern.CASE_INSENSITIVE
);
private LabelStyleInstallerUtility() {}
/*
When the JLabel's text is changed externally — by a sprouts.Val binding,
a manual setText elsewhere in the codebase, or any other mechanism — the
renderer is rebuilt by BasicLabelUI from the new (un-styled) text and
our injection is gone. To make the styling survive these out-of-band
changes we install (once per label) a property change listener that
triggers a fresh style cycle whenever the text changes to something
that differs from what we last installed. Comparing against the
last-installed snapshot is what lets us tell our own setText apart
from external ones — without it, every setText we do would trigger
another cycle and we would loop.
*/
static void _ensureHtmlTextListenerInstalled(JLabel label) {
if ( label.getClientProperty(_HTML_TEXT_LISTENER_KEY) != null )
return;
label.putClientProperty(_HTML_TEXT_LISTENER_KEY, Boolean.TRUE);
// Installing the text listener (once):
label.addPropertyChangeListener("text", evt -> {
Object newValue = evt.getNewValue();
if ( !(newValue instanceof String) )
return;
String newText = (String) newValue;
if ( !BasicHTML.isHTMLString(newText) )
return;
Object lastInstalled = label.getClientProperty(_HTML_LAST_INSTALLED_KEY);
if ( Objects.equals(newText, lastInstalled) )
return; // this was our own setText
/*
Force the cycle (force=true): the engine's stored StyleConf may
still match the current styler output, but the label's actual
text was just reset externally — so the conf-equality short
circuit would skip the re-injection we need.
*/
ComponentExtension.from(label).gatherApplyAndInstallStyle(true);
});
}
/**
* Combined entry point that brings an HTML JLabel's text in sync with
* both the {@link UI#scale()} factor and the supplied {@link FontConf}.
*
* <p>The label's text is treated as a function of three inputs: the
* user-provided "original" HTML, the current UI scale, and the active
* FontConf. The original is recovered from a client property each cycle,
* scaled, optionally augmented with a SwingTree-managed
* <code><head><style></code> block, and reinstalled via
* {@link JLabel#setText(String)}. The result is also cached on the label
* so the next cycle can distinguish a "we did this" text from one set
* externally.
*/
static void _applyHtmlScalingAndStyle(JLabel label, FontConf fontConf) {
String currentText = label.getText();
if ( !BasicHTML.isHTMLString(currentText) ) {
// Text became non-HTML: clear tracking state so a future HTML
// value is treated as a fresh original (and not as something
// we already installed).
if ( label.getClientProperty(_HTML_LAST_INSTALLED_KEY) != null )
label.putClientProperty(_HTML_LAST_INSTALLED_KEY, null);
if ( label.getClientProperty(_HTML_ORIGINAL_KEY) != null )
label.putClientProperty(_HTML_ORIGINAL_KEY, null);
return;
}
float scale = UI.scale();
String original = _recoverOriginalHtml(label, currentText);
String scaled = _scaleInlineFontSizes(original, scale);
String css = _buildHtmlBodyCss(fontConf)
+ _buildHtmlScalingDefaultsCss(label, scale, fontConf);
@Nullable String desired;
if ( css.isEmpty() ) {
desired = scaled;
} else {
desired = _injectStyleTag(scaled, css);
if ( desired == null )
desired = scaled;
}
label.putClientProperty(_HTML_ORIGINAL_KEY, original);
if ( Objects.equals(desired, currentText) ) {
label.putClientProperty(_HTML_LAST_INSTALLED_KEY, currentText);
} else {
// IMPORTANT: write the snapshot BEFORE setText so the listener
// installed below can recognise this as our own change.
label.putClientProperty(_HTML_LAST_INSTALLED_KEY, desired);
label.setText(desired);
}
_ensureHtmlTextListenerInstalled(label);
}
/*
Recover the un-scaled, un-injected HTML.
The label's current text is one of three things:
1. The text we installed on the previous cycle (matches lastInstalled).
In that case the stored original is authoritative.
2. Text set by external code (Var binding, setText elsewhere). The
stored original is now stale; we strip our injection (defensively,
in case it is still in there) and treat the result as a new
original.
3. The first time we see this label — neither client property is set
yet. Same as (2).
*/
private static String _recoverOriginalHtml(JLabel label, String currentText) {
Object lastInstalled = label.getClientProperty(_HTML_LAST_INSTALLED_KEY);
if ( Objects.equals(currentText, lastInstalled) ) {
Object stored = label.getClientProperty(_HTML_ORIGINAL_KEY);
if ( stored instanceof String )
return (String) stored;
}
String stripped = _stripHtmlInjection(currentText);
return stripped != null ? stripped : currentText;
}
/**
* Multiplies every {@code font-size:NN(px|pt)} value found in the given
* HTML by the supplied scale factor and returns the rewritten string.
* Returns the input unchanged when scale equals 1 or no match is found.
*/
static String _scaleInlineFontSizes(String html, float scale) {
if ( scale == 1f || html.isEmpty() )
return html;
Matcher m = _INLINE_FONT_SIZE_PATTERN.matcher(html);
StringBuffer sb = new StringBuffer(html.length() + 16);
boolean any = false;
while ( m.find() ) {
any = true;
String prefix = m.group(1);
double value = Double.parseDouble(m.group(2));
String unit = m.group(3);
double result = value * scale;
String formatted;
if ( !Double.isFinite(result) ) {
formatted = m.group(2); // pathological scale; leave value alone
} else if ( result == Math.floor(result) ) {
formatted = Long.toString((long) result);
} else {
// Two decimals is plenty for sub-pixel precision and avoids
// dragging an exponent or a 17-digit double into the CSS.
formatted = String.format(Locale.ROOT, "%.2f", result);
}
m.appendReplacement(sb, Matcher.quoteReplacement(prefix + formatted + unit));
}
if ( !any )
return html;
m.appendTail(sb);
return sb.toString();
}
static @Nullable String _stripHtmlInjection( @Nullable String html ) {
if ( html == null )
return null;
if ( !BasicHTML.isHTMLString(html) )
return html;
if ( html.length() < _HTML_OPEN_TAG_LEN )
return html;
String afterOpen = html.substring(_HTML_OPEN_TAG_LEN);
if ( !afterOpen.startsWith(_HTML_INJECTION_OPEN) )
return html;
int closeIdx = afterOpen.indexOf(_HTML_INJECTION_CLOSE, _HTML_INJECTION_OPEN.length());
if ( closeIdx < 0 )
return html;
return html.substring(0, _HTML_OPEN_TAG_LEN)
+ afterOpen.substring(closeIdx + _HTML_INJECTION_CLOSE.length());
}
static @Nullable String _injectStyleTag( @Nullable String html, String css ) {
if ( html == null )
return null;
if ( html.length() < _HTML_OPEN_TAG_LEN )
return html;
return html.substring(0, _HTML_OPEN_TAG_LEN)
+ _HTML_INJECTION_OPEN + css + _HTML_INJECTION_CLOSE
+ html.substring(_HTML_OPEN_TAG_LEN);
}
/*
Swing's HTMLEditorKit ships a default stylesheet that pins heading
font-sizes to absolute keywords (h1 = x-large, h2 = large, ...). Those
keywords resolve, inside javax.swing.text.html.CSS, to fixed pt values
that do not react to the JLabel's font, to FlatLaf's HiDPI scaling, or
to UI.scale(). The body element is rendered using the JLabel's font,
but headings are not.
When UI.scale() != 1 we therefore need to override both the body and
h1..h6 with explicit pt values so the rendered HTML actually scales.
For the body we use either the FontConf.size (already scaled by the
StyleConf pipeline) or, when no FontConf size was given, the JLabel's
current font-size. Headings are sized as multiples of the base,
matching the HTML5 default ratios.
We deliberately skip this when scale == 1: at the default scale the
kit's own defaults are the right answer and we want unstyled labels
to remain text-untouched.
*/
static String _buildHtmlScalingDefaultsCss(JLabel label, float scale, FontConf fontConf) {
if ( scale == 1f )
return "";
int baseSize;
boolean injectBody;
if ( fontConf.size() > 0 ) {
baseSize = fontConf.size(); // FontConf is already scaled upstream
injectBody = false; // _buildHtmlBodyCss already emitted body{font-size:...}
} else {
Font f = label.getFont();
baseSize = ( f != null && f.getSize() > 0 ) ? f.getSize() : 12; // We already expect SwingTree or the Look and Feel (like FlatLaF) to have the font size scaled!
injectBody = true;
}
StringBuilder sb = new StringBuilder();
if ( injectBody )
sb.append("body{font-size:").append(baseSize).append("pt;}");
sb.append("h1{font-size:").append(Math.round(baseSize * 2.00f)).append("pt;}");
sb.append("h2{font-size:").append(Math.round(baseSize * 1.50f)).append("pt;}");
sb.append("h3{font-size:").append(Math.round(baseSize * 1.17f)).append("pt;}");
sb.append("h4{font-size:").append(baseSize).append("pt;}");
sb.append("h5{font-size:").append(Math.round(baseSize * 0.83f)).append("pt;}");
sb.append("h6{font-size:").append(Math.round(baseSize * 0.67f)).append("pt;}");
return sb.toString();
}
static String _buildHtmlBodyCss(FontConf fontConf) {
StringBuilder body = new StringBuilder();
if ( !fontConf.family().isEmpty() )
body.append("font-family:\"").append(_cssEscape(fontConf.family())).append("\";");
if ( fontConf.size() > 0 )
body.append("font-size:").append(fontConf.size()).append("pt;");
fontConf.posture().ifPresent( p -> {
if ( p > 0f )
body.append("font-style:italic;");
});
fontConf.weight().ifPresent( w -> {
// FontConf weight is on AWT's TextAttribute.WEIGHT scale where 1.0
// is regular and 2.0 is bold. CSS font-weight uses 100..900 with
// 400=normal and 700=bold. Map piecewise-linearly so the canonical
// anchors land exactly, then snap to the nearest 100 since legacy
// CSS only honors multiples of 100.
int cssWeight;
if ( w <= 1f ) cssWeight = Math.round(w * 400f);
else cssWeight = Math.round(400f + (w - 1f) * 300f);
cssWeight = Math.max(100, Math.min(900, ((cssWeight + 50) / 100) * 100));
String value;
if ( cssWeight == 400 ) value = "normal";
else if ( cssWeight == 700 ) value = "bold";
else value = String.valueOf(cssWeight);
body.append("font-weight:").append(value).append(";");
});
fontConf.paint().ifPresent( paint -> {
if ( paint instanceof Color) {
Color c = (Color) paint;
body.append(String.format("color:#%02x%02x%02x;", c.getRed(), c.getGreen(), c.getBlue()));
}
});
fontConf.backgroundPaint().ifPresent( paint -> {
if ( paint instanceof Color ) {
Color c = (Color) paint;
body.append(String.format("background-color:#%02x%02x%02x;", c.getRed(), c.getGreen(), c.getBlue()));
}
});
StringBuilder decoration = new StringBuilder();
if ( fontConf.isUnderlined() )
decoration.append("underline ");
if ( fontConf.isStrikeThrough() )
decoration.append("line-through ");
if ( decoration.length() > 0 )
body.append("text-decoration:").append(decoration.toString().trim()).append(";");
if ( body.length() == 0 )
return "";
return "body{" + body + "}";
}
private static String _cssEscape(String value) {
// Strip rather than escape: Swing's CSS parser does not consistently honor
// backslash escapes inside string values, so we play it safe and drop any
// character that could let a hostile (or just quirky) value break out of
// the surrounding "..." declaration.
StringBuilder sb = new StringBuilder(value.length());
for ( int i = 0; i < value.length(); i++ ) {
char ch = value.charAt(i);
if (
ch == '"' ||
ch == ';' ||
ch == '\\' ||
ch == '<' ||
ch == '>' ||
ch == '{' ||
ch == '}' ||
ch == '\n' ||
ch == '\r'
)
sb.append(' ');
else
sb.append(ch);
}
return sb.toString();
}
}