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);