diff --git a/src/lib/export/dom2svg/index.d.ts b/src/lib/export/dom2svg/index.d.ts index 1cc0aa26..5a5abbb8 100644 --- a/src/lib/export/dom2svg/index.d.ts +++ b/src/lib/export/dom2svg/index.d.ts @@ -8,18 +8,28 @@ interface FontConfig { type FontMapping = Record; /** SVG compatibility configuration flags */ interface SvgCompatConfig { + /** Use instead of for overflow clipping */ useClipPathForOverflow: boolean; + /** Skip CSS filter effects (blur, brightness, etc.) */ stripFilters: boolean; + /** Skip box-shadow rendering */ stripBoxShadows: boolean; + /** Skip CSS mask-image */ stripMaskImage: boolean; + /** Skip text-shadow filters */ stripTextShadows: boolean; + /** No style= attributes on SVG elements */ avoidStyleAttributes: boolean; + /** No xml:space="preserve" on elements */ stripXmlSpace: boolean; + /** Skip group-level opacity (prevents Inkscape from rasterizing subtrees) */ stripGroupOpacity: boolean; + /** Avoid transform attributes inside (Inkscape ignores them) */ inlineClipPathTransforms: boolean; + /** Convert nested to with translate (Inkscape clips overflow:visible) */ flattenNestedSvg: boolean; } -/** SVG compatibility preset */ +/** SVG compatibility preset: 'full' (default), 'inkscape' (LaTeX/Inkscape safe), or custom config */ type SvgCompat = 'full' | 'inkscape' | SvgCompatConfig; /** Options for domToSvg() */ interface DomToSvgOptions { diff --git a/src/lib/export/dom2svg/index.js b/src/lib/export/dom2svg/index.js index 1fc77f9e..09fd58d5 100644 --- a/src/lib/export/dom2svg/index.js +++ b/src/lib/export/dom2svg/index.js @@ -2006,19 +2006,193 @@ function extractRotationDeg(transform) { return Math.atan2(b, a) * (180 / Math.PI); } +// src/assets/fonts.ts +var FONT_TIMEOUT_MS = 1e4; +function createFontCache(mapping) { + const cache = /* @__PURE__ */ new Map(); + let opentypeModule = null; + async function loadOpentype() { + if (opentypeModule) return opentypeModule; + opentypeModule = await import("opentype.js"); + return opentypeModule; + } + function getKey(family, weight, style) { + return `${family}|${weight ?? "normal"}|${style ?? "normal"}`; + } + function normalizeWeight(w) { + if (w === void 0 || w === "normal") return 400; + if (w === "bold") return 700; + return typeof w === "string" ? parseInt(w, 10) || 400 : w; + } + function normalizeStyle(s) { + return s === "italic" || s === "oblique" ? "italic" : "normal"; + } + function findConfig(family, weight, style) { + const entry = mapping[family]; + if (!entry) return null; + if (typeof entry === "string") { + return { url: entry }; + } + if (!Array.isArray(entry)) { + return entry; + } + const targetWeight = normalizeWeight(weight); + const targetStyle = normalizeStyle(style); + let best = null; + let bestScore = -1; + for (const cfg of entry) { + let score = 0; + if (normalizeStyle(cfg.style) === targetStyle) score += 2; + if (normalizeWeight(cfg.weight) === targetWeight) score += 1; + if (score > bestScore) { + bestScore = score; + best = cfg; + } + } + return best ?? entry[0] ?? null; + } + return { + async getFont(family, weight, style) { + const key = getKey(family, weight, style); + if (cache.has(key)) { + return cache.get(key); + } + const config = findConfig(family, weight, style); + if (!config) return null; + const opentype = await loadOpentype(); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FONT_TIMEOUT_MS); + const response = await fetch(config.url, { signal: controller.signal }); + clearTimeout(timer); + const buffer = await response.arrayBuffer(); + const font = opentype.parse(buffer); + cache.set(key, font); + return font; + } catch (err) { + console.warn(`dom2svg: Failed to load font "${family}" from ${config.url}:`, err); + return null; + } + }, + has(family) { + return family in mapping; + } + }; +} +function textToPath(font, text, x, y, fontSize) { + try { + const path = font.getPath(text, x, y, fontSize); + return path.toPathData(2); + } catch { + return null; + } +} +function cleanFontFamily(fontFamily) { + const first = fontFamily.split(",")[0]?.trim() ?? fontFamily; + return first.replace(/^["']|["']$/g, ""); +} + +// src/renderers/svg-presentation.ts +var COMMON_PROPERTIES = [ + ["fill", "rgb(0, 0, 0)"], + ["stroke", "none"], + ["fill-opacity", "1"], + ["fill-rule", "nonzero"], + ["clip-rule", "nonzero"], + ["paint-order", "normal"], + ["shape-rendering", "auto"], + ["vector-effect", "none"], + ["visibility", "visible"], + ["marker-start", "none"], + ["marker-mid", "none"], + ["marker-end", "none"] +]; +var STROKE_PROPERTIES = [ + ["stroke-width", "1"], + ["stroke-opacity", "1"], + ["stroke-linecap", "butt"], + ["stroke-linejoin", "miter"], + ["stroke-miterlimit", "4"], + ["stroke-dasharray", "none"], + ["stroke-dashoffset", "0"] +]; +var TEXT_PROPERTIES = [ + ["font-weight", "400"], + ["font-style", "normal"], + ["font-variant", "normal"], + ["letter-spacing", "normal"], + ["word-spacing", "normal"], + ["text-anchor", "start"], + ["dominant-baseline", "auto"], + // The `text-decoration` shorthand computes to a value like + // "underline solid rgb(0, 0, 0)", which no SVG attribute accepts + ["text-decoration-line", "none"] +]; +var ATTRIBUTE_NAMES = { + "text-decoration-line": "text-decoration" +}; +var TEXT_ELEMENTS = /* @__PURE__ */ new Set(["text", "tspan", "textPath", "tref", "altGlyph"]); +var LENGTH_PROPERTIES = /* @__PURE__ */ new Set([ + "stroke-width", + "stroke-dasharray", + "stroke-dashoffset", + "font-size", + "letter-spacing", + "word-spacing" +]); +function normalize(property, value) { + const trimmed = value.trim().replace(/\s+/g, " "); + if (!LENGTH_PROPERTIES.has(property)) return trimmed; + return trimmed.replace(/(-?[\d.]+)px/g, "$1"); +} +function isTextContentElement(localName) { + return TEXT_ELEMENTS.has(localName); +} +function collectSvgPresentationAttributes(styles, localName) { + const attributes = {}; + function take(property, initial) { + const raw = styles.getPropertyValue(property); + if (!raw) return; + const value = normalize(property, raw); + if (!value || initial !== null && value === initial) return; + attributes[ATTRIBUTE_NAMES[property] ?? property] = value; + } + for (const [property, initial] of COMMON_PROPERTIES) { + take(property, initial); + } + const stroke = styles.getPropertyValue("stroke").trim(); + if (stroke && stroke !== "none") { + for (const [property, initial] of STROKE_PROPERTIES) { + take(property, initial); + } + } + if (isTextContentElement(localName)) { + take("font-family", null); + take("font-size", null); + for (const [property, initial] of TEXT_PROPERTIES) { + take(property, initial); + } + } + return attributes; +} + // src/renderers/svg-element.ts -function renderSvgElement(element, ctx) { +async function renderSvgElement(element, ctx) { const computedColor = window.getComputedStyle(element).color || "rgb(0, 0, 0)"; - const clone = cloneWithNamespace(element, ctx); + const clone = await cloneWithNamespace(element, ctx); resolveCurrentColor(clone, computedColor); rewriteIds(clone, ctx); return clone; } -function cloneWithNamespace(node, ctx, resolveDepth = 0) { +async function cloneWithNamespace(node, ctx, resolveDepth = 0) { if (node.localName === "use" && resolveDepth < 5) { - const resolved = resolveUseElement(node, ctx, resolveDepth); + const resolved = await resolveUseElement(node, ctx, resolveDepth); if (resolved) return resolved; } + if (node.localName === "text") { + const outlined = await textElementToPath(node, ctx); + if (outlined) return outlined; + } const flattenSvg = ctx.compat.flattenNestedSvg && node.localName === "svg" && node.ownerSVGElement !== null && !node.getAttribute("viewBox"); const clone = ctx.svgDocument.createElementNS( node.namespaceURI || SVG_NS, @@ -2051,14 +2225,14 @@ function cloneWithNamespace(node, ctx, resolveDepth = 0) { inlineSvgPresentationStyles(node, clone, ctx); for (const child of Array.from(node.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE) { - clone.appendChild(cloneWithNamespace(child, ctx, resolveDepth)); + clone.appendChild(await cloneWithNamespace(child, ctx, resolveDepth)); } else if (child.nodeType === Node.TEXT_NODE) { clone.appendChild(ctx.svgDocument.createTextNode(child.textContent || "")); } } return clone; } -function resolveUseElement(useEl, ctx, resolveDepth) { +async function resolveUseElement(useEl, ctx, resolveDepth) { const href = useEl.getAttribute("href") || useEl.getAttributeNS(XLINK_NS, "href"); if (!href || !href.startsWith("#")) return null; const refId = href.slice(1); @@ -2093,27 +2267,21 @@ function resolveUseElement(useEl, ctx, resolveDepth) { wrapper.setAttribute("overflow", "hidden"); for (const child of Array.from(refEl.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE) { - wrapper.appendChild(cloneWithNamespace(child, ctx, resolveDepth + 1)); + wrapper.appendChild(await cloneWithNamespace(child, ctx, resolveDepth + 1)); } } group.appendChild(wrapper); } else { - group.appendChild(cloneWithNamespace(refEl, ctx, resolveDepth + 1)); + group.appendChild(await cloneWithNamespace(refEl, ctx, resolveDepth + 1)); } return group; } function inlineSvgPresentationStyles(source, clone, ctx) { const styles = window.getComputedStyle(source); - if (!clone.hasAttribute("fill")) { - const fill = styles.fill; - if (fill && fill !== "rgb(0, 0, 0)") { - clone.setAttribute("fill", fill); - } - } - if (!clone.hasAttribute("stroke")) { - const stroke = styles.stroke; - if (stroke && stroke !== "none") { - clone.setAttribute("stroke", stroke); + const presentation = collectSvgPresentationAttributes(styles, source.localName); + for (const [name, value] of Object.entries(presentation)) { + if (!clone.hasAttribute(name)) { + clone.setAttribute(name, value); } } if (!clone.hasAttribute("opacity")) { @@ -2125,6 +2293,88 @@ function inlineSvgPresentationStyles(source, clone, ctx) { } } } +var TEXT_PAINT_ATTRIBUTES = [ + "fill", + "fill-opacity", + "fill-rule", + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-linecap", + "stroke-linejoin", + "stroke-dasharray", + "paint-order", + "visibility" +]; +async function textElementToPath(node, ctx) { + if (!ctx.options.textToPath || !ctx.fontCache) return null; + if (node.children.length > 0) return null; + const text = node.textContent ?? ""; + if (!text.trim()) return null; + const styles = window.getComputedStyle(node); + const fontFamily = cleanFontFamily(styles.fontFamily); + if (!ctx.fontCache.has(fontFamily)) return null; + const spacing = [styles.letterSpacing, styles.wordSpacing]; + if (spacing.some((value) => value && value !== "normal" && parseFloat(value) !== 0)) { + return null; + } + const font = await ctx.fontCache.getFont(fontFamily, styles.fontWeight, styles.fontStyle); + if (!font) return null; + const fontSize = parseFloat(styles.fontSize) || 16; + const origin = glyphOrigin(node, font, fontSize); + if (!origin) return null; + const pathData = textToPath(font, text, origin.x, origin.y, fontSize); + if (!pathData) return null; + const presentation = collectSvgPresentationAttributes(styles, node.localName); + const paint = { fill: styles.fill || "rgb(0, 0, 0)" }; + for (const name of TEXT_PAINT_ATTRIBUTES) { + const value = presentation[name]; + if (value) paint[name] = value; + } + const outlined = paintsStrokeFirst(paint) ? haloedGlyphPaths(pathData, paint, ctx) : glyphPath(pathData, paint, ctx); + const transform = node.getAttribute("transform"); + if (transform) outlined.setAttribute("transform", transform); + return outlined; +} +function glyphOrigin(node, font, fontSize) { + const unitsPerEm = font?.unitsPerEm; + const ascender = font?.ascender; + if (!unitsPerEm || typeof ascender !== "number") return null; + try { + const start = node.getStartPositionOfChar(0); + const box = node.getBBox(); + return { x: start.x, y: box.y + ascender / unitsPerEm * fontSize }; + } catch { + return null; + } +} +function paintsStrokeFirst(paint) { + const order = paint["paint-order"]; + if (!order || !paint.stroke || paint.stroke === "none") return false; + return /^(markers\s+)?stroke\b/.test(order); +} +function glyphPath(pathData, paint, ctx) { + const path = ctx.svgDocument.createElementNS(SVG_NS, "path"); + path.setAttribute("d", pathData); + for (const [name, value] of Object.entries(paint)) { + path.setAttribute(name, value); + } + return path; +} +function haloedGlyphPaths(pathData, paint, ctx) { + const halo = { fill: "none" }; + const glyphs = {}; + for (const [name, value] of Object.entries(paint)) { + if (name === "paint-order") continue; + if (name.startsWith("stroke")) halo[name] = value; + else glyphs[name] = value; + } + if (paint.visibility) halo.visibility = paint.visibility; + const group = ctx.svgDocument.createElementNS(SVG_NS, "g"); + group.appendChild(glyphPath(pathData, halo, ctx)); + group.appendChild(glyphPath(pathData, glyphs, ctx)); + return group; +} function rewriteIds(root, ctx) { const idMap = /* @__PURE__ */ new Map(); const allElements = root.querySelectorAll("[id]"); @@ -2192,92 +2442,6 @@ function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -// src/assets/fonts.ts -var FONT_TIMEOUT_MS = 1e4; -function createFontCache(mapping) { - const cache = /* @__PURE__ */ new Map(); - let opentypeModule = null; - async function loadOpentype() { - if (opentypeModule) return opentypeModule; - opentypeModule = await import("opentype.js"); - return opentypeModule; - } - function getKey(family, weight, style) { - return `${family}|${weight ?? "normal"}|${style ?? "normal"}`; - } - function normalizeWeight(w) { - if (w === void 0 || w === "normal") return 400; - if (w === "bold") return 700; - return typeof w === "string" ? parseInt(w, 10) || 400 : w; - } - function normalizeStyle(s) { - return s === "italic" || s === "oblique" ? "italic" : "normal"; - } - function findConfig(family, weight, style) { - const entry = mapping[family]; - if (!entry) return null; - if (typeof entry === "string") { - return { url: entry }; - } - if (!Array.isArray(entry)) { - return entry; - } - const targetWeight = normalizeWeight(weight); - const targetStyle = normalizeStyle(style); - let best = null; - let bestScore = -1; - for (const cfg of entry) { - let score = 0; - if (normalizeStyle(cfg.style) === targetStyle) score += 2; - if (normalizeWeight(cfg.weight) === targetWeight) score += 1; - if (score > bestScore) { - bestScore = score; - best = cfg; - } - } - return best ?? entry[0] ?? null; - } - return { - async getFont(family, weight, style) { - const key = getKey(family, weight, style); - if (cache.has(key)) { - return cache.get(key); - } - const config = findConfig(family, weight, style); - if (!config) return null; - const opentype = await loadOpentype(); - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FONT_TIMEOUT_MS); - const response = await fetch(config.url, { signal: controller.signal }); - clearTimeout(timer); - const buffer = await response.arrayBuffer(); - const font = opentype.parse(buffer); - cache.set(key, font); - return font; - } catch (err) { - console.warn(`dom2svg: Failed to load font "${family}" from ${config.url}:`, err); - return null; - } - }, - has(family) { - return family in mapping; - } - }; -} -function textToPath(font, text, x, y, fontSize) { - try { - const path = font.getPath(text, x, y, fontSize); - return path.toPathData(2); - } catch { - return null; - } -} -function cleanFontFamily(fontFamily) { - const first = fontFamily.split(",")[0]?.trim() ?? fontFamily; - return first.replace(/^["']|["']$/g, ""); -} - // src/assets/text-shadow.ts function parseTextShadows(value) { if (!value || value === "none") return []; @@ -2672,7 +2836,7 @@ async function walkElement(element, rootElement, ctx) { } if (isSvgElement(element) && element !== rootElement) { const box = getRelativeBox(element, rootElement); - const clone = renderSvgElement(element, ctx); + const clone = await renderSvgElement(element, ctx); if (element.tagName.toLowerCase() === "svg") { clone.setAttribute("x", String(box.x)); clone.setAttribute("y", String(box.y)); diff --git a/src/lib/export/dom2svg/index.js.map b/src/lib/export/dom2svg/index.js.map index 6a16a8ba..731fd481 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/compat.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, ctx.compat.inlineClipPathTransforms);\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\n/**\r\n * Translate all absolute coordinates in SVG path data by (dx, dy).\r\n * Handles M, L, C, S, Q, T, A commands and their lowercase (relative) forms.\r\n * Relative commands (lowercase) are left unchanged since they're already relative.\r\n */\r\nfunction translatePathData(d: string, dx: number, dy: number): string {\r\n // Tokenize: split on command letters, keeping them\r\n return d.replace(/([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)/g, (_, cmd: string, args: string) => {\r\n const nums = args.match(/-?\\d*\\.?\\d+(?:e[+-]?\\d+)?/gi)?.map(Number) ?? [];\r\n if (nums.length === 0) return cmd + args;\r\n\r\n switch (cmd) {\r\n case \"M\": case \"L\": case \"T\":\r\n // Pairs of (x, y)\r\n for (let i = 0; i < nums.length - 1; i += 2) {\r\n nums[i] += dx;\r\n nums[i + 1] += dy;\r\n }\r\n break;\r\n case \"H\":\r\n for (let i = 0; i < nums.length; i++) nums[i] += dx;\r\n break;\r\n case \"V\":\r\n for (let i = 0; i < nums.length; i++) nums[i] += dy;\r\n break;\r\n case \"C\":\r\n // 6 values: (x1,y1, x2,y2, x,y)\r\n for (let i = 0; i < nums.length - 1; i += 2) {\r\n nums[i] += dx;\r\n nums[i + 1] += dy;\r\n }\r\n break;\r\n case \"S\": case \"Q\":\r\n // 4 values: (x1,y1, x,y) or (x2,y2, x,y)\r\n for (let i = 0; i < nums.length - 1; i += 2) {\r\n nums[i] += dx;\r\n nums[i + 1] += dy;\r\n }\r\n break;\r\n case \"A\":\r\n // 7 values: (rx, ry, angle, largeArc, sweep, x, y) — only translate x,y\r\n for (let i = 0; i < nums.length; i += 7) {\r\n if (i + 5 < nums.length) nums[i + 5] += dx;\r\n if (i + 6 < nums.length) nums[i + 6] += dy;\r\n }\r\n break;\r\n // Relative commands (lowercase) — leave unchanged\r\n default:\r\n return cmd + args;\r\n }\r\n return cmd + nums.join(\" \");\r\n });\r\n}\r\n\r\nfunction shapeToSvg(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n inlineTransforms: boolean = false,\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 if (inlineTransforms && (box.x !== 0 || box.y !== 0)) {\r\n // Pre-translate path data to avoid transform attribute inside \r\n // (Inkscape ignores transforms on elements inside )\r\n path.setAttribute(\"d\", translatePathData(shape.d, box.x, box.y));\r\n } else {\r\n path.setAttribute(\"d\", shape.d);\r\n path.setAttribute(\"transform\", `translate(${box.x}, ${box.y})`);\r\n }\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 let box = getRelativeBox(element, rootElement);\r\n\r\n // When flattenTransforms is enabled, getBoundingClientRect returns the\r\n // axis-aligned bounding box which loses rotated shapes (e.g. a CSS\r\n // `transform: rotate(45deg)` diamond appears as a rectangle).\r\n // Detect rotation and recover the pre-rotation dimensions so that\r\n // visual shapes (background, borders) render at the correct size,\r\n // then wrap them in a rotated SVG group.\r\n let visualTransform: string | null = null;\r\n if (ctx.options.flattenTransforms && styles.transform && styles.transform !== \"none\") {\r\n const angle = extractRotationDeg(styles.transform);\r\n if (Math.abs(angle) > 0.5) {\r\n const el = element as HTMLElement;\r\n const preW = el.offsetWidth;\r\n const preH = el.offsetHeight;\r\n if (preW > 0 && preH > 0 && (Math.abs(preW - box.width) > 1 || Math.abs(preH - box.height) > 1)) {\r\n const cx = box.x + box.width / 2;\r\n const cy = box.y + box.height / 2;\r\n box = {\r\n x: cx - preW / 2,\r\n y: cy - preH / 2,\r\n width: preW,\r\n height: preH,\r\n };\r\n visualTransform = `rotate(${angle.toFixed(2)}, ${cx.toFixed(2)}, ${cy.toFixed(2)})`;\r\n }\r\n }\r\n }\r\n\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 (!ctx.compat.stripFilters && 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 if (!ctx.compat.stripBoxShadows) {\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\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 (,