fix: resolve CSS paints onto rendered painters and skip overridden use fills

This commit is contained in:
Marco Beretta 2026-06-24 15:57:15 +02:00
parent 6188922ff7
commit 206c34528c
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 72 additions and 106 deletions

View file

@ -208,6 +208,18 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><style>.hidden{display:none}</style><defs><path id="g" d="M6 6h12v12H6z" /></defs><path fill="#333" d="M0 0h4v4H0z" /><use href="#g" class="hidden" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('tints a referenced glyph whose own fill overrides the use fill', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><path id="p" fill="#333" d="M4 4h16v16H4z" /></defs><use href="#p" fill="#000" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('still counts a use fill that the referenced shape inherits', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><path id="p" d="M4 4h16v16H4z" /></defs><use href="#p" fill="#000" /><path fill="#ccc" d="M0 0h4v4H0z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('currentColor resolved against a fixed color', () => {
@ -308,6 +320,24 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><g fill="#f00"><path d="M4 4h16v16H4z" /></g><path fill="#333" d="M0 0h4v4H0z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('tints a glyph whose root CSS fill is overridden by every rendered shape', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>svg{fill:#f00}</style><path fill="#333" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('tints a glyph whose CSS group fill no rendered shape inherits', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>g{fill:#f00}</style><g><path fill="#333" d="M4 4h16v16H4z" /></g></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('still counts a CSS container fill inherited by an unpainted shape', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>g{fill:#f00}</style><g><path d="M4 4h16v16H4z" /></g></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('default fills alongside <style> rules', () => {

View file

@ -78,6 +78,8 @@ const PAINT_OPACITY = new Map([
/** Declarations resolved from `<style>` rules for tint detection. */
const RESOLVED_DECLS = new Set([
'fill',
'stroke',
'stop-color',
'color',
'display',
'opacity',
@ -89,10 +91,6 @@ const RESOLVED_DECLS = new Set([
/** A `<style>` rule reduced to the paint/opacity declarations we resolve. */
type StyleRule = { selector: string; declarations: Map<string, string> };
/** Matches paint declarations inside a `<style>` block, capturing property and
* value (not `color`, which only resolves `currentColor` rather than painting). */
const CSS_COLOR_REGEX = /(fill|stroke|stop-color)\s*:\s*([^;}]+)/gi;
function hexToRgb(hex: string): [number, number, number] | null {
let value = hex.slice(1);
if (value.length === 3 || value.length === 4) {
@ -290,96 +288,6 @@ function hasOpaqueBackground(root: Element, rules: StyleRule[]): boolean {
return false;
}
/**
* The tones a CSS `currentColor` paint contributes: it is resolved per rendered
* element the rule matches (against an inherited fixed `color`), so a fixed color
* is preserved rather than recorded as the theme-following sentinel. Hidden,
* functional, or fully transparent matches paint nothing and are skipped.
*/
function resolveCssCurrentColor(
root: Element,
selector: string,
rules: StyleRule[],
paintProp: string,
): string[] {
if (selector === '') {
return [];
}
let matched: Element[];
try {
matched = Array.from(root.querySelectorAll(selector));
if (root.matches(selector)) {
matched = [root, ...matched];
}
} catch {
return [CURRENT_COLOR];
}
return matched
.filter(
(el) =>
!isHidden(el, root, rules) &&
!isInside(el, root, FUNCTIONAL_CONTAINERS) &&
!paintInvisible(el, root, rules, paintProp),
)
.map((el) => resolveCurrentColor(el, root, rules));
}
function colorsFromStyleBlocks(root: Element, rules: StyleRule[]): string[] {
const colors: string[] = [];
for (const style of Array.from(root.querySelectorAll('style'))) {
for (const rule of (style.textContent ?? '').split('}')) {
const brace = rule.indexOf('{');
if (brace === -1) {
continue;
}
const selector = rule.slice(0, brace).trim();
for (const match of rule.slice(brace + 1).matchAll(CSS_COLOR_REGEX)) {
const property = match[1].toLowerCase();
const value = match[2].trim();
if (value.toLowerCase() === CURRENT_COLOR) {
colors.push(...resolveCssCurrentColor(root, selector, rules, property));
} else if (selectorPaintsRendered(root, selector, rules, property)) {
colors.push(value);
}
}
}
}
return colors;
}
/**
* True when a CSS selector matches at least one element that actually paints: it
* is rendered (not `display:none`), is not inside a functional template, and the
* paint is not made invisible by opacity. Unused or hidden rules contribute no
* tone. The root element is checked too, since `querySelectorAll` only walks
* descendants.
*/
function selectorPaintsRendered(
root: Element,
selector: string,
rules: StyleRule[],
paintProp: string,
): boolean {
if (selector === '') {
return false;
}
let matched: Element[];
try {
matched = Array.from(root.querySelectorAll(selector));
if (root.matches(selector)) {
matched = [root, ...matched];
}
} catch {
return true;
}
return matched.some(
(el) =>
!isHidden(el, root, rules) &&
!isInside(el, root, FUNCTIONAL_CONTAINERS) &&
!paintInvisible(el, root, rules, paintProp),
);
}
/**
* Resolves a `currentColor` paint to the fixed `color` set on the element or an
* ancestor up to `boundary` (inline style, attribute, or CSS), since that is what
@ -460,9 +368,11 @@ function referenceMap(root: Element, rules: StyleRule[]): Map<Element, Element[]
/**
* The paint an element renders for a property, or null when it paints none.
* Fill/stroke are resolved through inheritance but only on actual painters, so a
* value declared on a pure container (`svg`, `g`) is ignored unless a painter
* inherits it. `stop-color` only paints on a gradient `<stop>`.
* Fill/stroke are resolved through inheritance (inline, attribute, or CSS) but only
* on actual painters, so a value declared on a pure container (`svg`, `g`) is
* ignored unless a painter inherits it. A `<use>`'s paint counts only where the
* referenced content actually inherits it, not where the template overrides it.
* `stop-color` only paints on a gradient `<stop>`.
*/
function renderedPaint(
el: Element,
@ -471,10 +381,16 @@ function renderedPaint(
property: string,
): string | null {
if (property === 'stop-color') {
return el.matches('stop') ? readPaint(el, property) : null;
return el.matches('stop') ? styleValue(el, rules, property) : null;
}
const painters = property === 'stroke' ? STROKE_PAINTERS : FILL_PAINTERS;
return el.matches(painters) ? resolvePaint(el, root, rules, property) : null;
if (!el.matches(painters)) {
return null;
}
if (el.matches('use') && !instanceContributesPaint(el, root, rules, property)) {
return null;
}
return resolvePaint(el, root, rules, property);
}
function collectColors(root: Element, rules: StyleRule[]): string[] {
@ -501,7 +417,6 @@ function collectColors(root: Element, rules: StyleRule[]): string[] {
}
}
}
colors.push(...colorsFromStyleBlocks(root, rules));
return colors
.map((color) => color.trim().toLowerCase())
.filter((color) => color.length > 0 && !IGNORABLE_COLORS.has(color) && paintAlpha(color) !== 0);
@ -737,27 +652,48 @@ function referencedTarget(use: Element, root: Element): Element | null {
return null;
}
/** True when a referenced template has a fillable shape with no fill of its own. */
function targetHasDefaultBlackShape(target: Element, rules: StyleRule[]): boolean {
const shapes = Array.from(target.querySelectorAll(FILLABLE_SHAPES));
if (target.matches(FILLABLE_SHAPES)) {
/**
* True when a referenced template has a rendered painter that inherits `property`
* rather than setting its own, so a `<use>` supplying that paint is what colors it.
* For `fill` the painter must enclose an area; a stroke paints on any outline.
*/
function targetInheritsPaint(target: Element, rules: StyleRule[], property: string): boolean {
const painters = property === 'stroke' ? STROKE_PAINTERS : FILLABLE_SHAPES;
const shapes = Array.from(target.querySelectorAll(painters));
if (target.matches(painters)) {
shapes.unshift(target);
}
for (const el of shapes) {
if (
resolveFill(el, target, rules) != null ||
resolvePaint(el, target, rules, property) != null ||
isInside(el, target, FUNCTIONAL_CONTAINERS) ||
isHidden(el, target, rules)
) {
continue;
}
if (rendersFillArea(el)) {
if (property === 'stroke' || rendersFillArea(el)) {
return true;
}
}
return false;
}
/** True when a referenced template has a fillable shape with no fill of its own. */
function targetHasDefaultBlackShape(target: Element, rules: StyleRule[]): boolean {
return targetInheritsPaint(target, rules, 'fill');
}
/** True when a `<use>`'s own paint reaches a referenced shape that inherits it. */
function instanceContributesPaint(
use: Element,
root: Element,
rules: StyleRule[],
property: string,
): boolean {
const target = referencedTarget(use, root);
return target != null && targetInheritsPaint(target, rules, property);
}
/**
* True when a visible `<use>` renders a template's default black fill: the use
* supplies no fill of its own, so an unpainted shape in the referenced content