From f865d14b50ff55643b68c1f2e5557b5d0444d644 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:20:54 +0200 Subject: [PATCH] fix: keep checking ancestor opacity after resolving paint opacity --- client/src/utils/__tests__/svg.test.ts | 20 ++++++++++++++++++++ client/src/utils/svg.ts | 16 +++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts index ad86bb6cb1..3740685742 100644 --- a/client/src/utils/__tests__/svg.test.ts +++ b/client/src/utils/__tests__/svg.test.ts @@ -466,6 +466,12 @@ describe('isMonochromeSvg', () => { expect(isMonochromeSvg(svg)).toBe(true); }); + it('ignores a background rect in an opacity-zero group despite its own fill-opacity', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + it('rejects a full-size percentage background rect', () => { const svg = ''; @@ -527,6 +533,20 @@ describe('isMonochromeSvg', () => { }); }); + describe('opacity-zero ancestors hide their descendants', () => { + it('ignores a colored paint inside an opacity-zero group despite its own fill-opacity', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('still counts a paint whose fill-opacity is one under a visible group', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + }); + describe('embedded raster/foreign content (not tintable)', () => { it('rejects an SVG wrapping an embedded raster image (href)', () => { const svg = ''; diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts index c70d4eaa06..72b4db2b57 100644 --- a/client/src/utils/svg.ts +++ b/client/src/utils/svg.ts @@ -513,9 +513,11 @@ function isHidden(el: Element, root: Element, rules: StyleRule[]): boolean { } /** - * True when a paint is fully transparent through opacity: the element (or an - * ancestor group) has `opacity:0`, or the inherited paint-specific opacity (e.g. - * `fill-opacity`) resolves to zero. + * True when a paint is fully transparent through opacity: the element or any + * ancestor group has `opacity:0` (group opacity is not inherited and composites + * the whole subtree), or the nearest paint-specific opacity (e.g. `fill-opacity`) + * resolves to zero. The nearest paint-specific opacity wins, but the walk keeps + * checking ancestors for `opacity:0` even after a nonzero one is found. */ function paintInvisible( el: Element, @@ -524,15 +526,19 @@ function paintInvisible( paintProp: string, ): boolean { const opacityProp = PAINT_OPACITY.get(paintProp); + let paintOpacityResolved = false; let current: Element | null = el; while (current != null) { if (styleNumber(current, rules, 'opacity') === 0) { return true; } - if (opacityProp != null) { + if (opacityProp != null && !paintOpacityResolved) { const value = styleNumber(current, rules, opacityProp); if (value != null) { - return value === 0; + if (value === 0) { + return true; + } + paintOpacityResolved = true; } } if (current === root) {