fix: detect opaque SVG backgrounds without a viewBox

Opaque background detection only read canvas dimensions from the viewBox, so
an SVG that declares width and height on the root element but omits the
viewBox slipped through and was tinted into a solid block.

Fall back to the root svg width and height when no viewBox is present, and
match attribute names exactly so stroke-width is not mistaken for the canvas
width. Add tests for the no-viewBox cases.
This commit is contained in:
Marco Beretta 2026-06-17 22:50:28 +02:00
parent 85260671fc
commit 85163e4cbb
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 38 additions and 4 deletions

View file

@ -118,6 +118,24 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="none" /><path fill="#333" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('rejects a background rect using root width/height when no viewBox is present', () => {
const svg =
'<svg width="24" height="24"><rect width="24" height="24" fill="#fff" /><path fill="#000" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('rejects a background rect when root dimensions carry units', () => {
const svg =
'<svg width="48px" height="48px"><rect width="48" height="48" fill="black" /><path fill="white" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('does not confuse stroke-width with the canvas width', () => {
const svg =
'<svg width="24" height="24" stroke-width="2"><rect width="24" height="24" fill="#eee" /><path fill="#222" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
});

View file

@ -104,13 +104,27 @@ function extractColors(svg: string): string[] {
}
const VIEWBOX_REGEX = /viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/i;
const SVG_TAG_REGEX = /<svg\b[^>]*>/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'));
const match = tag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']([^"']*)["']`, 'i'));
return match ? match[1].trim() : null;
}
function rootDimension(svg: string, name: 'width' | 'height'): number | null {
const tag = svg.match(SVG_TAG_REGEX);
if (!tag) {
return null;
}
const value = getAttr(tag[0], name);
if (value == null) {
return null;
}
const size = parseFloat(value);
return Number.isNaN(size) ? null : size;
}
function coversCanvas(value: string | null, canvas: number | null): boolean {
if (value == null) {
return false;
@ -127,13 +141,15 @@ function coversCanvas(value: string | null, canvas: number | null): boolean {
/**
* 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
* canvas). 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.
* Canvas dimensions come from the viewBox, falling back to the root `<svg>`
* width and height when no viewBox is present.
*/
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 width = viewBox ? parseFloat(viewBox[1]) : rootDimension(svg, 'width');
const height = viewBox ? parseFloat(viewBox[2]) : rootDimension(svg, 'height');
const rects = svg.match(RECT_REGEX) ?? [];
for (const rect of rects) {