diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts index 5cfa80418a..6d9b05f6bf 100644 --- a/client/src/utils/__tests__/svg.test.ts +++ b/client/src/utils/__tests__/svg.test.ts @@ -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 = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('rejects a black background rect with a white glyph', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('rejects a full-size percentage background rect', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('rejects a default-fill (opaque black) background rect', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('still tints a glyph drawn with small rects on a transparent background', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('ignores a transparent background rect', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + }); }); describe('sanitizeSvg', () => { diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts index 5d5fb4bd64..454c02da9b 100644 --- a/client/src/utils/svg.ts +++ b/client/src/utils/svg.ts @@ -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 = /]*>/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 `` 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;