fix: only tint single-tone grayscale SVGs

A grayscale SVG can draw its background as a full-canvas path (not just a
rect), e.g. a white background path plus a black glyph. The rect-only
background check missed that, and the icon flattened to a solid currentColor
block under the CSS mask.

Tint only when the SVG resolves to a single grayscale tone. Any second tone
(a background shape drawn as a path or rect, an accent, or a second shade)
now preserves the icon's own colors, which covers full-canvas path
backgrounds without per-shape geometry parsing.
This commit is contained in:
Marco Beretta 2026-06-18 09:05:33 +02:00
parent f063d44c34
commit 91d0ca44ac
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 64 additions and 32 deletions

View file

@ -17,13 +17,14 @@ describe('isMonochromeSvg', () => {
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats grayscale shades as monochrome', () => {
const svg = '<svg><path fill="#333" /><path stroke="#666666" /><rect fill="#ccc" /></svg>';
it('treats one grayscale tone written several ways as monochrome', () => {
const svg =
'<svg><path fill="#333" /><path stroke="#333333" /><path style="fill: rgb(51, 51, 51)" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('treats named grayscale colors as monochrome', () => {
const svg = '<svg><path fill="black" /><path stroke="gray" /></svg>';
it('treats a single named grayscale color as monochrome', () => {
const svg = '<svg><path fill="black" /><path stroke="black" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
@ -43,12 +44,30 @@ describe('isMonochromeSvg', () => {
expect(isMonochromeSvg(svg)).toBe(true);
});
it('handles colors defined inside a style block', () => {
const svg = '<svg><style>.a{fill:#222}.b{stroke:#888}</style><path class="a" /></svg>';
it('handles a single color defined inside a style block', () => {
const svg = '<svg><style>.a{fill:#222}.b{stroke:#222}</style><path class="a" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
});
describe('multiple grayscale tones (conservatively preserved)', () => {
it('preserves an SVG with two grayscale shades', () => {
const svg = '<svg><path fill="#333" /><path fill="#999" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('preserves a black-and-white two-tone glyph', () => {
const svg = '<svg><path fill="black" /><path fill="white" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('rejects a full-canvas path background with a glyph', () => {
const svg =
'<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="#fff" /><path fill="#000" d="M6 6h12v12H6z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('multi-color icons (colors preserved)', () => {
it('treats a saturated hex color as multi-color', () => {
const svg = '<svg><path fill="#ff0000" /></svg>';

View file

@ -11,21 +11,21 @@ import DOMPurify from 'dompurify';
/** Color keywords that carry no chromatic information and are ignored. */
const IGNORABLE_COLORS = new Set(['none', 'transparent', 'inherit', 'currentcolor']);
/** Named CSS colors that are pure grayscale. Unknown names are treated as chromatic. */
const GRAY_NAMES = new Set([
'black',
'white',
'gray',
'grey',
'silver',
'gainsboro',
'whitesmoke',
'lightgray',
'lightgrey',
'darkgray',
'darkgrey',
'dimgray',
'dimgrey',
/** Named CSS grayscale colors mapped to their 0-255 level. Unknown names are chromatic. */
const GRAY_LEVELS = new Map<string, number>([
['black', 0],
['white', 255],
['gray', 128],
['grey', 128],
['silver', 192],
['gainsboro', 220],
['whitesmoke', 245],
['lightgray', 211],
['lightgrey', 211],
['darkgray', 169],
['darkgrey', 169],
['dimgray', 105],
['dimgrey', 105],
]);
/** Paint properties whose color values determine whether an SVG is monochrome. */
@ -74,24 +74,29 @@ function functionalToValues(color: string): number[] | null {
return values.some(Number.isNaN) ? null : values;
}
function isGrayscaleColor(color: string): boolean {
/** Returns the 0-255 gray level of a grayscale color, or null when it is chromatic. */
function grayLevel(color: string): number | null {
if (color.startsWith('#')) {
const rgb = hexToRgb(color);
return rgb ? rgb[0] === rgb[1] && rgb[1] === rgb[2] : false;
return rgb && rgb[0] === rgb[1] && rgb[1] === rgb[2] ? rgb[0] : null;
}
if (color.startsWith('rgb')) {
const rgb = functionalToValues(color);
if (!rgb) {
return false;
return null;
}
const [r, g, b] = rgb.map(Math.round);
return r === g && g === b;
return r === g && g === b ? r : null;
}
if (color.startsWith('hsl')) {
const hsl = functionalToValues(color);
return hsl ? hsl[1] === 0 : false;
if (!hsl) {
return null;
}
return hsl[1] === 0 ? Math.round(hsl[2]) : null;
}
return GRAY_NAMES.has(color);
const named = GRAY_LEVELS.get(color);
return named === undefined ? null : named;
}
function parseSvgRoot(svg: string): Element | null {
@ -233,10 +238,10 @@ function collectColors(root: Element): string[] {
/**
* Returns true when an SVG can be safely tinted to match the theme: it embeds no
* raster (`<image>`) or foreign (`<foreignObject>`) content, has no opaque
* background, and every paint color is grayscale (or it relies on the default
* black fill / `currentColor`). Multi-color logos, icons with an opaque
* background, embedded rasters, and anything unparseable return false so they
* keep their own colors.
* 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.
*/
export function isMonochromeSvg(svg: string): boolean {
const root = parseSvgRoot(svg);
@ -253,7 +258,15 @@ export function isMonochromeSvg(svg: string): boolean {
if (colors.length === 0) {
return true;
}
return colors.every(isGrayscaleColor);
const levels = new Set<number>();
for (const color of colors) {
const level = grayLevel(color);
if (level === null) {
return false;
}
levels.add(level);
}
return levels.size <= 1;
}
/**