fix: treat currentColor as a tone and stroked closed shapes as filled

This commit is contained in:
Marco Beretta 2026-06-22 09:54:26 +02:00
parent 6bdea5b465
commit 89ee7fc8c6
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 63 additions and 10 deletions

View file

@ -79,17 +79,41 @@ describe('isMonochromeSvg', () => {
expect(isMonochromeSvg(svg)).toBe(true);
});
it('does not add an implicit fill for an explicitly stroked path', () => {
it('does not add an implicit fill for a stroked open path (no enclosed area)', () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 12h16" stroke="#333" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('rejects a closed stroked path that still renders its default black fill', () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" stroke="#fff" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('inherits an ancestor fill instead of assuming the default black', () => {
const svg = '<svg viewBox="0 0 24 24"><g fill="#333"><path d="M4 4h16v16H4z" /></g></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
});
describe('currentColor (a visible, theme-following tone)', () => {
it('tints an icon painted entirely with currentColor', () => {
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('preserves currentColor mixed with an explicit light paint', () => {
const svg =
'<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="#fff" /><path fill="currentColor" d="M6 6h12v12H6z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('preserves currentColor mixed with a default-black glyph', () => {
const svg =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M2 2h4v4H2z" /><path d="M6 6h12v12H6z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('multi-color icons (colors preserved)', () => {
it('treats a saturated hex color as multi-color', () => {
const svg = '<svg><path fill="#ff0000" /></svg>';

View file

@ -8,8 +8,12 @@ import DOMPurify from 'dompurify';
* with regexes.
*/
/** Color keywords that carry no chromatic information and are ignored. */
const IGNORABLE_COLORS = new Set(['none', 'transparent', 'inherit', 'currentcolor']);
/** Color keywords that paint nothing (or defer) and so contribute no tone. */
const IGNORABLE_COLORS = new Set(['none', 'transparent', 'inherit']);
/** `currentColor` is a visible paint that follows the theme; it has no fixed level. */
const CURRENT_COLOR = 'currentcolor';
const CURRENT_COLOR_TONE = -1;
/** Named CSS grayscale colors mapped to their 0-255 level. Unknown names are chromatic. */
const GRAY_LEVELS = new Map<string, number>([
@ -269,24 +273,45 @@ function inNonRenderingContainer(el: Element, root: Element): boolean {
}
/**
* True when a rendered shape paints with SVG's default black fill: it has no
* explicit fill or stroke, inherits no fill from an ancestor, and is not a
* non-rendering template. Such a shape contributes a black tone that explicit
* paint values alone do not capture. Skipped when a `<style>` block is present,
* since a CSS rule may color the shape and cannot be resolved without matching.
* True when a shape encloses a fillable area, so its default fill is actually
* painted. Open paths (and paths with no `d`) enclose nothing, so their default
* fill is invisible even when the shape is stroked.
*/
function rendersFillArea(el: Element): boolean {
const tag = el.nodeName.toLowerCase();
if (tag === 'path') {
const d = el.getAttribute('d');
return d != null && /[zZ]/.test(d);
}
if (tag === 'text') {
return (el.textContent ?? '').trim().length > 0;
}
return true;
}
/**
* True when a rendered shape paints with SVG's default black fill: it sets no
* explicit fill, inherits none from an ancestor, is not a non-rendering template,
* and encloses a fillable area. A stroke does not suppress the default fill, so a
* closed stroked shape still renders black. Such a shape contributes a black tone
* that explicit paint values alone do not capture. Skipped when a `<style>` block
* is present, since a CSS rule may color the shape and cannot be resolved without
* matching.
*/
function hasDefaultBlackShape(root: Element): boolean {
if (root.querySelector('style') != null) {
return false;
}
for (const el of Array.from(root.querySelectorAll(FILLABLE_SHAPES))) {
if (readPaint(el, 'fill') != null || readPaint(el, 'stroke') != null) {
if (readPaint(el, 'fill') != null) {
continue;
}
if (inheritsExplicitFill(el) || inNonRenderingContainer(el, root)) {
continue;
}
return true;
if (rendersFillArea(el)) {
return true;
}
}
return false;
}
@ -312,6 +337,10 @@ export function isMonochromeSvg(svg: string): boolean {
}
const levels = new Set<number>();
for (const color of collectColors(root)) {
if (color === CURRENT_COLOR) {
levels.add(CURRENT_COLOR_TONE);
continue;
}
const level = grayLevel(color);
if (level === null) {
return false;