From 85260671fc579720b6923e7f91ce65cdad4d3e7f Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 17 Jun 2026 17:47:30 +0200
Subject: [PATCH] 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.
---
client/src/utils/__tests__/svg.test.ts | 38 ++++++++++++++
client/src/utils/svg.ts | 69 ++++++++++++++++++++++++--
2 files changed, 104 insertions(+), 3 deletions(-)
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;