Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions src/paper/toSvg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null>
Expand Down
64 changes: 63 additions & 1 deletion test/paper/toSvg.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand Down