From b720032a2e7f3771dbe891324566e3ccb0813afa Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 18 Feb 2026 18:16:54 +0100 Subject: [PATCH 1/8] Add SVG compat presets and Export SVG (LaTeX) menu item --- src/lib/components/contextMenuBuilders.ts | 12 +++ src/lib/export/dom2svg/index.d.ts | 18 ++++- src/lib/export/dom2svg/index.js | 91 +++++++++++++++++------ src/lib/export/dom2svg/index.js.map | 2 +- src/lib/export/svg/renderer.ts | 3 +- src/lib/export/svg/types.ts | 6 +- 6 files changed, 106 insertions(+), 26 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 2e64428..c11db9d 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -415,6 +415,18 @@ function buildCanvasMenu( console.error('SVG export failed:', e); } } + }, + { + label: 'Export SVG (LaTeX)', + icon: 'image', + action: async () => { + try { + const svg = await exportToSVG({ compat: 'inkscape' }); + downloadSvg(svg, 'pathview-graph-latex.svg'); + } catch (e) { + console.error('SVG export (LaTeX) failed:', e); + } + } } ]; diff --git a/src/lib/export/dom2svg/index.d.ts b/src/lib/export/dom2svg/index.d.ts index 3f33283..86e5775 100644 --- a/src/lib/export/dom2svg/index.d.ts +++ b/src/lib/export/dom2svg/index.d.ts @@ -6,6 +6,18 @@ interface FontConfig { } /** Font mapping: family name → URL string, single config, or array of configs for multiple weights/styles */ type FontMapping = Record; +/** SVG compatibility configuration flags */ +interface SvgCompatConfig { + useClipPathForOverflow: boolean; + stripFilters: boolean; + stripBoxShadows: boolean; + stripMaskImage: boolean; + stripTextShadows: boolean; + avoidStyleAttributes: boolean; + stripXmlSpace: boolean; +} +/** SVG compatibility preset */ +type SvgCompat = 'full' | 'inkscape' | SvgCompatConfig; /** Options for domToSvg() */ interface DomToSvgOptions { /** Map of font-family → URL or FontConfig for text-to-path conversion */ @@ -26,6 +38,8 @@ interface DomToSvgOptions { * with nested CSS transforms (e.g. SvelteFlow, React Flow) where * the default behaviour would double-apply transforms. */ flattenTransforms?: boolean; + /** SVG compatibility preset or custom config (default: 'full') */ + compat?: SvgCompat; } /** Internal render context passed through the tree */ interface RenderContext { @@ -37,6 +51,8 @@ interface RenderContext { idGenerator: IdGenerator; /** Options from the caller */ options: DomToSvgOptions; + /** Resolved SVG compatibility config */ + compat: SvgCompatConfig; /** Font cache (available when textToPath is enabled) */ fontCache?: FontCache; /** Current inherited opacity */ @@ -72,4 +88,4 @@ interface DomToSvgResult { */ declare function domToSvg(element: Element, options?: DomToSvgOptions): Promise; -export { type DomToSvgOptions, type DomToSvgResult, type FontConfig, type FontMapping, domToSvg }; +export { type DomToSvgOptions, type DomToSvgResult, type FontConfig, type FontMapping, type SvgCompat, type SvgCompatConfig, domToSvg }; diff --git a/src/lib/export/dom2svg/index.js b/src/lib/export/dom2svg/index.js index 50a5cb1..2afbdf5 100644 --- a/src/lib/export/dom2svg/index.js +++ b/src/lib/export/dom2svg/index.js @@ -1277,17 +1277,19 @@ async function renderHtmlElement(element, rootElement, ctx) { } const hidden = isVisibilityHidden(styles); if (!hidden) { - if (styles.filter && styles.filter !== "none") { + if (!ctx.compat.stripFilters && styles.filter && styles.filter !== "none") { const filterId = createSvgFilter(styles.filter, ctx); if (filterId) { group.setAttribute("filter", `url(#${filterId})`); } } - const boxShadowValue = styles.boxShadow; - if (boxShadowValue && boxShadowValue !== "none") { - const shadows = parseBoxShadows(boxShadowValue); - if (shadows.length > 0) { - renderBoxShadows(shadows, box, radii, ctx, group); + if (!ctx.compat.stripBoxShadows) { + const boxShadowValue = styles.boxShadow; + if (boxShadowValue && boxShadowValue !== "none") { + const shadows = parseBoxShadows(boxShadowValue); + if (shadows.length > 0) { + renderBoxShadows(shadows, box, radii, ctx, group); + } } } const bgColor = parseBackgroundColor(styles); @@ -1354,16 +1356,18 @@ async function renderHtmlElement(element, rootElement, ctx) { if (styles.display === "list-item") { renderListMarker(element, styles, box, ctx, group); } - const maskImage = styles.webkitMaskImage || styles.maskImage || styles.webkitMask || styles.mask; - if (maskImage && maskImage !== "none") { - await applyMaskImage(maskImage, styles, box, ctx, group); + if (!ctx.compat.stripMaskImage) { + const maskImage = styles.webkitMaskImage || styles.maskImage || styles.webkitMask || styles.mask; + if (maskImage && maskImage !== "none") { + await applyMaskImage(maskImage, styles, box, ctx, group); + } } await renderPseudoElement(element, "::before", rootElement, ctx, group); } if (hasOverflowClip(styles) && element !== rootElement) { - const maskGroup = createOverflowMask(box, radii, ctx); - group.appendChild(maskGroup); - group.__childTarget = maskGroup; + const clipGroup = ctx.compat.useClipPathForOverflow ? createOverflowClipPath(box, radii, ctx) : createOverflowMask(box, radii, ctx); + group.appendChild(clipGroup); + group.__childTarget = clipGroup; } return group; } @@ -1471,6 +1475,17 @@ function createOverflowMask(box, radii, ctx) { masked.setAttribute("mask", `url(#${maskId})`); return masked; } +function createOverflowClipPath(box, radii, ctx) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createBoxShape(box, radii, ctx); + clipPath.appendChild(clipShape); + ctx.defs.appendChild(clipPath); + const clipped = createSvgElement(ctx.svgDocument, "g"); + clipped.setAttribute("clip-path", `url(#${clipId})`); + return clipped; +} function applyClipMask(target, box, radii, ctx, group) { const maskId = ctx.idGenerator.next("mask"); const mask = createSvgElement(ctx.svgDocument, "mask"); @@ -1508,7 +1523,9 @@ async function applyMaskImage(maskImage, styles, box, ctx, group) { const maskId = ctx.idGenerator.next("mask"); const mask = createSvgElement(ctx.svgDocument, "mask"); mask.setAttribute("id", maskId); - mask.setAttribute("style", "mask-type: alpha"); + if (!ctx.compat.avoidStyleAttributes) { + mask.setAttribute("style", "mask-type: alpha"); + } const imgEl = createSvgElement(ctx.svgDocument, "image"); setAttributes(imgEl, { x: box.x, @@ -2350,18 +2367,20 @@ async function renderTextNode(textNode, rootElement, ctx) { x: line.x.toFixed(2), y: line.y.toFixed(2) }); - applyTextStyles(textEl, styles); + applyTextStyles(textEl, styles, ctx); textEl.textContent = displayText; group.appendChild(textEl); } } if (group.childNodes.length === 0) return null; - const textShadowValue = styles.textShadow; - if (textShadowValue && textShadowValue !== "none") { - const shadows = parseTextShadows(textShadowValue); - const filterId = createTextShadowFilter(shadows, ctx); - if (filterId) { - group.setAttribute("filter", `url(#${filterId})`); + if (!ctx.compat.stripTextShadows) { + const textShadowValue = styles.textShadow; + if (textShadowValue && textShadowValue !== "none") { + const shadows = parseTextShadows(textShadowValue); + const filterId = createTextShadowFilter(shadows, ctx); + if (filterId) { + group.setAttribute("filter", `url(#${filterId})`); + } } } return group; @@ -2508,7 +2527,7 @@ function applyTextTransform(text, transform) { return text; } } -function applyTextStyles(textEl, styles) { +function applyTextStyles(textEl, styles, ctx) { setAttributes(textEl, { "font-family": styles.fontFamily, "font-size": styles.fontSize, @@ -2516,7 +2535,9 @@ function applyTextStyles(textEl, styles) { "font-style": styles.fontStyle, fill: styles.color }); - textEl.setAttribute("xml:space", "preserve"); + if (!ctx.compat.stripXmlSpace) { + textEl.setAttribute("xml:space", "preserve"); + } if (styles.letterSpacing && styles.letterSpacing !== "normal") { textEl.setAttribute("letter-spacing", styles.letterSpacing); } @@ -2632,6 +2653,31 @@ function shouldExclude(element, ctx) { return exclude(element); } +// src/compat.ts +var FULL_PRESET = { + useClipPathForOverflow: false, + stripFilters: false, + stripBoxShadows: false, + stripMaskImage: false, + stripTextShadows: false, + avoidStyleAttributes: false, + stripXmlSpace: false +}; +var INKSCAPE_PRESET = { + useClipPathForOverflow: true, + stripFilters: true, + stripBoxShadows: true, + stripMaskImage: true, + stripTextShadows: true, + avoidStyleAttributes: true, + stripXmlSpace: true +}; +function resolveCompat(compat) { + if (!compat || compat === "full") return FULL_PRESET; + if (compat === "inkscape") return INKSCAPE_PRESET; + return compat; +} + // src/index.ts async function domToSvg(element, options = {}) { const padding = options.padding ?? 0; @@ -2665,6 +2711,7 @@ async function domToSvg(element, options = {}) { defs, idGenerator: createIdGenerator(), options, + compat: resolveCompat(options.compat), opacity: 1 }; if (options.textToPath && options.fonts) { diff --git a/src/lib/export/dom2svg/index.js.map b/src/lib/export/dom2svg/index.js.map index cf0f37a..d1ffb7b 100644 --- a/src/lib/export/dom2svg/index.js.map +++ b/src/lib/export/dom2svg/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/utils/dom.ts","../src/utils/id-generator.ts","../src/core/styles.ts","../src/utils/geometry.ts","../src/assets/gradients.ts","../src/assets/images.ts","../src/transforms/parse.ts","../src/transforms/matrix.ts","../src/transforms/svg.ts","../src/assets/filters.ts","../src/assets/box-shadow.ts","../src/assets/clip-path.ts","../src/renderers/html-element.ts","../src/renderers/svg-element.ts","../src/assets/fonts.ts","../src/assets/text-shadow.ts","../src/renderers/text-node.ts","../src/core/traversal.ts","../src/index.ts"],"sourcesContent":["export const SVG_NS = \"http://www.w3.org/2000/svg\";\r\nexport const XLINK_NS = \"http://www.w3.org/1999/xlink\";\r\nexport const XMLNS_NS = \"http://www.w3.org/2000/xmlns/\";\r\n\r\n/** Check if a node is an Element */\r\nexport function isElement(node: Node): node is Element {\r\n return node.nodeType === Node.ELEMENT_NODE;\r\n}\r\n\r\n/** Check if a node is a Text node */\r\nexport function isTextNode(node: Node): node is Text {\r\n return node.nodeType === Node.TEXT_NODE;\r\n}\r\n\r\n/** Check if an element is an SVG element */\r\nexport function isSvgElement(element: Element): element is SVGElement {\r\n return element.namespaceURI === SVG_NS;\r\n}\r\n\r\n/** Check if an element is an HTMLImageElement */\r\nexport function isImageElement(element: Element): element is HTMLImageElement {\r\n return element instanceof HTMLImageElement;\r\n}\r\n\r\n/** Check if an element is an HTMLCanvasElement */\r\nexport function isCanvasElement(element: Element): element is HTMLCanvasElement {\r\n return element instanceof HTMLCanvasElement;\r\n}\r\n\r\n/** Check if an element is a form control with a text value */\r\nexport function isFormElement(\r\n element: Element,\r\n): element is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {\r\n return (\r\n element instanceof HTMLInputElement ||\r\n element instanceof HTMLTextAreaElement ||\r\n element instanceof HTMLSelectElement\r\n );\r\n}\r\n\r\n/** Create an SVG element in the SVG namespace */\r\nexport function createSvgElement(\r\n doc: Document,\r\n tagName: K,\r\n): SVGElementTagNameMap[K];\r\nexport function createSvgElement(doc: Document, tagName: string): SVGElement;\r\nexport function createSvgElement(doc: Document, tagName: string): SVGElement {\r\n return doc.createElementNS(SVG_NS, tagName);\r\n}\r\n\r\n/** Set multiple attributes on an SVG element */\r\nexport function setAttributes(\r\n element: SVGElement,\r\n attrs: Record,\r\n): void {\r\n for (const [key, value] of Object.entries(attrs)) {\r\n element.setAttribute(key, String(value));\r\n // Also set xlink:href for SVG 1.1 compatibility (e.g. Figma, re-parsed SVG)\r\n if (key === \"href\") {\r\n element.setAttributeNS(XLINK_NS, \"xlink:href\", String(value));\r\n }\r\n }\r\n}\r\n\r\n/** Get computed style for pseudo-elements */\r\nexport function getPseudoStyles(\r\n element: Element,\r\n pseudo: \"::before\" | \"::after\",\r\n): CSSStyleDeclaration {\r\n return window.getComputedStyle(element, pseudo);\r\n}\r\n","import type { IdGenerator } from \"../types.js\";\r\n\r\n/** Global counter shared across all generators to avoid ID collisions\r\n * when multiple SVGs are embedded in the same HTML document. */\r\nlet globalCounter = 0;\r\n\r\n/** Creates an ID generator that produces unique IDs with an optional prefix */\r\nexport function createIdGenerator(): IdGenerator {\r\n return {\r\n next(prefix = \"d2s\"): string {\r\n return `${prefix}-${globalCounter++}`;\r\n },\r\n };\r\n}\r\n\r\n/** Reset the global counter (for testing only) */\r\nexport function resetIdCounter(): void {\r\n globalCounter = 0;\r\n}\r\n","import type { BorderSide, Borders, BorderRadii } from \"../types.js\";\r\n\r\n/** Check if an element's entire subtree should be skipped (display:none) */\r\nexport function isInvisible(styles: CSSStyleDeclaration): boolean {\r\n return styles.display === \"none\";\r\n}\r\n\r\n/** Check if element's own visuals are hidden (children may still be visible) */\r\nexport function isVisibilityHidden(styles: CSSStyleDeclaration): boolean {\r\n return styles.visibility === \"hidden\";\r\n}\r\n\r\n/** Parse a single border side from computed styles */\r\nfunction parseBorderSide(\r\n width: string,\r\n style: string,\r\n color: string,\r\n): BorderSide {\r\n return {\r\n width: parseFloat(width) || 0,\r\n style,\r\n color,\r\n };\r\n}\r\n\r\n/** Parse all four borders from computed styles */\r\nexport function parseBorders(styles: CSSStyleDeclaration): Borders {\r\n return {\r\n top: parseBorderSide(\r\n styles.borderTopWidth,\r\n styles.borderTopStyle,\r\n styles.borderTopColor,\r\n ),\r\n right: parseBorderSide(\r\n styles.borderRightWidth,\r\n styles.borderRightStyle,\r\n styles.borderRightColor,\r\n ),\r\n bottom: parseBorderSide(\r\n styles.borderBottomWidth,\r\n styles.borderBottomStyle,\r\n styles.borderBottomColor,\r\n ),\r\n left: parseBorderSide(\r\n styles.borderLeftWidth,\r\n styles.borderLeftStyle,\r\n styles.borderLeftColor,\r\n ),\r\n };\r\n}\r\n\r\n/** Parse border-radius into [horizontal, vertical] pairs in px */\r\nexport function parseBorderRadii(styles: CSSStyleDeclaration): BorderRadii {\r\n return {\r\n topLeft: parseRadiusPair(styles.borderTopLeftRadius),\r\n topRight: parseRadiusPair(styles.borderTopRightRadius),\r\n bottomRight: parseRadiusPair(styles.borderBottomRightRadius),\r\n bottomLeft: parseRadiusPair(styles.borderBottomLeftRadius),\r\n };\r\n}\r\n\r\nfunction parseRadiusPair(value: string): [number, number] {\r\n const parts = value.split(/\\s+/).map((v) => parseFloat(v) || 0);\r\n return [parts[0] ?? 0, parts[1] ?? parts[0] ?? 0];\r\n}\r\n\r\n/** Check if any border has a visible width */\r\nexport function hasBorder(borders: Borders): boolean {\r\n return (\r\n (borders.top.width > 0 && borders.top.style !== \"none\") ||\r\n (borders.right.width > 0 && borders.right.style !== \"none\") ||\r\n (borders.bottom.width > 0 && borders.bottom.style !== \"none\") ||\r\n (borders.left.width > 0 && borders.left.style !== \"none\")\r\n );\r\n}\r\n\r\n/** Check if any border-radius is non-zero */\r\nexport function hasRadius(radii: BorderRadii): boolean {\r\n return (\r\n radii.topLeft[0] > 0 ||\r\n radii.topLeft[1] > 0 ||\r\n radii.topRight[0] > 0 ||\r\n radii.topRight[1] > 0 ||\r\n radii.bottomRight[0] > 0 ||\r\n radii.bottomRight[1] > 0 ||\r\n radii.bottomLeft[0] > 0 ||\r\n radii.bottomLeft[1] > 0\r\n );\r\n}\r\n\r\n/** Check if all four radii corners are identical (uniform) */\r\nexport function isUniformRadius(radii: BorderRadii): boolean {\r\n const [rx, ry] = radii.topLeft;\r\n return (\r\n radii.topRight[0] === rx &&\r\n radii.topRight[1] === ry &&\r\n radii.bottomRight[0] === rx &&\r\n radii.bottomRight[1] === ry &&\r\n radii.bottomLeft[0] === rx &&\r\n radii.bottomLeft[1] === ry\r\n );\r\n}\r\n\r\n/** Check if element has overflow clipping (hidden, clip, scroll, auto all clip) */\r\nexport function hasOverflowClip(styles: CSSStyleDeclaration): boolean {\r\n const clipped = new Set([\"hidden\", \"clip\", \"scroll\", \"auto\"]);\r\n return (\r\n clipped.has(styles.overflow) ||\r\n clipped.has(styles.overflowX) ||\r\n clipped.has(styles.overflowY)\r\n );\r\n}\r\n\r\n/** Parse background-color, return null if transparent */\r\nexport function parseBackgroundColor(\r\n styles: CSSStyleDeclaration,\r\n): string | null {\r\n const bg = styles.backgroundColor;\r\n if (!bg || bg === \"transparent\" || bg === \"rgba(0, 0, 0, 0)\") return null;\r\n return bg;\r\n}\r\n\r\n/** Check if there's a background-image (gradient or url) */\r\nexport function hasBackgroundImage(styles: CSSStyleDeclaration): boolean {\r\n return !!styles.backgroundImage && styles.backgroundImage !== \"none\";\r\n}\r\n\r\n/** Parse opacity value */\r\nexport function parseOpacity(styles: CSSStyleDeclaration): number {\r\n const value = parseFloat(styles.opacity);\r\n return isNaN(value) ? 1 : value;\r\n}\r\n\r\n/** Check if element creates a new stacking context */\r\nexport function createsStackingContext(styles: CSSStyleDeclaration): boolean {\r\n // Positioned with z-index != auto\r\n if (\r\n styles.position !== \"static\" &&\r\n styles.position !== \"\" &&\r\n styles.zIndex !== \"auto\"\r\n ) {\r\n return true;\r\n }\r\n // Opacity less than 1\r\n if (parseFloat(styles.opacity) < 1) return true;\r\n // CSS transforms\r\n if (styles.transform && styles.transform !== \"none\") return true;\r\n // Filter\r\n if (styles.filter && styles.filter !== \"none\") return true;\r\n // Isolation\r\n if (styles.isolation === \"isolate\") return true;\r\n // Mix blend mode\r\n if (styles.mixBlendMode && styles.mixBlendMode !== \"normal\") return true;\r\n\r\n return false;\r\n}\r\n\r\n/** Get the z-index as a number (0 for auto) */\r\nexport function getZIndex(styles: CSSStyleDeclaration): number {\r\n if (styles.zIndex === \"auto\" || !styles.zIndex) return 0;\r\n return parseInt(styles.zIndex, 10) || 0;\r\n}\r\n\r\n/** Check if element is positioned */\r\nexport function isPositioned(styles: CSSStyleDeclaration): boolean {\r\n return styles.position !== \"static\" && styles.position !== \"\";\r\n}\r\n\r\n/** Check if element is a float */\r\nexport function isFloat(styles: CSSStyleDeclaration): boolean {\r\n return styles.cssFloat !== \"none\" && styles.cssFloat !== \"\";\r\n}\r\n\r\n/**\r\n * Clamp border-radii to fit the box, following the CSS spec algorithm:\r\n * compute the ratio for each side, use the minimum to scale all radii.\r\n */\r\nexport function clampRadii(radii: BorderRadii, width: number, height: number): BorderRadii {\r\n // Horizontal sums (top and bottom edges)\r\n const topH = radii.topLeft[0] + radii.topRight[0];\r\n const bottomH = radii.bottomLeft[0] + radii.bottomRight[0];\r\n // Vertical sums (left and right edges)\r\n const leftV = radii.topLeft[1] + radii.bottomLeft[1];\r\n const rightV = radii.topRight[1] + radii.bottomRight[1];\r\n\r\n let f = 1;\r\n if (topH > 0) f = Math.min(f, width / topH);\r\n if (bottomH > 0) f = Math.min(f, width / bottomH);\r\n if (leftV > 0) f = Math.min(f, height / leftV);\r\n if (rightV > 0) f = Math.min(f, height / rightV);\r\n\r\n if (f >= 1) return radii;\r\n\r\n return {\r\n topLeft: [radii.topLeft[0] * f, radii.topLeft[1] * f],\r\n topRight: [radii.topRight[0] * f, radii.topRight[1] * f],\r\n bottomRight: [radii.bottomRight[0] * f, radii.bottomRight[1] * f],\r\n bottomLeft: [radii.bottomLeft[0] * f, radii.bottomLeft[1] * f],\r\n };\r\n}\r\n\r\n/** Check if element is inline-level */\r\nexport function isInlineLevel(styles: CSSStyleDeclaration): boolean {\r\n const d = styles.display;\r\n return (\r\n d === \"inline\" ||\r\n d === \"inline-block\" ||\r\n d === \"inline-flex\" ||\r\n d === \"inline-grid\" ||\r\n d === \"inline-table\"\r\n );\r\n}\r\n","import type { BoxGeometry, BorderRadii } from \"../types.js\";\r\n\r\n/** Get an element's bounding box relative to a root element */\r\nexport function getRelativeBox(element: Element, root: Element): BoxGeometry {\r\n const elRect = element.getBoundingClientRect();\r\n const rootRect = root.getBoundingClientRect();\r\n return {\r\n x: elRect.left - rootRect.left,\r\n y: elRect.top - rootRect.top,\r\n width: elRect.width,\r\n height: elRect.height,\r\n };\r\n}\r\n\r\n/** Build an SVG path d-attribute for a rounded rectangle with non-uniform radii */\r\nexport function buildRoundedRectPath(\r\n x: number, y: number, width: number, height: number,\r\n radii: BorderRadii,\r\n): string {\r\n const [tlx, tly] = radii.topLeft;\r\n const [trx, try_] = radii.topRight;\r\n const [brx, bry] = radii.bottomRight;\r\n const [blx, bly] = radii.bottomLeft;\r\n\r\n return [\r\n `M ${x + tlx} ${y}`,\r\n `L ${x + width - trx} ${y}`,\r\n trx || try_ ? `A ${trx} ${try_} 0 0 1 ${x + width} ${y + try_}` : \"\",\r\n `L ${x + width} ${y + height - bry}`,\r\n brx || bry ? `A ${brx} ${bry} 0 0 1 ${x + width - brx} ${y + height}` : \"\",\r\n `L ${x + blx} ${y + height}`,\r\n blx || bly ? `A ${blx} ${bly} 0 0 1 ${x} ${y + height - bly}` : \"\",\r\n `L ${x} ${y + tly}`,\r\n tlx || tly ? `A ${tlx} ${tly} 0 0 1 ${x + tlx} ${y}` : \"\",\r\n \"Z\",\r\n ].filter(Boolean).join(\" \");\r\n}\r\n","import type { LinearGradient, GradientStop, RenderContext, BoxGeometry } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\n\r\n/** Parse a CSS linear-gradient() into our LinearGradient structure */\r\nexport function parseLinearGradient(value: string): LinearGradient | null {\r\n // Match linear-gradient(...) - handle both prefix and standard\r\n const match = value.match(/linear-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n if (parts.length < 2) return null;\r\n\r\n let angle = 180; // default: to bottom\r\n let stopsStart = 0;\r\n\r\n // Check if first part is a direction\r\n const first = parts[0]!.trim();\r\n if (first.startsWith(\"to \")) {\r\n angle = directionToAngle(first);\r\n stopsStart = 1;\r\n } else if (first.match(/^-?[\\d.]+(?:deg|rad|turn|grad)/)) {\r\n angle = parseAngle(first);\r\n stopsStart = 1;\r\n }\r\n\r\n const stops: GradientStop[] = [];\r\n const rawStops = parts.slice(stopsStart);\r\n\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const { color, position } = parseColorStop(rawStops[i]!.trim(), i, rawStops.length);\r\n stops.push({ color, position });\r\n }\r\n\r\n return { angle, stops };\r\n}\r\n\r\n/** Convert a linear-gradient to an SVG element */\r\nexport function createSvgLinearGradient(\r\n gradient: LinearGradient,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): SVGLinearGradientElement {\r\n const id = ctx.idGenerator.next(\"grad\");\r\n const el = createSvgElement(\r\n ctx.svgDocument,\r\n \"linearGradient\",\r\n ) as SVGLinearGradientElement;\r\n\r\n // Use userSpaceOnUse with pixel coordinates for correct diagonal angles\r\n // on non-square elements (objectBoundingBox distorts the angle).\r\n const cx = box.x + box.width / 2;\r\n const cy = box.y + box.height / 2;\r\n const angleRad = (gradient.angle * Math.PI) / 180;\r\n // CSS angle: 0deg = to top (↑), 90deg = to right (→)\r\n const dx = Math.sin(angleRad);\r\n const dy = -Math.cos(angleRad);\r\n // Gradient line half-length per CSS spec: extends to the perpendicular\r\n // from the farthest corner.\r\n const halfLen = Math.abs(box.width / 2 * dx) + Math.abs(box.height / 2 * dy);\r\n const x1 = cx - dx * halfLen;\r\n const y1 = cy - dy * halfLen;\r\n const x2 = cx + dx * halfLen;\r\n const y2 = cy + dy * halfLen;\r\n\r\n setAttributes(el, {\r\n id,\r\n gradientUnits: \"userSpaceOnUse\",\r\n x1: x1.toFixed(2),\r\n y1: y1.toFixed(2),\r\n x2: x2.toFixed(2),\r\n y2: y2.toFixed(2),\r\n });\r\n\r\n for (const stop of gradient.stops) {\r\n const stopEl = createSvgElement(ctx.svgDocument, \"stop\");\r\n setAttributes(stopEl, {\r\n offset: `${(stop.position * 100).toFixed(1)}%`,\r\n \"stop-color\": stop.color,\r\n });\r\n el.appendChild(stopEl);\r\n }\r\n\r\n ctx.defs.appendChild(el);\r\n return el;\r\n}\r\n\r\n/**\r\n * Rasterize a conic-gradient (or radial-gradient) to a data URL\r\n * using the Canvas 2D API. Returns null if the gradient type is\r\n * not supported or the Canvas API is unavailable.\r\n */\r\nexport function rasterizeGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n if (value.includes(\"conic-gradient\")) {\r\n return rasterizeConicGradient(value, width, height);\r\n }\r\n if (value.includes(\"radial-gradient\")) {\r\n return rasterizeRadialGradient(value, width, height);\r\n }\r\n return null;\r\n}\r\n\r\nfunction rasterizeConicGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n const match = value.match(/conic-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const scale = 2;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = Math.ceil(width * scale);\r\n canvas.height = Math.ceil(height * scale);\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx || !(\"createConicGradient\" in ctx)) return null;\r\n\r\n ctx.scale(scale, scale);\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n\r\n let startDeg = 0;\r\n let stopsStart = 0;\r\n\r\n // Parse \"from \" prefix\r\n const first = parts[0]!.trim();\r\n const fromMatch = first.match(/^from\\s+(-?[\\d.]+)(deg|rad|turn|grad)/);\r\n if (fromMatch) {\r\n startDeg = parseAngle(fromMatch[1]! + fromMatch[2]!);\r\n stopsStart = 1;\r\n }\r\n\r\n const cx = width / 2;\r\n const cy = height / 2;\r\n\r\n // CSS 0deg = top (12 o'clock), Canvas 0rad = right (3 o'clock)\r\n const startRad = ((startDeg - 90) * Math.PI) / 180;\r\n const gradient = ctx.createConicGradient(startRad, cx, cy);\r\n\r\n const rawStops = parts.slice(stopsStart);\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const stop = rawStops[i]!.trim();\r\n const { color, position } = parseColorStop(stop, i, rawStops.length);\r\n try {\r\n gradient.addColorStop(position, color);\r\n } catch {\r\n // Invalid color — skip\r\n }\r\n }\r\n\r\n ctx.fillStyle = gradient;\r\n ctx.fillRect(0, 0, width, height);\r\n return canvas.toDataURL(\"image/png\");\r\n}\r\n\r\nfunction rasterizeRadialGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n const match = value.match(/radial-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const scale = 2;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = Math.ceil(width * scale);\r\n canvas.height = Math.ceil(height * scale);\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx) return null;\r\n\r\n ctx.scale(scale, scale);\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n\r\n let isCircle = false;\r\n let stopsStart = 0;\r\n let customCx: number | null = null;\r\n let customCy: number | null = null;\r\n\r\n // Check if the first part is a shape/size descriptor\r\n const first = parts[0]!.trim();\r\n if (first === \"circle\" || first.startsWith(\"circle \")) {\r\n isCircle = true;\r\n stopsStart = 1;\r\n } else if (first === \"ellipse\" || first.startsWith(\"ellipse \")) {\r\n stopsStart = 1;\r\n } else if (first.includes(\"at \") && !first.includes(\"#\") && !first.match(/^(rgb|hsl)/)) {\r\n stopsStart = 1;\r\n }\r\n\r\n // Parse \"at cx cy\" position from shape descriptor\r\n if (stopsStart === 1) {\r\n const atMatch = first.match(/at\\s+(.+)/);\r\n if (atMatch) {\r\n const posParts = atMatch[1]!.trim().split(/\\s+/);\r\n customCx = parseLengthOrPercent(posParts[0]!, width);\r\n customCy = parseLengthOrPercent(posParts[1] ?? posParts[0]!, height);\r\n }\r\n }\r\n\r\n const cx = customCx ?? width / 2;\r\n const cy = customCy ?? height / 2;\r\n\r\n // Use transform to create an elliptical gradient\r\n const rx = width / 2;\r\n const ry = height / 2;\r\n // CSS default: farthest-corner. For a circle, that's the distance to the corner.\r\n const radius = isCircle ? Math.sqrt(rx * rx + ry * ry) : Math.max(rx, ry);\r\n\r\n ctx.save();\r\n if (!isCircle && rx !== ry) {\r\n ctx.translate(cx, cy);\r\n ctx.scale(rx / radius, ry / radius);\r\n ctx.translate(-cx, -cy);\r\n }\r\n\r\n const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);\r\n\r\n const rawStops = parts.slice(stopsStart);\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const stop = rawStops[i]!.trim();\r\n const { color, position } = parseColorStop(stop, i, rawStops.length);\r\n try {\r\n gradient.addColorStop(position, color);\r\n } catch {\r\n // Invalid color — skip\r\n }\r\n }\r\n\r\n ctx.fillStyle = gradient;\r\n // When the elliptical transform compresses one axis, the fillRect must\r\n // be expanded in the transformed space to cover the full canvas.\r\n if (!isCircle && rx !== ry) {\r\n const sx = radius / rx;\r\n const sy = radius / ry;\r\n ctx.fillRect(cx * (1 - sx), cy * (1 - sy), width * sx, height * sy);\r\n } else {\r\n ctx.fillRect(0, 0, width, height);\r\n }\r\n ctx.restore();\r\n\r\n return canvas.toDataURL(\"image/png\");\r\n}\r\n\r\n/**\r\n * Parse a color stop like \"red 50%\" into color and position.\r\n * Handles modern CSS color syntax with spaces (e.g. \"hsl(120deg 50% 50%) 75%\")\r\n * by only looking for a position % after the last closing parenthesis.\r\n */\r\nfunction parseColorStop(\r\n stop: string,\r\n index: number,\r\n total: number,\r\n): { color: string; position: number } {\r\n // Look for a trailing percentage after any function parens\r\n const lastParen = stop.lastIndexOf(\")\");\r\n const tail = lastParen >= 0 ? stop.slice(lastParen + 1) : stop;\r\n const posMatch = tail.match(/\\s+([\\d.]+%)\\s*$/);\r\n if (posMatch) {\r\n const posStr = posMatch[1]!;\r\n const colorEnd = stop.length - posMatch[0].length;\r\n return {\r\n color: stop.slice(0, colorEnd).trim(),\r\n position: parseFloat(posStr) / 100,\r\n };\r\n }\r\n // No parens: try simple \"color position\" format (e.g. \"red 50%\")\r\n if (lastParen < 0) {\r\n const spaceIdx = stop.lastIndexOf(\" \");\r\n if (spaceIdx > 0 && stop.slice(spaceIdx).match(/[\\d.]+%/)) {\r\n return {\r\n color: stop.slice(0, spaceIdx).trim(),\r\n position: parseFloat(stop.slice(spaceIdx)) / 100,\r\n };\r\n }\r\n }\r\n return {\r\n color: stop,\r\n position: total > 1 ? index / (total - 1) : 0,\r\n };\r\n}\r\n\r\n/** Split gradient arguments respecting nested parentheses */\r\nfunction splitGradientArgs(str: string): string[] {\r\n const parts: string[] = [];\r\n let depth = 0;\r\n let current = \"\";\r\n\r\n for (const char of str) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \",\" && depth === 0) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n return parts;\r\n}\r\n\r\nfunction directionToAngle(dir: string): number {\r\n const map: Record = {\r\n \"to top\": 0,\r\n \"to right\": 90,\r\n \"to bottom\": 180,\r\n \"to left\": 270,\r\n \"to top right\": 45,\r\n \"to top left\": 315,\r\n \"to bottom right\": 135,\r\n \"to bottom left\": 225,\r\n };\r\n return map[dir] ?? 180;\r\n}\r\n\r\nfunction parseAngle(value: string): number {\r\n if (value.endsWith(\"deg\")) return parseFloat(value);\r\n if (value.endsWith(\"rad\")) return (parseFloat(value) * 180) / Math.PI;\r\n if (value.endsWith(\"turn\")) return parseFloat(value) * 360;\r\n if (value.endsWith(\"grad\")) return parseFloat(value) * 0.9;\r\n return parseFloat(value);\r\n}\r\n\r\n/** Parse a CSS length (px) or percentage relative to a container dimension */\r\nfunction parseLengthOrPercent(value: string, containerSize: number): number | null {\r\n if (value === \"center\") return containerSize / 2;\r\n if (value === \"left\" || value === \"top\") return 0;\r\n if (value === \"right\" || value === \"bottom\") return containerSize;\r\n if (value.endsWith(\"%\")) return (parseFloat(value) / 100) * containerSize;\r\n const num = parseFloat(value);\r\n return isNaN(num) ? null : num;\r\n}\r\n","const IMAGE_TIMEOUT_MS = 10_000;\r\nconst MAX_CANVAS_DIM = 4096;\r\n\r\n/**\r\n * Convert an image URL to a data URL by drawing it onto a canvas.\r\n * Falls back to the original URL if CORS prevents reading or loading times out.\r\n */\r\nexport async function imageToDataUrl(url: string): Promise {\r\n // Already a data URL\r\n if (url.startsWith(\"data:\")) return url;\r\n\r\n return new Promise((resolve) => {\r\n const img = new Image();\r\n img.crossOrigin = \"anonymous\";\r\n\r\n const timer = setTimeout(() => {\r\n console.warn(`dom2svg: Image load timed out after ${IMAGE_TIMEOUT_MS}ms, using original URL: ${url}`);\r\n img.onload = null;\r\n img.onerror = null;\r\n resolve(url);\r\n }, IMAGE_TIMEOUT_MS);\r\n\r\n img.onload = () => {\r\n clearTimeout(timer);\r\n try {\r\n const canvas = document.createElement(\"canvas\");\r\n // Cap dimensions to prevent OOM on very large images\r\n let w = img.naturalWidth;\r\n let h = img.naturalHeight;\r\n if (w > MAX_CANVAS_DIM || h > MAX_CANVAS_DIM) {\r\n const scale = MAX_CANVAS_DIM / Math.max(w, h);\r\n w = Math.round(w * scale);\r\n h = Math.round(h * scale);\r\n }\r\n canvas.width = w;\r\n canvas.height = h;\r\n const ctx = canvas.getContext(\"2d\");\r\n if (ctx) {\r\n ctx.drawImage(img, 0, 0, w, h);\r\n resolve(canvas.toDataURL(\"image/png\"));\r\n } else {\r\n resolve(url);\r\n }\r\n } catch {\r\n console.warn(`dom2svg: CORS prevented inlining image, external URL will remain in SVG: ${url}`);\r\n resolve(url);\r\n }\r\n };\r\n img.onerror = () => {\r\n clearTimeout(timer);\r\n console.warn(`dom2svg: Failed to load image, external URL will remain in SVG: ${url}`);\r\n resolve(url);\r\n };\r\n img.src = url;\r\n });\r\n}\r\n\r\n/** Extract URL from css url() value */\r\nexport function extractUrlFromCss(value: string): string | null {\r\n const match = value.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);\r\n return match?.[1] ?? null;\r\n}\r\n\r\n/** Convert a canvas element to a data URL */\r\nexport function canvasToDataUrl(canvas: HTMLCanvasElement): string {\r\n try {\r\n return canvas.toDataURL(\"image/png\");\r\n } catch {\r\n return \"\";\r\n }\r\n}\r\n","import type { TransformFunction } from \"../types.js\";\r\n\r\n/**\r\n * Parse a CSS transform string into a list of transform functions.\r\n * Supports: matrix, translate, translateX, translateY, scale, scaleX, scaleY,\r\n * rotate, skewX, skewY.\r\n */\r\nexport function parseTransform(value: string): TransformFunction[] {\r\n if (!value || value === \"none\") return [];\r\n\r\n const functions: TransformFunction[] = [];\r\n const regex = /(\\w+)\\(([^)]+)\\)/g;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = regex.exec(value)) !== null) {\r\n const name = match[1]!;\r\n const args = match[2]!.split(\",\").map((s) => s.trim());\r\n\r\n switch (name) {\r\n case \"matrix\": {\r\n const vals = args.map(parseFloat);\r\n if (vals.length === 6) {\r\n functions.push({\r\n type: \"matrix\",\r\n values: vals as [number, number, number, number, number, number],\r\n });\r\n }\r\n break;\r\n }\r\n case \"translate\": {\r\n const x = parseLengthValue(args[0]!);\r\n const y = args[1] ? parseLengthValue(args[1]) : 0;\r\n functions.push({ type: \"translate\", x, y });\r\n break;\r\n }\r\n case \"translateX\": {\r\n functions.push({ type: \"translate\", x: parseLengthValue(args[0]!), y: 0 });\r\n break;\r\n }\r\n case \"translateY\": {\r\n functions.push({ type: \"translate\", x: 0, y: parseLengthValue(args[0]!) });\r\n break;\r\n }\r\n case \"scale\": {\r\n const sx = parseFloat(args[0]!);\r\n const sy = args[1] ? parseFloat(args[1]) : sx;\r\n functions.push({ type: \"scale\", x: sx, y: sy });\r\n break;\r\n }\r\n case \"scaleX\": {\r\n functions.push({ type: \"scale\", x: parseFloat(args[0]!), y: 1 });\r\n break;\r\n }\r\n case \"scaleY\": {\r\n functions.push({ type: \"scale\", x: 1, y: parseFloat(args[0]!) });\r\n break;\r\n }\r\n case \"rotate\": {\r\n functions.push({ type: \"rotate\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n case \"skewX\": {\r\n functions.push({ type: \"skewX\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n case \"skewY\": {\r\n functions.push({ type: \"skewY\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n }\r\n }\r\n\r\n return functions;\r\n}\r\n\r\nfunction parseLengthValue(value: string): number {\r\n return parseFloat(value) || 0;\r\n}\r\n\r\nfunction parseAngleValue(value: string): number {\r\n value = value.trim();\r\n if (value.endsWith(\"rad\")) return (parseFloat(value) * 180) / Math.PI;\r\n if (value.endsWith(\"turn\")) return parseFloat(value) * 360;\r\n if (value.endsWith(\"grad\")) return parseFloat(value) * 0.9;\r\n // Default: degrees\r\n return parseFloat(value) || 0;\r\n}\r\n","import type { MatrixTuple } from \"../types.js\";\r\n\r\n/**\r\n * 2D affine transform matrix operations.\r\n * Matrix layout: [a, b, c, d, e, f]\r\n *\r\n * | a c e |\r\n * | b d f |\r\n * | 0 0 1 |\r\n */\r\n\r\n/** Identity matrix */\r\nexport function identity(): MatrixTuple {\r\n return [1, 0, 0, 1, 0, 0];\r\n}\r\n\r\n/** Multiply two matrices: A * B */\r\nexport function multiply(a: MatrixTuple, b: MatrixTuple): MatrixTuple {\r\n return [\r\n a[0] * b[0] + a[2] * b[1],\r\n a[1] * b[0] + a[3] * b[1],\r\n a[0] * b[2] + a[2] * b[3],\r\n a[1] * b[2] + a[3] * b[3],\r\n a[0] * b[4] + a[2] * b[5] + a[4],\r\n a[1] * b[4] + a[3] * b[5] + a[5],\r\n ];\r\n}\r\n\r\n/** Create a translation matrix */\r\nexport function translate(tx: number, ty: number): MatrixTuple {\r\n return [1, 0, 0, 1, tx, ty];\r\n}\r\n\r\n/** Create a scale matrix */\r\nexport function scale(sx: number, sy: number): MatrixTuple {\r\n return [sx, 0, 0, sy, 0, 0];\r\n}\r\n\r\n/** Create a rotation matrix (angle in degrees) */\r\nexport function rotate(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n const cos = Math.cos(rad);\r\n const sin = Math.sin(rad);\r\n return [cos, sin, -sin, cos, 0, 0];\r\n}\r\n\r\n/** Create a skewX matrix (angle in degrees) */\r\nexport function skewX(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n return [1, 0, Math.tan(rad), 1, 0, 0];\r\n}\r\n\r\n/** Create a skewY matrix (angle in degrees) */\r\nexport function skewY(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n return [1, Math.tan(rad), 0, 1, 0, 0];\r\n}\r\n\r\n/** Compute the inverse of a matrix. Returns null if singular. */\r\nexport function inverse(m: MatrixTuple): MatrixTuple | null {\r\n const det = m[0] * m[3] - m[1] * m[2];\r\n if (Math.abs(det) < 1e-10) return null;\r\n\r\n const invDet = 1 / det;\r\n return [\r\n m[3] * invDet,\r\n -m[1] * invDet,\r\n -m[2] * invDet,\r\n m[0] * invDet,\r\n (m[2] * m[5] - m[3] * m[4]) * invDet,\r\n (m[1] * m[4] - m[0] * m[5]) * invDet,\r\n ];\r\n}\r\n\r\n/** Check if a matrix is the identity matrix */\r\nexport function isIdentity(m: MatrixTuple): boolean {\r\n return (\r\n Math.abs(m[0] - 1) < 1e-10 &&\r\n Math.abs(m[1]) < 1e-10 &&\r\n Math.abs(m[2]) < 1e-10 &&\r\n Math.abs(m[3] - 1) < 1e-10 &&\r\n Math.abs(m[4]) < 1e-10 &&\r\n Math.abs(m[5]) < 1e-10\r\n );\r\n}\r\n\r\n/** Format matrix as SVG transform attribute value */\r\nexport function toSvgTransform(m: MatrixTuple): string {\r\n return `matrix(${m.map((v) => v.toFixed(6)).join(\",\")})`;\r\n}\r\n","import type { TransformFunction, MatrixTuple } from \"../types.js\";\r\nimport { parseTransform } from \"./parse.js\";\r\nimport * as mat from \"./matrix.js\";\r\n\r\n/**\r\n * Convert a CSS transform string to an SVG transform attribute value.\r\n * Returns null if no transform or identity transform.\r\n */\r\nexport function cssTransformToSvg(\r\n cssTransform: string,\r\n transformOrigin: string,\r\n box: { x: number; y: number; width: number; height: number },\r\n): string | null {\r\n const functions = parseTransform(cssTransform);\r\n if (functions.length === 0) return null;\r\n\r\n // Parse transform-origin\r\n const [ox, oy] = parseTransformOrigin(transformOrigin, box);\r\n\r\n // Build the combined matrix\r\n let result = mat.identity();\r\n\r\n // Move origin\r\n result = mat.multiply(result, mat.translate(ox, oy));\r\n\r\n // Apply each transform function\r\n for (const fn of functions) {\r\n result = mat.multiply(result, transformFunctionToMatrix(fn));\r\n }\r\n\r\n // Move origin back\r\n result = mat.multiply(result, mat.translate(-ox, -oy));\r\n\r\n if (mat.isIdentity(result)) return null;\r\n\r\n return mat.toSvgTransform(result);\r\n}\r\n\r\n/** Convert a single TransformFunction to a matrix */\r\nfunction transformFunctionToMatrix(fn: TransformFunction): MatrixTuple {\r\n switch (fn.type) {\r\n case \"matrix\":\r\n return fn.values;\r\n case \"translate\":\r\n return mat.translate(fn.x, fn.y);\r\n case \"scale\":\r\n return mat.scale(fn.x, fn.y);\r\n case \"rotate\":\r\n return mat.rotate(fn.angle);\r\n case \"skewX\":\r\n return mat.skewX(fn.angle);\r\n case \"skewY\":\r\n return mat.skewY(fn.angle);\r\n }\r\n}\r\n\r\n/** Parse CSS transform-origin into absolute coordinates */\r\nfunction parseTransformOrigin(\r\n origin: string,\r\n box: { x: number; y: number; width: number; height: number },\r\n): [number, number] {\r\n const parts = origin.split(/\\s+/);\r\n const x = parseOriginValue(parts[0] ?? \"50%\", box.width, box.x);\r\n const y = parseOriginValue(parts[1] ?? \"50%\", box.height, box.y);\r\n return [x, y];\r\n}\r\n\r\nfunction parseOriginValue(\r\n value: string,\r\n size: number,\r\n offset: number,\r\n): number {\r\n if (value === \"left\" || value === \"top\") return offset;\r\n if (value === \"right\" || value === \"bottom\") return offset + size;\r\n if (value === \"center\") return offset + size / 2;\r\n if (value.endsWith(\"%\")) {\r\n return offset + (parseFloat(value) / 100) * size;\r\n }\r\n return offset + parseFloat(value);\r\n}\r\n","import type { RenderContext } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\n\r\n/** A parsed CSS filter function */\r\ninterface CssFilterFunction {\r\n name: string;\r\n args: string;\r\n}\r\n\r\n/**\r\n * Parse a CSS filter value and create an SVG with the equivalent primitives.\r\n * Supports: blur, brightness, contrast, drop-shadow, grayscale, hue-rotate,\r\n * invert, opacity, saturate, sepia.\r\n * Returns the filter ID, or null if no recognized filter functions found.\r\n */\r\nexport function createSvgFilter(\r\n filterValue: string,\r\n ctx: RenderContext,\r\n): string | null {\r\n const functions = parseCssFilterFunctions(filterValue);\r\n if (functions.length === 0) return null;\r\n\r\n const id = ctx.idGenerator.next(\"filter\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n setAttributes(filter, {\r\n id,\r\n x: \"-50%\",\r\n y: \"-50%\",\r\n width: \"200%\",\r\n height: \"200%\",\r\n });\r\n\r\n let hasAny = false;\r\n\r\n for (const fn of functions) {\r\n const primitives = createFilterPrimitives(fn, ctx);\r\n for (const prim of primitives) {\r\n filter.appendChild(prim);\r\n hasAny = true;\r\n }\r\n }\r\n\r\n if (!hasAny) return null;\r\n\r\n ctx.defs.appendChild(filter);\r\n return id;\r\n}\r\n\r\n/** Parse a numeric value that may have a % suffix. Returns a ratio (1 = 100%). */\r\nfunction parseFilterAmount(raw: string): number {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"%\")) {\r\n return (parseFloat(trimmed) || 0) / 100;\r\n }\r\n return parseFloat(trimmed) || 0;\r\n}\r\n\r\n/** Parse an angle value, returning degrees. Handles deg, rad, grad, turn. */\r\nfunction parseAngle(raw: string): number {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"rad\")) return (parseFloat(trimmed) || 0) * (180 / Math.PI);\r\n if (trimmed.endsWith(\"grad\")) return (parseFloat(trimmed) || 0) * 0.9;\r\n if (trimmed.endsWith(\"turn\")) return (parseFloat(trimmed) || 0) * 360;\r\n // deg or bare number\r\n return parseFloat(trimmed) || 0;\r\n}\r\n\r\n/** Create SVG filter primitive(s) for a single CSS filter function */\r\nfunction createFilterPrimitives(\r\n fn: CssFilterFunction,\r\n ctx: RenderContext,\r\n): SVGElement[] {\r\n switch (fn.name) {\r\n case \"blur\": {\r\n // CSS blur() value IS the stdDeviation directly\r\n const radius = parseFloat(fn.args) || 0;\r\n const blur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(blur, { stdDeviation: radius });\r\n return [blur];\r\n }\r\n\r\n case \"brightness\": {\r\n const amount = parseFilterAmount(fn.args);\r\n return [createComponentTransfer(ctx, { slope: amount })];\r\n }\r\n\r\n case \"contrast\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const intercept = 0.5 - 0.5 * amount;\r\n return [createComponentTransfer(ctx, { slope: amount, intercept })];\r\n }\r\n\r\n case \"drop-shadow\": {\r\n const parsed = parseDropShadow(`drop-shadow(${fn.args})`);\r\n if (!parsed) return [];\r\n const shadow = createSvgElement(ctx.svgDocument, \"feDropShadow\");\r\n setAttributes(shadow, {\r\n dx: parsed.offsetX,\r\n dy: parsed.offsetY,\r\n stdDeviation: parsed.blur / 2,\r\n \"flood-color\": parsed.color,\r\n \"flood-opacity\": 1,\r\n });\r\n return [shadow];\r\n }\r\n\r\n case \"grayscale\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const s = Math.max(0, Math.min(1, 1 - amount));\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"saturate\", values: s });\r\n return [matrix];\r\n }\r\n\r\n case \"hue-rotate\": {\r\n const degrees = parseAngle(fn.args);\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"hueRotate\", values: degrees });\r\n return [matrix];\r\n }\r\n\r\n case \"invert\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const lo = amount;\r\n const hi = 1 - amount;\r\n return [createComponentTransfer(ctx, {\r\n type: \"table\",\r\n tableValues: `${lo} ${hi}`,\r\n })];\r\n }\r\n\r\n case \"opacity\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const transfer = createSvgElement(ctx.svgDocument, \"feComponentTransfer\");\r\n const funcA = createSvgElement(ctx.svgDocument, \"feFuncA\");\r\n setAttributes(funcA, { type: \"linear\", slope: amount, intercept: 0 });\r\n transfer.appendChild(funcA);\r\n return [transfer];\r\n }\r\n\r\n case \"saturate\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"saturate\", values: amount });\r\n return [matrix];\r\n }\r\n\r\n case \"sepia\": {\r\n const amount = Math.max(0, Math.min(1, parseFilterAmount(fn.args)));\r\n // Interpolate between identity matrix and sepia matrix\r\n const a = amount;\r\n const b = 1 - amount;\r\n const values = [\r\n b + a * 0.393, a * 0.769, a * 0.189, 0, 0,\r\n a * 0.349, b + a * 0.686, a * 0.168, 0, 0,\r\n a * 0.272, a * 0.534, b + a * 0.131, 0, 0,\r\n 0, 0, 0, 1, 0,\r\n ].map(v => v.toFixed(4)).join(\" \");\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"matrix\", values });\r\n return [matrix];\r\n }\r\n\r\n default:\r\n return [];\r\n }\r\n}\r\n\r\n/** Create an feComponentTransfer for RGB channels with uniform settings */\r\nfunction createComponentTransfer(\r\n ctx: RenderContext,\r\n opts: { slope?: number; intercept?: number; type?: string; tableValues?: string },\r\n): SVGElement {\r\n const transfer = createSvgElement(ctx.svgDocument, \"feComponentTransfer\");\r\n for (const channel of [\"feFuncR\", \"feFuncG\", \"feFuncB\"] as const) {\r\n const func = createSvgElement(ctx.svgDocument, channel);\r\n if (opts.type === \"table\" && opts.tableValues) {\r\n setAttributes(func, { type: \"table\", tableValues: opts.tableValues });\r\n } else {\r\n const attrs: Record = {\r\n type: \"linear\",\r\n slope: opts.slope ?? 1,\r\n };\r\n if (opts.intercept !== undefined) attrs.intercept = opts.intercept;\r\n setAttributes(func, attrs);\r\n }\r\n transfer.appendChild(func);\r\n }\r\n return transfer;\r\n}\r\n\r\n/**\r\n * Extract individual CSS filter functions from a filter value string.\r\n * Handles nested parentheses (e.g. drop-shadow with rgba()).\r\n */\r\nexport function parseCssFilterFunctions(value: string): CssFilterFunction[] {\r\n const results: CssFilterFunction[] = [];\r\n const regex = /([a-z-]+)\\(/gi;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = regex.exec(value)) !== null) {\r\n const name = match[1]!;\r\n const argsStart = match.index + match[0].length;\r\n\r\n // Find matching closing paren, respecting nesting\r\n let depth = 1;\r\n let i = argsStart;\r\n for (; i < value.length && depth > 0; i++) {\r\n if (value[i] === \"(\") depth++;\r\n else if (value[i] === \")\") depth--;\r\n }\r\n\r\n const args = value.slice(argsStart, i - 1).trim();\r\n results.push({ name: name.toLowerCase(), args });\r\n\r\n // Advance regex past this function\r\n regex.lastIndex = i;\r\n }\r\n\r\n return results;\r\n}\r\n\r\nexport interface DropShadow {\r\n offsetX: number;\r\n offsetY: number;\r\n blur: number;\r\n color: string;\r\n}\r\n\r\n/** @internal Exported for testing */\r\nexport function parseDropShadow(value: string): DropShadow | null {\r\n // Match drop-shadow(...) respecting nested parentheses (e.g. rgba())\r\n const startIdx = value.indexOf(\"drop-shadow(\");\r\n if (startIdx === -1) return null;\r\n\r\n const argsStart = startIdx + \"drop-shadow(\".length;\r\n let depth = 1;\r\n let argsEnd = argsStart;\r\n for (let i = argsStart; i < value.length && depth > 0; i++) {\r\n if (value[i] === \"(\") depth++;\r\n else if (value[i] === \")\") depth--;\r\n if (depth > 0) argsEnd = i + 1;\r\n }\r\n\r\n const args = value.slice(argsStart, argsEnd).trim();\r\n if (!args) return null;\r\n\r\n // Parse: offsetX offsetY [blur] [color]\r\n // Color can be at start or end, with various formats\r\n const parts: string[] = [];\r\n let current = \"\";\r\n let parenDepth = 0;\r\n\r\n for (const char of args) {\r\n if (char === \"(\") parenDepth++;\r\n else if (char === \")\") parenDepth--;\r\n\r\n if (char === \" \" && parenDepth === 0 && current) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n\r\n if (parts.length < 2) return null;\r\n\r\n // Find numeric values and color\r\n const numericParts: number[] = [];\r\n let color = \"rgba(0,0,0,0.3)\";\r\n\r\n for (const part of parts) {\r\n const num = parseFloat(part);\r\n if (!isNaN(num) && (part.endsWith(\"px\") || part.match(/^-?[\\d.]+$/))) {\r\n numericParts.push(num);\r\n } else {\r\n color = part;\r\n }\r\n }\r\n\r\n return {\r\n offsetX: numericParts[0] ?? 0,\r\n offsetY: numericParts[1] ?? 0,\r\n blur: numericParts[2] ?? 0,\r\n color,\r\n };\r\n}\r\n","import type { RenderContext, BoxGeometry, BorderRadii } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\nimport { buildRoundedRectPath } from \"../utils/geometry.js\";\r\nimport { hasRadius, isUniformRadius } from \"../core/styles.js\";\r\n\r\nexport interface BoxShadow {\r\n inset: boolean;\r\n offsetX: number;\r\n offsetY: number;\r\n blur: number;\r\n spread: number;\r\n color: string;\r\n}\r\n\r\n/**\r\n * Parse a CSS box-shadow value into an array of BoxShadow objects.\r\n * Supports multiple shadows, inset, spread, blur, and color in various formats.\r\n */\r\nexport function parseBoxShadows(value: string): BoxShadow[] {\r\n if (!value || value === \"none\") return [];\r\n\r\n const shadows: BoxShadow[] = [];\r\n const parts = splitTopLevelCommas(value);\r\n\r\n for (const part of parts) {\r\n const shadow = parseSingleShadow(part.trim());\r\n if (shadow) shadows.push(shadow);\r\n }\r\n\r\n return shadows;\r\n}\r\n\r\n/** Split on commas at depth 0 (respecting parentheses) */\r\nfunction splitTopLevelCommas(str: string): string[] {\r\n const parts: string[] = [];\r\n let depth = 0;\r\n let current = \"\";\r\n\r\n for (const char of str) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \",\" && depth === 0) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n return parts;\r\n}\r\n\r\n/** Parse a single box-shadow value */\r\nfunction parseSingleShadow(value: string): BoxShadow | null {\r\n let inset = false;\r\n let working = value;\r\n\r\n // Check for inset keyword\r\n if (working.startsWith(\"inset \")) {\r\n inset = true;\r\n working = working.slice(6).trim();\r\n } else if (working.endsWith(\" inset\")) {\r\n inset = true;\r\n working = working.slice(0, -6).trim();\r\n }\r\n\r\n // Tokenize respecting parentheses\r\n const tokens: string[] = [];\r\n let current = \"\";\r\n let depth = 0;\r\n\r\n for (const char of working) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \" \" && depth === 0 && current) {\r\n tokens.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) tokens.push(current);\r\n\r\n // Separate numeric (px) tokens from color tokens\r\n const numericValues: number[] = [];\r\n const colorParts: string[] = [];\r\n\r\n for (const token of tokens) {\r\n const num = parseFloat(token);\r\n if (!isNaN(num) && (token.endsWith(\"px\") || token.match(/^-?[\\d.]+$/))) {\r\n numericValues.push(num);\r\n } else {\r\n colorParts.push(token);\r\n }\r\n }\r\n\r\n if (numericValues.length < 2) return null;\r\n\r\n return {\r\n inset,\r\n offsetX: numericValues[0]!,\r\n offsetY: numericValues[1]!,\r\n blur: numericValues[2] ?? 0,\r\n spread: numericValues[3] ?? 0,\r\n color: colorParts.join(\" \") || \"rgba(0, 0, 0, 0.3)\",\r\n };\r\n}\r\n\r\n/**\r\n * Render box-shadows as SVG elements. Non-inset shadows use SVG filters\r\n * for Gaussian blur; inset shadows are approximated similarly.\r\n * Returns an array of SVG elements to prepend before the element's content.\r\n */\r\nexport function renderBoxShadows(\r\n shadows: BoxShadow[],\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // CSS renders shadows in reverse order (first shadow = topmost)\r\n for (let i = shadows.length - 1; i >= 0; i--) {\r\n const shadow = shadows[i]!;\r\n if (shadow.inset) {\r\n renderInsetShadow(shadow, box, radii, ctx, group);\r\n } else {\r\n renderOuterShadow(shadow, box, radii, ctx, group);\r\n }\r\n }\r\n}\r\n\r\nfunction renderOuterShadow(\r\n shadow: BoxShadow,\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // Expand box by spread\r\n const spreadBox: BoxGeometry = {\r\n x: box.x + shadow.offsetX - shadow.spread,\r\n y: box.y + shadow.offsetY - shadow.spread,\r\n width: box.width + shadow.spread * 2,\r\n height: box.height + shadow.spread * 2,\r\n };\r\n\r\n // Expand radii by spread\r\n const spreadRadii = expandRadii(radii, shadow.spread);\r\n\r\n // Create shape\r\n const shape = createShadowShape(spreadBox, spreadRadii, ctx);\r\n shape.setAttribute(\"fill\", shadow.color);\r\n\r\n if (shadow.blur > 0) {\r\n // Create SVG filter for blur\r\n const filterId = ctx.idGenerator.next(\"shadow\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n const margin = shadow.blur * 2 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + shadow.spread;\r\n // Guard against zero/tiny dimensions to avoid division-by-zero or huge percentages\r\n const safeW = Math.max(spreadBox.width, 1);\r\n const safeH = Math.max(spreadBox.height, 1);\r\n setAttributes(filter, {\r\n id: filterId,\r\n x: `-${((margin / safeW) * 100 + 10).toFixed(0)}%`,\r\n y: `-${((margin / safeH) * 100 + 10).toFixed(0)}%`,\r\n width: `${(200 + (margin / safeW) * 200 + 20).toFixed(0)}%`,\r\n height: `${(200 + (margin / safeH) * 200 + 20).toFixed(0)}%`,\r\n });\r\n\r\n const feGaussianBlur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(feGaussianBlur, {\r\n in: \"SourceGraphic\",\r\n stdDeviation: shadow.blur / 2,\r\n });\r\n filter.appendChild(feGaussianBlur);\r\n ctx.defs.appendChild(filter);\r\n\r\n shape.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n\r\n // Insert shadow before existing children (shadows render behind content)\r\n group.insertBefore(shape, group.firstChild);\r\n}\r\n\r\nfunction renderInsetShadow(\r\n shadow: BoxShadow,\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // For inset shadows, we draw a filled ring clipped to the box.\r\n // The ring is a large rect minus the inner shadow shape.\r\n const clipId = ctx.idGenerator.next(\"inset-clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n const clipShape = createShadowShape(box, radii, ctx);\r\n clipPath.appendChild(clipShape);\r\n ctx.defs.appendChild(clipPath);\r\n\r\n // Inner shape (shrunk by spread, offset)\r\n const innerBox: BoxGeometry = {\r\n x: box.x + shadow.offsetX + shadow.spread,\r\n y: box.y + shadow.offsetY + shadow.spread,\r\n width: Math.max(0, box.width - shadow.spread * 2),\r\n height: Math.max(0, box.height - shadow.spread * 2),\r\n };\r\n const innerRadii = expandRadii(radii, -shadow.spread);\r\n\r\n // Use a large outer rect and inner cutout path\r\n const g = createSvgElement(ctx.svgDocument, \"g\") as SVGGElement;\r\n g.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n\r\n // Large surrounding fill\r\n const outerRect = createSvgElement(ctx.svgDocument, \"rect\");\r\n const pad = shadow.blur * 3 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + 100;\r\n setAttributes(outerRect, {\r\n x: box.x - pad,\r\n y: box.y - pad,\r\n width: box.width + pad * 2,\r\n height: box.height + pad * 2,\r\n fill: shadow.color,\r\n });\r\n\r\n // Inner cutout\r\n const innerShape = createShadowShape(innerBox, innerRadii, ctx);\r\n innerShape.setAttribute(\"fill\", shadow.color);\r\n\r\n // Use fill-rule evenodd with combined path for cutout effect\r\n // Simpler: just use the inner shape as a mask\r\n const maskId = ctx.idGenerator.next(\"inset-mask\");\r\n const mask = createSvgElement(ctx.svgDocument, \"mask\");\r\n mask.setAttribute(\"id\", maskId);\r\n\r\n const maskWhite = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(maskWhite, { x: box.x - pad, y: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, fill: \"white\" });\r\n const maskBlack = createShadowShape(innerBox, innerRadii, ctx);\r\n maskBlack.setAttribute(\"fill\", \"black\");\r\n mask.appendChild(maskWhite);\r\n mask.appendChild(maskBlack);\r\n ctx.defs.appendChild(mask);\r\n\r\n outerRect.setAttribute(\"mask\", `url(#${maskId})`);\r\n\r\n if (shadow.blur > 0) {\r\n const filterId = ctx.idGenerator.next(\"inset-blur\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n setAttributes(filter, { id: filterId, x: \"-50%\", y: \"-50%\", width: \"200%\", height: \"200%\" });\r\n const feBlur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(feBlur, { in: \"SourceGraphic\", stdDeviation: shadow.blur / 2 });\r\n filter.appendChild(feBlur);\r\n ctx.defs.appendChild(filter);\r\n outerRect.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n\r\n g.appendChild(outerRect);\r\n group.insertBefore(g, group.firstChild);\r\n}\r\n\r\n/** Create a shape element matching the box (rect or rounded-rect path) */\r\nfunction createShadowShape(\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n): SVGElement {\r\n if (hasRadius(radii) && !isUniformRadius(radii)) {\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii));\r\n return path;\r\n }\r\n\r\n const rect = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(rect, { x: box.x, y: box.y, width: box.width, height: box.height });\r\n\r\n if (hasRadius(radii) && isUniformRadius(radii)) {\r\n setAttributes(rect, { rx: radii.topLeft[0], ry: radii.topLeft[1] });\r\n }\r\n\r\n return rect;\r\n}\r\n\r\n/** Expand (or shrink if negative) radii by a given amount */\r\nfunction expandRadii(radii: BorderRadii, amount: number): BorderRadii {\r\n return {\r\n topLeft: [Math.max(0, radii.topLeft[0] + amount), Math.max(0, radii.topLeft[1] + amount)],\r\n topRight: [Math.max(0, radii.topRight[0] + amount), Math.max(0, radii.topRight[1] + amount)],\r\n bottomRight: [Math.max(0, radii.bottomRight[0] + amount), Math.max(0, radii.bottomRight[1] + amount)],\r\n bottomLeft: [Math.max(0, radii.bottomLeft[0] + amount), Math.max(0, radii.bottomLeft[1] + amount)],\r\n };\r\n}\r\n","import type { RenderContext, BoxGeometry } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\nimport { buildRoundedRectPath } from \"../utils/geometry.js\";\r\n\r\nexport type ClipPathShape =\r\n | { type: \"inset\"; top: number; right: number; bottom: number; left: number; round?: string }\r\n | { type: \"circle\"; radius: number; cx: number; cy: number; cxPct?: boolean; cyPct?: boolean }\r\n | { type: \"ellipse\"; rx: number; ry: number; cx: number; cy: number; cxPct?: boolean; cyPct?: boolean }\r\n | { type: \"polygon\"; points: [number, number][] }\r\n | { type: \"path\"; d: string };\r\n\r\n/** Parse a CSS length value, detecting percentage vs pixel units */\r\nfunction parseLengthValue(raw: string): { value: number; isPct: boolean } {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"%\")) {\r\n return { value: parseFloat(trimmed) || 0, isPct: true };\r\n }\r\n return { value: parseFloat(trimmed) || 0, isPct: false };\r\n}\r\n\r\n/**\r\n * Parse a CSS clip-path value into a ClipPathShape.\r\n * Handles both pixel and percentage values (browser may keep center positions as %).\r\n */\r\nexport function parseClipPath(value: string): ClipPathShape | null {\r\n if (!value || value === \"none\") return null;\r\n\r\n const insetMatch = value.match(/^inset\\((.+)\\)$/);\r\n if (insetMatch) return parseInset(insetMatch[1]!);\r\n\r\n const circleMatch = value.match(/^circle\\((.+)\\)$/);\r\n if (circleMatch) return parseCircle(circleMatch[1]!);\r\n\r\n const ellipseMatch = value.match(/^ellipse\\((.+)\\)$/);\r\n if (ellipseMatch) return parseEllipse(ellipseMatch[1]!);\r\n\r\n const polygonMatch = value.match(/^polygon\\((.+)\\)$/);\r\n if (polygonMatch) return parsePolygon(polygonMatch[1]!);\r\n\r\n const pathMatch = value.match(/^path\\([\"']?(.+?)[\"']?\\)$/);\r\n if (pathMatch) return { type: \"path\", d: pathMatch[1]! };\r\n\r\n return null;\r\n}\r\n\r\nfunction parseInset(args: string): ClipPathShape | null {\r\n // inset(top right bottom left round radii)\r\n const roundIdx = args.indexOf(\" round \");\r\n let insetPart = args;\r\n let round: string | undefined;\r\n if (roundIdx >= 0) {\r\n insetPart = args.slice(0, roundIdx);\r\n round = args.slice(roundIdx + 7).trim();\r\n }\r\n\r\n const values = insetPart.trim().split(/\\s+/).map((v) => parseFloat(v) || 0);\r\n const top = values[0] ?? 0;\r\n const right = values[1] ?? top;\r\n const bottom = values[2] ?? top;\r\n const left = values[3] ?? right;\r\n\r\n return { type: \"inset\", top, right, bottom, left, round };\r\n}\r\n\r\nfunction parseCircle(args: string): ClipPathShape | null {\r\n // circle(radius at cx cy)\r\n const atIdx = args.indexOf(\" at \");\r\n let radius = 0;\r\n let cx = 0;\r\n let cy = 0;\r\n let cxPct = false;\r\n let cyPct = false;\r\n\r\n if (atIdx >= 0) {\r\n radius = parseFloat(args.slice(0, atIdx)) || 0;\r\n const center = args.slice(atIdx + 4).trim().split(/\\s+/);\r\n const cxVal = parseLengthValue(center[0]!);\r\n const cyVal = parseLengthValue(center[1]!);\r\n cx = cxVal.value; cxPct = cxVal.isPct;\r\n cy = cyVal.value; cyPct = cyVal.isPct;\r\n } else {\r\n radius = parseFloat(args) || 0;\r\n // CSS spec: default center is 50% 50%\r\n cx = 50; cy = 50;\r\n cxPct = true; cyPct = true;\r\n }\r\n\r\n return { type: \"circle\", radius, cx, cy, cxPct, cyPct };\r\n}\r\n\r\nfunction parseEllipse(args: string): ClipPathShape | null {\r\n // ellipse(rx ry at cx cy)\r\n const atIdx = args.indexOf(\" at \");\r\n let rx = 0;\r\n let ry = 0;\r\n let cx = 0;\r\n let cy = 0;\r\n let cxPct = false;\r\n let cyPct = false;\r\n\r\n if (atIdx >= 0) {\r\n const radii = args.slice(0, atIdx).trim().split(/\\s+/);\r\n rx = parseFloat(radii[0]!) || 0;\r\n ry = parseFloat(radii[1]!) || 0;\r\n const center = args.slice(atIdx + 4).trim().split(/\\s+/);\r\n const cxVal = parseLengthValue(center[0]!);\r\n const cyVal = parseLengthValue(center[1]!);\r\n cx = cxVal.value; cxPct = cxVal.isPct;\r\n cy = cyVal.value; cyPct = cyVal.isPct;\r\n } else {\r\n const parts = args.trim().split(/\\s+/);\r\n rx = parseFloat(parts[0]!) || 0;\r\n ry = parseFloat(parts[1]!) || 0;\r\n // CSS spec: default center is 50% 50%\r\n cx = 50; cy = 50;\r\n cxPct = true; cyPct = true;\r\n }\r\n\r\n return { type: \"ellipse\", rx, ry, cx, cy, cxPct, cyPct };\r\n}\r\n\r\nfunction parsePolygon(args: string): ClipPathShape | null {\r\n // polygon(x1 y1, x2 y2, ...)\r\n // Remove optional fill-rule prefix\r\n let cleaned = args.trim();\r\n if (cleaned.startsWith(\"nonzero,\") || cleaned.startsWith(\"evenodd,\")) {\r\n cleaned = cleaned.slice(cleaned.indexOf(\",\") + 1).trim();\r\n }\r\n\r\n const points: [number, number][] = [];\r\n const pairs = cleaned.split(\",\");\r\n\r\n for (const pair of pairs) {\r\n const parts = pair.trim().split(/\\s+/);\r\n if (parts.length >= 2) {\r\n points.push([parseFloat(parts[0]!) || 0, parseFloat(parts[1]!) || 0]);\r\n }\r\n }\r\n\r\n if (points.length < 3) return null;\r\n return { type: \"polygon\", points };\r\n}\r\n\r\n/**\r\n * Create an SVG element in defs and return its ID.\r\n * The clip shape is positioned relative to the element's box.\r\n */\r\nexport function createSvgClipPath(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): string | null {\r\n const clipId = ctx.idGenerator.next(\"clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n\r\n const svgShape = shapeToSvg(shape, box, ctx);\r\n if (!svgShape) return null;\r\n\r\n clipPath.appendChild(svgShape);\r\n ctx.defs.appendChild(clipPath);\r\n\r\n return clipId;\r\n}\r\n\r\nfunction shapeToSvg(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): SVGElement | null {\r\n switch (shape.type) {\r\n case \"inset\": {\r\n const x = box.x + shape.left;\r\n const y = box.y + shape.top;\r\n const w = Math.max(0, box.width - shape.left - shape.right);\r\n const h = Math.max(0, box.height - shape.top - shape.bottom);\r\n\r\n if (shape.round) {\r\n // Parse border-radius shorthand for inset\r\n const radiiValues = shape.round.split(\"/\").map((part) =>\r\n part.trim().split(/\\s+/).map((v) => parseFloat(v) || 0),\r\n );\r\n const h_values = radiiValues[0] ?? [0];\r\n const v_values = radiiValues[1] ?? h_values;\r\n\r\n const radii = {\r\n topLeft: [h_values[0] ?? 0, v_values[0] ?? 0] as [number, number],\r\n topRight: [h_values[1] ?? h_values[0] ?? 0, v_values[1] ?? v_values[0] ?? 0] as [number, number],\r\n bottomRight: [h_values[2] ?? h_values[0] ?? 0, v_values[2] ?? v_values[0] ?? 0] as [number, number],\r\n bottomLeft: [h_values[3] ?? h_values[1] ?? h_values[0] ?? 0, v_values[3] ?? v_values[1] ?? v_values[0] ?? 0] as [number, number],\r\n };\r\n\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", buildRoundedRectPath(x, y, w, h, radii));\r\n return path;\r\n }\r\n\r\n const rect = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(rect, { x, y, width: w, height: h });\r\n return rect;\r\n }\r\n\r\n case \"circle\": {\r\n const resolvedCx = shape.cxPct ? (shape.cx / 100) * box.width : shape.cx;\r\n const resolvedCy = shape.cyPct ? (shape.cy / 100) * box.height : shape.cy;\r\n const circle = createSvgElement(ctx.svgDocument, \"circle\");\r\n setAttributes(circle, {\r\n cx: box.x + resolvedCx,\r\n cy: box.y + resolvedCy,\r\n r: shape.radius,\r\n });\r\n return circle;\r\n }\r\n\r\n case \"ellipse\": {\r\n const resolvedCx = shape.cxPct ? (shape.cx / 100) * box.width : shape.cx;\r\n const resolvedCy = shape.cyPct ? (shape.cy / 100) * box.height : shape.cy;\r\n const ellipse = createSvgElement(ctx.svgDocument, \"ellipse\");\r\n setAttributes(ellipse, {\r\n cx: box.x + resolvedCx,\r\n cy: box.y + resolvedCy,\r\n rx: shape.rx,\r\n ry: shape.ry,\r\n });\r\n return ellipse;\r\n }\r\n\r\n case \"polygon\": {\r\n const polygon = createSvgElement(ctx.svgDocument, \"polygon\");\r\n const pointsStr = shape.points\r\n .map(([x, y]) => `${box.x + x},${box.y + y}`)\r\n .join(\" \");\r\n polygon.setAttribute(\"points\", pointsStr);\r\n return polygon;\r\n }\r\n\r\n case \"path\": {\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", shape.d);\r\n // Translate path to box position\r\n path.setAttribute(\"transform\", `translate(${box.x}, ${box.y})`);\r\n return path;\r\n }\r\n\r\n default:\r\n return null;\r\n }\r\n}\r\n","import type { RenderContext, BorderRadii, BoxGeometry } from \"../types.js\";\r\nimport {\r\n createSvgElement,\r\n setAttributes,\r\n isImageElement,\r\n isCanvasElement,\r\n isFormElement,\r\n getPseudoStyles,\r\n} from \"../utils/dom.js\";\r\nimport { getRelativeBox, buildRoundedRectPath } from \"../utils/geometry.js\";\r\nimport {\r\n parseBorders,\r\n parseBorderRadii,\r\n clampRadii,\r\n hasBorder,\r\n hasRadius,\r\n isUniformRadius,\r\n hasOverflowClip,\r\n parseBackgroundColor,\r\n hasBackgroundImage,\r\n isVisibilityHidden,\r\n} from \"../core/styles.js\";\r\nimport { parseLinearGradient, createSvgLinearGradient, rasterizeGradient } from \"../assets/gradients.js\";\r\nimport { imageToDataUrl, extractUrlFromCss, canvasToDataUrl } from \"../assets/images.js\";\r\nimport { cssTransformToSvg } from \"../transforms/svg.js\";\r\nimport { createSvgFilter } from \"../assets/filters.js\";\r\nimport { parseBoxShadows, renderBoxShadows } from \"../assets/box-shadow.js\";\r\nimport { parseClipPath, createSvgClipPath } from \"../assets/clip-path.js\";\r\n\r\n/**\r\n * Render an HTML element's visual properties (background, borders, overflow mask).\r\n * Returns a group containing the element's own visuals.\r\n * Children are rendered separately by the traversal engine.\r\n */\r\nexport async function renderHtmlElement(\r\n element: Element,\r\n rootElement: Element,\r\n ctx: RenderContext,\r\n): Promise {\r\n const group = createSvgElement(ctx.svgDocument, \"g\") as SVGGElement;\r\n const styles = window.getComputedStyle(element);\r\n const box = getRelativeBox(element, rootElement);\r\n const radii = clampRadii(parseBorderRadii(styles), box.width, box.height);\r\n\r\n // CSS Transforms (applied even when visibility:hidden for layout)\r\n // When flattenTransforms is enabled, skip — getBoundingClientRect positions\r\n // already include the effect of CSS transforms.\r\n if (!ctx.options.flattenTransforms && styles.transform && styles.transform !== \"none\") {\r\n const svgTransform = cssTransformToSvg(\r\n styles.transform,\r\n styles.transformOrigin,\r\n box,\r\n );\r\n if (svgTransform) {\r\n group.setAttribute(\"transform\", svgTransform);\r\n }\r\n }\r\n\r\n // CSS clip-path (applied even when visibility:hidden, like transforms)\r\n const clipPathValue = styles.clipPath;\r\n if (clipPathValue && clipPathValue !== \"none\") {\r\n const shape = parseClipPath(clipPathValue);\r\n if (shape) {\r\n const clipId = createSvgClipPath(shape, box, ctx);\r\n if (clipId) group.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n }\r\n }\r\n\r\n // Skip own visuals when visibility:hidden, but keep the group\r\n // so visible children can still be rendered inside it.\r\n const hidden = isVisibilityHidden(styles);\r\n\r\n if (!hidden) {\r\n // CSS Filters (blur, brightness, contrast, drop-shadow, grayscale, etc.)\r\n if (styles.filter && styles.filter !== \"none\") {\r\n const filterId = createSvgFilter(styles.filter, ctx);\r\n if (filterId) {\r\n group.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n }\r\n\r\n // Box shadows (rendered behind content)\r\n const boxShadowValue = styles.boxShadow;\r\n if (boxShadowValue && boxShadowValue !== \"none\") {\r\n const shadows = parseBoxShadows(boxShadowValue);\r\n if (shadows.length > 0) {\r\n renderBoxShadows(shadows, box, radii, ctx, group);\r\n }\r\n }\r\n\r\n // Background color\r\n const bgColor = parseBackgroundColor(styles);\r\n if (bgColor) {\r\n const rect = createBoxShape(box, radii, ctx);\r\n rect.setAttribute(\"fill\", bgColor);\r\n group.appendChild(rect);\r\n }\r\n\r\n // Background image (gradients + URLs)\r\n if (hasBackgroundImage(styles)) {\r\n await renderBackgroundImages(styles, box, radii, ctx, group);\r\n }\r\n\r\n // Borders\r\n const borders = parseBorders(styles);\r\n if (hasBorder(borders)) {\r\n renderBorders(group, box, borders, radii, ctx);\r\n }\r\n\r\n // Outline (rendered outside the border box)\r\n renderOutline(styles, box, radii, ctx, group);\r\n\r\n // element\r\n if (isImageElement(element) && element.src) {\r\n const dataUrl = await imageToDataUrl(element.src);\r\n const imgEl = createSvgElement(ctx.svgDocument, \"image\");\r\n setAttributes(imgEl, {\r\n x: box.x,\r\n y: box.y,\r\n width: box.width,\r\n height: box.height,\r\n href: dataUrl,\r\n });\r\n const objectFit = styles.objectFit || element.style.objectFit;\r\n if (objectFit === \"fill\" || objectFit === \"\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"none\");\r\n } else if (objectFit === \"contain\" || objectFit === \"scale-down\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"xMidYMid meet\");\r\n } else if (objectFit === \"cover\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"xMidYMid slice\");\r\n }\r\n // Clip image to border-radius when present\r\n if (hasRadius(radii)) {\r\n const clipId = ctx.idGenerator.next(\"clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n const clipShape = createSvgElement(ctx.svgDocument, \"path\");\r\n clipShape.setAttribute(\"d\", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii));\r\n clipPath.appendChild(clipShape);\r\n ctx.defs.appendChild(clipPath);\r\n imgEl.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n }\r\n group.appendChild(imgEl);\r\n }\r\n\r\n // element\r\n if (isCanvasElement(element)) {\r\n const dataUrl = canvasToDataUrl(element);\r\n if (dataUrl) {\r\n const imgEl = createSvgElement(ctx.svgDocument, \"image\");\r\n setAttributes(imgEl, {\r\n x: box.x,\r\n y: box.y,\r\n width: box.width,\r\n height: box.height,\r\n href: dataUrl,\r\n });\r\n group.appendChild(imgEl);\r\n }\r\n }\r\n\r\n // Form element content (,