diff --git a/src/paper/toSvg.ts b/src/paper/toSvg.ts index 4eeae184..334bef97 100644 --- a/src/paper/toSvg.ts +++ b/src/paper/toSvg.ts @@ -141,14 +141,59 @@ function addWatermark(svg: SVGElement, viewBox: Rect, watermarkSvg: string) { function collectAppliedCssFromDocument(targetSubtree: Element): CSSStyleRule[] { const appliedRules: CSSStyleRule[] = []; enumerateStylesheets(rule => { - const selectorWithoutPseudo = rule.selectorText.replace(/::[a-zA-Z-]+$/, ''); - if (targetSubtree.querySelector(selectorWithoutPseudo)) { + const applies = toMatchableSelectors(rule.selectorText).some(selector => { + try { + return Boolean(targetSubtree.querySelector(selector)); + } catch { + return false; + } + }); + if (applies) { appliedRules.push(rule); } }); return appliedRules; } +/** + * Turns a CSS selector list into base selectors usable with `querySelector`: + * a pseudo-element is always the last part of a selector and cannot be matched, + * so its trailing `::x` is dropped and selectors left empty (as in a + * `*, ::before, ::after` reset) are removed. + */ +export function toMatchableSelectors(selectorText: string): string[] { + return splitSelectorList(selectorText) + .map(selector => selector.replace(/::[\w-]+(\([^)]*\))?\s*$/, '').trim()) + .filter(selector => selector.length > 0); +} + +/** Splits a selector list on top-level commas, ignoring `()`, `[]` and strings. */ +function splitSelectorList(selectorText: string): string[] { + const selectors: string[] = []; + let depth = 0; + let quote: string | undefined; + let start = 0; + for (let i = 0; i < selectorText.length; i++) { + const ch = selectorText[i]; + if (quote !== undefined) { + if (ch === quote && selectorText[i - 1] !== '\\') { + quote = undefined; + } + } else if (ch === '"' || ch === '\'') { + quote = ch; + } else if (ch === '(' || ch === '[') { + depth++; + } else if (ch === ')' || ch === ']') { + depth--; + } else if (ch === ',' && depth === 0) { + selectors.push(selectorText.slice(start, i)); + start = i + 1; + } + } + selectors.push(selectorText.slice(start)); + return selectors; +} + function collectImageUrlsFromElements( target: Element, imageUrls: Map diff --git a/test/paper/toSvg.test.tsx b/test/paper/toSvg.test.tsx index a51c8d9b..ed039466 100644 --- a/test/paper/toSvg.test.tsx +++ b/test/paper/toSvg.test.tsx @@ -5,7 +5,7 @@ import { render } from 'vitest-browser-react'; import type { ColorSchemeApi } from '../../src/coreUtils/colorScheme'; import { HtmlPaperLayer, SvgPaperLayer, type PaperTransform } from '../../src/paper/paperLayers'; -import { toSVG } from '../../src/paper/toSvg'; +import { toSVG, toMatchableSelectors } from '../../src/paper/toSvg'; import IconResource from './toSvg.resource.svg'; import IconInline from './toSvg.inline.svg'; @@ -291,6 +291,68 @@ describe('toSvg()', () => { }); }); +describe('toMatchableSelectors()', () => { + it('returns plain selectors unchanged', () => { + expect(toMatchableSelectors('.card')).toEqual(['.card']); + expect(toMatchableSelectors('.a .b > .c')).toEqual(['.a .b > .c']); + }); + + it('splits a comma-separated list', () => { + expect(toMatchableSelectors('.a, .b')).toEqual(['.a', '.b']); + expect(toMatchableSelectors('.a,.b , .c')).toEqual(['.a', '.b', '.c']); + }); + + it('drops a trailing pseudo-element', () => { + expect(toMatchableSelectors('.card::before')).toEqual(['.card']); + expect(toMatchableSelectors('.a .b::after')).toEqual(['.a .b']); + expect(toMatchableSelectors('::before')).toEqual([]); + }); + + it('drops pseudo-elements per selector in a list', () => { + expect(toMatchableSelectors('.a::before, .b')).toEqual(['.a', '.b']); + expect(toMatchableSelectors('.a, .b::after')).toEqual(['.a', '.b']); + }); + + it('keeps the universal selector from a "*, ::before, ::after" reset', () => { + expect(toMatchableSelectors('*, ::before, ::after')).toEqual(['*']); + expect(toMatchableSelectors('*, ::after, ::before')).toEqual(['*']); + }); + + it('handles functional pseudo-elements', () => { + expect(toMatchableSelectors('.a::part(label)')).toEqual(['.a']); + expect(toMatchableSelectors('::slotted(.x)')).toEqual([]); + }); + + it('keeps pseudo-classes (single colon)', () => { + expect(toMatchableSelectors('.a:hover')).toEqual(['.a:hover']); + expect(toMatchableSelectors('.a:not(.b)')).toEqual(['.a:not(.b)']); + }); + + it('does not split commas inside :is()/:not()/:where()', () => { + expect(toMatchableSelectors(':is(.a, .b)')).toEqual([':is(.a, .b)']); + expect(toMatchableSelectors(':not(.a, .b)')).toEqual([':not(.a, .b)']); + expect(toMatchableSelectors(':where(.a, .b)')).toEqual([':where(.a, .b)']); + expect(toMatchableSelectors(':is(.a, :not(.b, .c))')).toEqual([':is(.a, :not(.b, .c))']); + }); + + it('does not split commas inside attribute values', () => { + expect(toMatchableSelectors('[data-x="a,b"], .c')).toEqual(['[data-x="a,b"]', '.c']); + expect(toMatchableSelectors("[data-x='a,b'], .c")).toEqual(["[data-x='a,b']", '.c']); + expect(toMatchableSelectors(':is([data-x="a,b"], .c)')).toEqual([':is([data-x="a,b"], .c)']); + }); + + it('splits only top-level commas in a mixed list', () => { + expect(toMatchableSelectors(':is(.a, .b), :not(.c, .d)')).toEqual([':is(.a, .b)', ':not(.c, .d)']); + expect(toMatchableSelectors(':is(.a, .b)::after, .c')).toEqual([':is(.a, .b)', '.c']); + expect(toMatchableSelectors('[href="a,b"]::before')).toEqual(['[href="a,b"]']); + }); + + it('returns an empty list for empty or pseudo-only input', () => { + expect(toMatchableSelectors('')).toEqual([]); + expect(toMatchableSelectors('::before, ::after')).toEqual([]); + }); +}); + const DUMMY_COLOR_SCHEME_API: ColorSchemeApi = { defined: true, actInColorScheme: (_scheme, action) => action(),