fix: do not tint custom SVGs that have an opaque background

A grayscale SVG with an opaque full-canvas background (for example an
exported logo with a white background rect and a black glyph) passed the
monochrome check and was drawn through a CSS mask. Masks key off the alpha
channel, so the opaque background filled the whole area with the tint color
and the icon collapsed into a solid block.

Detect a full-canvas opaque background rect and exclude such SVGs from
tinting, rendering them with their own colors instead. Transparent
single-color and multi-shade glyphs remain tintable. Add tests for the
background cases.
This commit is contained in:
Marco Beretta 2026-06-17 17:47:30 +02:00
parent 7c4e06a372
commit 85260671fc
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 104 additions and 3 deletions

View file

@ -81,6 +81,44 @@ describe('isMonochromeSvg', () => {
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('opaque background (not tintable as a mask)', () => {
it('rejects a white background rect with a black glyph', () => {
const svg =
'<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="#ffffff" /><path fill="#000" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('rejects a black background rect with a white glyph', () => {
const svg =
'<svg viewBox="0 0 48 48"><rect width="48" height="48" fill="black" /><path fill="white" d="M0 0h10v10H0z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('rejects a full-size percentage background rect', () => {
const svg =
'<svg viewBox="0 0 24 24"><rect x="0" y="0" width="100%" height="100%" fill="#eee" /><path fill="#222" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('rejects a default-fill (opaque black) background rect', () => {
const svg =
'<svg viewBox="0 0 16 16"><rect width="16" height="16" /><path fill="#fff" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('still tints a glyph drawn with small rects on a transparent background', () => {
const svg =
'<svg viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" fill="#333" /><rect x="14" y="14" width="6" height="6" fill="#333" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('ignores a transparent background rect', () => {
const svg =
'<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="none" /><path fill="#333" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
});
});
describe('sanitizeSvg', () => {

View file

@ -103,12 +103,75 @@ function extractColors(svg: string): string[] {
return colors;
}
const VIEWBOX_REGEX = /viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/i;
const RECT_REGEX = /<rect\b[^>]*>/gi;
function getAttr(tag: string, name: string): string | null {
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, 'i'));
return match ? match[1].trim() : null;
}
function coversCanvas(value: string | null, canvas: number | null): boolean {
if (value == null) {
return false;
}
if (value.trim() === '100%') {
return true;
}
const size = parseFloat(value);
if (Number.isNaN(size)) {
return false;
}
return canvas != null && size >= canvas * 0.98;
}
/**
* Returns true when an SVG only contains grayscale colors (or relies on the
* default black fill / `currentColor`), meaning it can be safely tinted to match
* the theme. Multi-color logos return false so their colors are preserved.
* Detects a full-canvas opaque background (a `<rect>` at the origin spanning the
* viewBox). Such SVGs cannot be tinted via a CSS mask, since the opaque
* background fills the whole area with the tint color instead of the glyph.
*/
function hasOpaqueBackground(svg: string): boolean {
const viewBox = svg.match(VIEWBOX_REGEX);
const width = viewBox ? parseFloat(viewBox[1]) : null;
const height = viewBox ? parseFloat(viewBox[2]) : null;
const rects = svg.match(RECT_REGEX) ?? [];
for (const rect of rects) {
const fill = getAttr(rect, 'fill');
if (fill === 'none' || fill === 'transparent') {
continue;
}
const fillOpacity = getAttr(rect, 'fill-opacity');
const opacity = getAttr(rect, 'opacity');
if (fillOpacity != null && parseFloat(fillOpacity) === 0) {
continue;
}
if (opacity != null && parseFloat(opacity) === 0) {
continue;
}
if (parseFloat(getAttr(rect, 'x') ?? '0') > 0 || parseFloat(getAttr(rect, 'y') ?? '0') > 0) {
continue;
}
if (
coversCanvas(getAttr(rect, 'width'), width) &&
coversCanvas(getAttr(rect, 'height'), height)
) {
return true;
}
}
return false;
}
/**
* Returns true when an SVG can be safely tinted to match the theme: it only
* contains grayscale colors (or relies on the default black fill /
* `currentColor`) and has no opaque background. Multi-color logos and icons with
* an opaque background return false so they are rendered with their own colors.
*/
export function isMonochromeSvg(svg: string): boolean {
if (hasOpaqueBackground(svg)) {
return false;
}
const colors = extractColors(svg);
if (colors.length === 0) {
return true;