fix: preserve url() id casing and record every nested use chain

This commit is contained in:
Marco Beretta 2026-06-26 22:13:58 +02:00
parent a3512261e4
commit 6f9dbd6229
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
2 changed files with 60 additions and 16 deletions

View file

@ -360,6 +360,18 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><defs><symbol id="s"><use href="#glyph" /></symbol><path id="glyph" fill="currentColor" d="M4 4h16v16H4z" /></defs><use href="#s" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('preserves a glyph instantiated twice with different colors through nested use', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><path id="glyph" fill="currentColor" d="M4 4h16v16H4z" /><symbol id="s"><use href="#glyph" color="#000" /><use href="#glyph" color="#fff" /></symbol></defs><use href="#s" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('terminates on a self-referential use cycle and still finds its color', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><symbol id="a"><use href="#b" /></symbol><symbol id="b"><use href="#a" /><path fill="#f00" d="M4 4h16v16H4z" /></symbol></defs><use href="#a" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('paint set on non-rendering containers', () => {
@ -525,6 +537,18 @@ describe('isMonochromeSvg', () => {
'<svg viewBox="0 0 24 24"><defs><filter id="shadow"><feDropShadow flood-color="#f00" /></filter></defs><path filter="url(#shadow)" opacity="0" fill="#000" d="M0 0h4v4H0z" /><path fill="#333" d="M6 6h12v12H6z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(true);
});
it('resolves a mixed-case filter id referenced by attribute', () => {
const svg =
'<svg viewBox="0 0 24 24"><defs><filter id="dropShadow"><feDropShadow flood-color="#f00" /></filter></defs><path filter="url(#dropShadow)" fill="#000" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
it('resolves a mixed-case filter id referenced by a CSS rule', () => {
const svg =
'<svg viewBox="0 0 24 24"><style>.fx{filter:url(#dropShadow)}</style><defs><filter id="dropShadow"><feFlood flood-color="#e00" /></filter></defs><path class="fx" fill="#000" d="M4 4h16v16H4z" /></svg>';
expect(isMonochromeSvg(svg)).toBe(false);
});
});
describe('opaque background (not tintable as a mask)', () => {

View file

@ -391,7 +391,9 @@ function currentColorTones(
* the innermost reference outward; nested `<use>` inside a referenced template are
* followed (a symbol may reference another template) with the outer chain preserved,
* so an outer `<use color>` reaches a nested `currentColor` shape. Hidden or
* opacity-zero uses render nothing and are skipped; a `seen` set guards cycles.
* opacity-zero uses render nothing and are skipped; a per-path guard prevents
* reference cycles without dropping a target's other instantiation chains (the same
* template may be instantiated more than once with different colors).
*/
function referenceMap(root: Element, rules: StyleRule[]): Map<Element, Element[][]> {
const map = new Map<Element, Element[][]>();
@ -403,11 +405,11 @@ function referenceMap(root: Element, rules: StyleRule[]): Map<Element, Element[]
}
map.set(el, [chain]);
};
const linkTarget = (target: Element, chain: Element[], seen: Set<Element>) => {
if (seen.has(target)) {
const linkTarget = (target: Element, chain: Element[], path: Set<Element>) => {
if (path.has(target)) {
return;
}
seen.add(target);
path.add(target);
link(target, chain);
for (const el of Array.from(target.querySelectorAll('*'))) {
link(el, chain);
@ -418,9 +420,10 @@ function referenceMap(root: Element, rules: StyleRule[]): Map<Element, Element[]
}
const nestedTarget = referencedTarget(nested, root);
if (nestedTarget != null) {
linkTarget(nestedTarget, [nested, ...chain], seen);
linkTarget(nestedTarget, [nested, ...chain], path);
}
}
path.delete(target);
};
for (const use of Array.from(root.querySelectorAll('use'))) {
if (instanceInvisible(use, root, rules) || isInside(use, root, DEFERRED_CONTAINERS)) {
@ -497,10 +500,31 @@ function collectColors(
return normalizeColors(colors);
}
/** A local `url(#id)` reference. The id is case-sensitive, so it must survive the
* value lowercasing that normalizes colors and keywords for comparison. */
const LOCAL_URL_REF = /url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/gi;
/**
* Lowercases a value for case-insensitive matching while preserving the casing of
* any local `url(#id)` fragment, since element ids are matched case-sensitively.
*/
function lowerPreservingRefs(value: string): string {
if (!/url\(/i.test(value)) {
return value.toLowerCase();
}
let result = '';
let last = 0;
for (const match of value.matchAll(LOCAL_URL_REF)) {
const start = match.index ?? 0;
result += value.slice(last, start).toLowerCase() + `url(#${match[2]})`;
last = start + match[0].length;
}
return result + value.slice(last).toLowerCase();
}
function localUrlRefs(value: string): string[] {
const refs: string[] = [];
const pattern = /url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/gi;
for (const match of value.matchAll(pattern)) {
for (const match of value.matchAll(LOCAL_URL_REF)) {
refs.push(match[2]);
}
return refs;
@ -619,13 +643,7 @@ function parseStyleRules(root: Element): StyleRule[] {
}
const property = declaration.slice(0, colon).trim().toLowerCase();
if (RESOLVED_DECLS.has(property)) {
declarations.set(
property,
declaration
.slice(colon + 1)
.trim()
.toLowerCase(),
);
declarations.set(property, lowerPreservingRefs(declaration.slice(colon + 1).trim()));
}
}
if (declarations.size > 0) {
@ -671,14 +689,16 @@ function cssDecl(el: Element, rules: StyleRule[], property: string): string | nu
function styleValue(el: Element, rules: StyleRule[], property: string): string | null {
const inline = inlineStyleValue(el, property);
if (inline != null && inline.trim() !== '') {
return inline.trim().toLowerCase();
return lowerPreservingRefs(inline.trim());
}
const css = cssDecl(el, rules, property);
if (css != null && css !== '') {
return css;
}
const attribute = el.getAttribute(property);
return attribute != null && attribute.trim() !== '' ? attribute.trim().toLowerCase() : null;
return attribute != null && attribute.trim() !== ''
? lowerPreservingRefs(attribute.trim())
: null;
}
/** Parses a property to a number, or null when absent/unparseable. */