fix: preserve SVGs whose filters recolor the source

This commit is contained in:
Marco Beretta 2026-06-30 04:54:43 +02:00
parent 6f9dbd6229
commit b3fbcccdee
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 86 additions and 5 deletions

View file

@ -549,6 +549,30 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><style>.fx{filter:url(#dropShadow)}</style><defs><filter id="dropShadow"><feFlood flood-color="#e00" /></filter></defs><path class="fx" fill="#000" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('preserves a glyph recolored by a feColorMatrix filter', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><filter id="r"><feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" /></filter></defs><path filter="url(#r)" fill="#000" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('preserves a glyph recolored by a feComponentTransfer filter', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><filter id="ct"><feComponentTransfer><feFuncR type="linear" slope="0" intercept="1" /></feComponentTransfer></filter></defs><path filter="url(#ct)" fill="#000" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('tints a grayscale glyph with a color-neutral blur filter', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><filter id="b"><feGaussianBlur stdDeviation="1" /></filter></defs><path filter="url(#b)" fill="#333" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('ignores an unreferenced recoloring filter', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><filter id="r"><feColorMatrix type="hueRotate" values="90" /></filter></defs><path fill="#333" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
});
describe('opaque background (not tintable as a mask)', () => {

View file

@ -48,6 +48,34 @@ const FILTER_COLOR_DEFAULTS = new Map<string, string>([
['lighting-color', 'white'],
]);
/**
* Filter primitives whose color contribution we already model (the flood/lighting
* color sources) or that only move, blur, or combine existing pixels without
* introducing or remapping color, plus their light-source/merge children. Any
* primitive outside this set (`feColorMatrix`, `feComponentTransfer`, `feImage`,
* `feTurbulence`, ...) can recolor the source into a fixed color a mask would
* discard, so an SVG whose rendered content references such a filter is preserved
* rather than tinted. A safelist keeps unknown/new primitives on the safe side.
*/
const COLOR_SAFE_FILTER_PRIMITIVES = new Set([
'feflood',
'fedropshadow',
'fediffuselighting',
'fespecularlighting',
'fedistantlight',
'fepointlight',
'fespotlight',
'femerge',
'femergenode',
'fegaussianblur',
'feoffset',
'fetile',
'femorphology',
'fecomposite',
'feblend',
'fedisplacementmap',
]);
/**
* Shapes that render with SVG's default black fill when none is supplied. Includes
* `polyline` (SVG closes it for fill painting) but not `line`, which has no area.
@ -568,11 +596,13 @@ function collectFilterPrimitiveColors(filter: Element, rules: StyleRule[]): stri
return colors;
}
function collectFilterColors(
/** The set of `<filter>` elements referenced by rendered (visible, non-template)
* content, so only filters that actually paint are inspected. */
function referencedFilters(
root: Element,
rules: StyleRule[],
referenceUses: Map<Element, Element[][]>,
): string[] {
): Set<Element> {
const filters = new Set<Element>();
for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
if (
@ -590,11 +620,33 @@ function collectFilterColors(
}
}
}
return filters;
}
function filterColors(filters: Set<Element>, rules: StyleRule[]): string[] {
return normalizeColors(
Array.from(filters).flatMap((filter) => collectFilterPrimitiveColors(filter, rules)),
);
}
/**
* True when a referenced filter contains a primitive that can recolor its input
* beyond the flood/lighting sources we collect, since we cannot predict the tone it
* produces (e.g. `feColorMatrix` mapping a black glyph to a fixed red logo). Such an
* SVG must keep its colors rather than be flattened to a `currentColor` mask.
*/
function filtersRecolor(filters: Set<Element>): boolean {
for (const filter of filters) {
for (const el of Array.from(filter.querySelectorAll('*'))) {
const name = el.nodeName.toLowerCase();
if (name.startsWith('fe') && !COLOR_SAFE_FILTER_PRIMITIVES.has(name)) {
return true;
}
}
}
return false;
}
/**
* Approximates a selector's CSS specificity as one comparable number (ids, then
* classes/attributes/pseudo-classes, then type selectors), so the cascade can pick
@ -1004,8 +1056,9 @@ function hasDefaultBlackUse(root: Element, rules: StyleRule[]): boolean {
* raster (`<image>`) or foreign (`<foreignObject>`) content, has no opaque
* background, and resolves to a single grayscale tone (or relies on the default
* black fill / `currentColor`). Anything with a second tone (a background shape,
* an accent, or a second shade), a chromatic color, or that is unparseable
* returns false, since a CSS mask would flatten it to a solid block.
* an accent, or a second shade), a chromatic color, a filter that recolors its
* input, or that is unparseable returns false, since a CSS mask would flatten it
* to a solid block.
*/
export function isMonochromeSvg(svg: string): boolean {
const root = parseSvgRoot(svg);
@ -1020,10 +1073,14 @@ export function isMonochromeSvg(svg: string): boolean {
return false;
}
const referenceUses = referenceMap(root, rules);
const filters = referencedFilters(root, rules, referenceUses);
if (filtersRecolor(filters)) {
return false;
}
const levels = new Set<number>();
for (const color of [
...collectColors(root, rules, referenceUses),
...collectFilterColors(root, rules, referenceUses),
...filterColors(filters, rules),
]) {
if (color === CURRENT_COLOR) {
levels.add(CURRENT_COLOR_TONE);