diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts index e150f1b80a..dbfd939fdc 100644 --- a/client/src/utils/__tests__/svg.test.ts +++ b/client/src/utils/__tests__/svg.test.ts @@ -549,6 +549,30 @@ describe('isMonochromeSvg', () => { ''; expect(isMonochromeSvg(svg)).toBe(false); }); + + it('preserves a glyph recolored by a feColorMatrix filter', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('preserves a glyph recolored by a feComponentTransfer filter', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('tints a grayscale glyph with a color-neutral blur filter', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('ignores an unreferenced recoloring filter', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); }); describe('opaque background (not tintable as a mask)', () => { diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts index a84c6522e2..081749bbfa 100644 --- a/client/src/utils/svg.ts +++ b/client/src/utils/svg.ts @@ -48,6 +48,34 @@ const FILTER_COLOR_DEFAULTS = new Map([ ['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 `` elements referenced by rendered (visible, non-template) + * content, so only filters that actually paint are inspected. */ +function referencedFilters( root: Element, rules: StyleRule[], referenceUses: Map, -): string[] { +): Set { const filters = new Set(); for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) { if ( @@ -590,11 +620,33 @@ function collectFilterColors( } } } + return filters; +} + +function filterColors(filters: Set, 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): 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 (``) or foreign (``) 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(); for (const color of [ ...collectColors(root, rules, referenceUses), - ...collectFilterColors(root, rules, referenceUses), + ...filterColors(filters, rules), ]) { if (color === CURRENT_COLOR) { levels.add(CURRENT_COLOR_TONE);