diff --git a/package-lock.json b/package-lock.json index c763a75..11afa6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,23 @@ "vite": "^7.3.0" } }, + "../dom2svg": { + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "opentype.js": "^1.3.4" + }, + "bin": { + "dom2svg": "bin/dom2svg.mjs" + }, + "devDependencies": { + "puppeteer-core": "^24.37.3", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^2.0.0" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -1399,7 +1416,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } diff --git a/package.json b/package.json index 0a9444b..39786f9 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", - "katex": "^0.16.0", +"katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", "pyodide": "^0.26.0" diff --git a/src/lib/export/dom2svg/index.d.ts b/src/lib/export/dom2svg/index.d.ts new file mode 100644 index 0000000..3f33283 --- /dev/null +++ b/src/lib/export/dom2svg/index.d.ts @@ -0,0 +1,75 @@ +/** Configuration for a single font face */ +interface FontConfig { + url: string; + weight?: string | number; + style?: string; +} +/** Font mapping: family name → URL string, single config, or array of configs for multiple weights/styles */ +type FontMapping = Record; +/** Options for domToSvg() */ +interface DomToSvgOptions { + /** Map of font-family → URL or FontConfig for text-to-path conversion */ + fonts?: FontMapping; + /** CSS selector or predicate to exclude elements */ + exclude?: string | ((element: Element) => boolean); + /** Custom handler for specific elements — return SVGElement to use it, or null to fall through to default rendering */ + handler?: (element: Element, context: RenderContext) => SVGElement | null; + /** Background color for the root SVG (default: transparent) */ + background?: string; + /** Extra padding around the captured area in px */ + padding?: number; + /** Whether to convert text to paths using opentype.js (default: false) */ + textToPath?: boolean; + /** Skip applying CSS transforms as SVG attributes (default: false). + * When true, element positions come solely from getBoundingClientRect + * which already includes CSS transforms. Use this when capturing containers + * with nested CSS transforms (e.g. SvelteFlow, React Flow) where + * the default behaviour would double-apply transforms. */ + flattenTransforms?: boolean; +} +/** Internal render context passed through the tree */ +interface RenderContext { + /** The output SVG document */ + svgDocument: Document; + /** The element for shared definitions */ + defs: SVGDefsElement; + /** ID generator for unique IDs */ + idGenerator: IdGenerator; + /** Options from the caller */ + options: DomToSvgOptions; + /** Font cache (available when textToPath is enabled) */ + fontCache?: FontCache; + /** Current inherited opacity */ + opacity: number; +} +/** Interface for unique ID generation */ +interface IdGenerator { + next(prefix?: string): string; +} +/** Interface for the font cache */ +interface FontCache { + getFont(family: string, weight?: string | number, style?: string): Promise; + has(family: string): boolean; +} +/** Result of domToSvg() */ +interface DomToSvgResult { + /** The generated SVG element */ + svg: SVGSVGElement; + /** Serialize to SVG string */ + toString(): string; + /** Serialize to a Blob */ + toBlob(): Blob; + /** Trigger a download in the browser */ + download(filename?: string): void; +} + +/** + * Convert a DOM element (including hybrid HTML/SVG) to a self-contained SVG. + * + * @param element - The root DOM element to convert + * @param options - Configuration options + * @returns A result object with the SVG and serialization helpers + */ +declare function domToSvg(element: Element, options?: DomToSvgOptions): Promise; + +export { type DomToSvgOptions, type DomToSvgResult, type FontConfig, type FontMapping, domToSvg }; diff --git a/src/lib/export/dom2svg/index.js b/src/lib/export/dom2svg/index.js new file mode 100644 index 0000000..50a5cb1 --- /dev/null +++ b/src/lib/export/dom2svg/index.js @@ -0,0 +1,2730 @@ +// src/utils/dom.ts +var SVG_NS = "http://www.w3.org/2000/svg"; +var XLINK_NS = "http://www.w3.org/1999/xlink"; +var XMLNS_NS = "http://www.w3.org/2000/xmlns/"; +function isElement(node) { + return node.nodeType === Node.ELEMENT_NODE; +} +function isTextNode(node) { + return node.nodeType === Node.TEXT_NODE; +} +function isSvgElement(element) { + return element.namespaceURI === SVG_NS; +} +function isImageElement(element) { + return element instanceof HTMLImageElement; +} +function isCanvasElement(element) { + return element instanceof HTMLCanvasElement; +} +function isFormElement(element) { + return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement; +} +function createSvgElement(doc, tagName) { + return doc.createElementNS(SVG_NS, tagName); +} +function setAttributes(element, attrs) { + for (const [key, value] of Object.entries(attrs)) { + element.setAttribute(key, String(value)); + if (key === "href") { + element.setAttributeNS(XLINK_NS, "xlink:href", String(value)); + } + } +} +function getPseudoStyles(element, pseudo) { + return window.getComputedStyle(element, pseudo); +} + +// src/utils/id-generator.ts +var globalCounter = 0; +function createIdGenerator() { + return { + next(prefix = "d2s") { + return `${prefix}-${globalCounter++}`; + } + }; +} + +// src/core/styles.ts +function isInvisible(styles) { + return styles.display === "none"; +} +function isVisibilityHidden(styles) { + return styles.visibility === "hidden"; +} +function parseBorderSide(width, style, color) { + return { + width: parseFloat(width) || 0, + style, + color + }; +} +function parseBorders(styles) { + return { + top: parseBorderSide( + styles.borderTopWidth, + styles.borderTopStyle, + styles.borderTopColor + ), + right: parseBorderSide( + styles.borderRightWidth, + styles.borderRightStyle, + styles.borderRightColor + ), + bottom: parseBorderSide( + styles.borderBottomWidth, + styles.borderBottomStyle, + styles.borderBottomColor + ), + left: parseBorderSide( + styles.borderLeftWidth, + styles.borderLeftStyle, + styles.borderLeftColor + ) + }; +} +function parseBorderRadii(styles) { + return { + topLeft: parseRadiusPair(styles.borderTopLeftRadius), + topRight: parseRadiusPair(styles.borderTopRightRadius), + bottomRight: parseRadiusPair(styles.borderBottomRightRadius), + bottomLeft: parseRadiusPair(styles.borderBottomLeftRadius) + }; +} +function parseRadiusPair(value) { + const parts = value.split(/\s+/).map((v) => parseFloat(v) || 0); + return [parts[0] ?? 0, parts[1] ?? parts[0] ?? 0]; +} +function hasBorder(borders) { + return borders.top.width > 0 && borders.top.style !== "none" || borders.right.width > 0 && borders.right.style !== "none" || borders.bottom.width > 0 && borders.bottom.style !== "none" || borders.left.width > 0 && borders.left.style !== "none"; +} +function hasRadius(radii) { + return radii.topLeft[0] > 0 || radii.topLeft[1] > 0 || radii.topRight[0] > 0 || radii.topRight[1] > 0 || radii.bottomRight[0] > 0 || radii.bottomRight[1] > 0 || radii.bottomLeft[0] > 0 || radii.bottomLeft[1] > 0; +} +function isUniformRadius(radii) { + const [rx, ry] = radii.topLeft; + return radii.topRight[0] === rx && radii.topRight[1] === ry && radii.bottomRight[0] === rx && radii.bottomRight[1] === ry && radii.bottomLeft[0] === rx && radii.bottomLeft[1] === ry; +} +function hasOverflowClip(styles) { + const clipped = /* @__PURE__ */ new Set(["hidden", "clip", "scroll", "auto"]); + return clipped.has(styles.overflow) || clipped.has(styles.overflowX) || clipped.has(styles.overflowY); +} +function parseBackgroundColor(styles) { + const bg = styles.backgroundColor; + if (!bg || bg === "transparent" || bg === "rgba(0, 0, 0, 0)") return null; + return bg; +} +function hasBackgroundImage(styles) { + return !!styles.backgroundImage && styles.backgroundImage !== "none"; +} +function createsStackingContext(styles) { + if (styles.position !== "static" && styles.position !== "" && styles.zIndex !== "auto") { + return true; + } + if (parseFloat(styles.opacity) < 1) return true; + if (styles.transform && styles.transform !== "none") return true; + if (styles.filter && styles.filter !== "none") return true; + if (styles.isolation === "isolate") return true; + if (styles.mixBlendMode && styles.mixBlendMode !== "normal") return true; + return false; +} +function getZIndex(styles) { + if (styles.zIndex === "auto" || !styles.zIndex) return 0; + return parseInt(styles.zIndex, 10) || 0; +} +function isPositioned(styles) { + return styles.position !== "static" && styles.position !== ""; +} +function isFloat(styles) { + return styles.cssFloat !== "none" && styles.cssFloat !== ""; +} +function clampRadii(radii, width, height) { + const topH = radii.topLeft[0] + radii.topRight[0]; + const bottomH = radii.bottomLeft[0] + radii.bottomRight[0]; + const leftV = radii.topLeft[1] + radii.bottomLeft[1]; + const rightV = radii.topRight[1] + radii.bottomRight[1]; + let f = 1; + if (topH > 0) f = Math.min(f, width / topH); + if (bottomH > 0) f = Math.min(f, width / bottomH); + if (leftV > 0) f = Math.min(f, height / leftV); + if (rightV > 0) f = Math.min(f, height / rightV); + if (f >= 1) return radii; + return { + topLeft: [radii.topLeft[0] * f, radii.topLeft[1] * f], + topRight: [radii.topRight[0] * f, radii.topRight[1] * f], + bottomRight: [radii.bottomRight[0] * f, radii.bottomRight[1] * f], + bottomLeft: [radii.bottomLeft[0] * f, radii.bottomLeft[1] * f] + }; +} +function isInlineLevel(styles) { + const d = styles.display; + return d === "inline" || d === "inline-block" || d === "inline-flex" || d === "inline-grid" || d === "inline-table"; +} + +// src/utils/geometry.ts +function getRelativeBox(element, root) { + const elRect = element.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + return { + x: elRect.left - rootRect.left, + y: elRect.top - rootRect.top, + width: elRect.width, + height: elRect.height + }; +} +function buildRoundedRectPath(x, y, width, height, radii) { + const [tlx, tly] = radii.topLeft; + const [trx, try_] = radii.topRight; + const [brx, bry] = radii.bottomRight; + const [blx, bly] = radii.bottomLeft; + return [ + `M ${x + tlx} ${y}`, + `L ${x + width - trx} ${y}`, + trx || try_ ? `A ${trx} ${try_} 0 0 1 ${x + width} ${y + try_}` : "", + `L ${x + width} ${y + height - bry}`, + brx || bry ? `A ${brx} ${bry} 0 0 1 ${x + width - brx} ${y + height}` : "", + `L ${x + blx} ${y + height}`, + blx || bly ? `A ${blx} ${bly} 0 0 1 ${x} ${y + height - bly}` : "", + `L ${x} ${y + tly}`, + tlx || tly ? `A ${tlx} ${tly} 0 0 1 ${x + tlx} ${y}` : "", + "Z" + ].filter(Boolean).join(" "); +} + +// src/assets/gradients.ts +function parseLinearGradient(value) { + const match = value.match(/linear-gradient\((.+)\)/); + if (!match) return null; + const body = match[1]; + const parts = splitGradientArgs(body); + if (parts.length < 2) return null; + let angle = 180; + let stopsStart = 0; + const first = parts[0].trim(); + if (first.startsWith("to ")) { + angle = directionToAngle(first); + stopsStart = 1; + } else if (first.match(/^-?[\d.]+(?:deg|rad|turn|grad)/)) { + angle = parseAngle(first); + stopsStart = 1; + } + const stops = []; + const rawStops = parts.slice(stopsStart); + for (let i = 0; i < rawStops.length; i++) { + const { color, position } = parseColorStop(rawStops[i].trim(), i, rawStops.length); + stops.push({ color, position }); + } + return { angle, stops }; +} +function createSvgLinearGradient(gradient, box, ctx) { + const id = ctx.idGenerator.next("grad"); + const el = createSvgElement( + ctx.svgDocument, + "linearGradient" + ); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + const angleRad = gradient.angle * Math.PI / 180; + const dx = Math.sin(angleRad); + const dy = -Math.cos(angleRad); + const halfLen = Math.abs(box.width / 2 * dx) + Math.abs(box.height / 2 * dy); + const x1 = cx - dx * halfLen; + const y1 = cy - dy * halfLen; + const x2 = cx + dx * halfLen; + const y2 = cy + dy * halfLen; + setAttributes(el, { + id, + gradientUnits: "userSpaceOnUse", + x1: x1.toFixed(2), + y1: y1.toFixed(2), + x2: x2.toFixed(2), + y2: y2.toFixed(2) + }); + for (const stop of gradient.stops) { + const stopEl = createSvgElement(ctx.svgDocument, "stop"); + setAttributes(stopEl, { + offset: `${(stop.position * 100).toFixed(1)}%`, + "stop-color": stop.color + }); + el.appendChild(stopEl); + } + ctx.defs.appendChild(el); + return el; +} +function rasterizeGradient(value, width, height) { + if (value.includes("conic-gradient")) { + return rasterizeConicGradient(value, width, height); + } + if (value.includes("radial-gradient")) { + return rasterizeRadialGradient(value, width, height); + } + return null; +} +function rasterizeConicGradient(value, width, height) { + const match = value.match(/conic-gradient\((.+)\)/); + if (!match) return null; + const scale2 = 2; + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(width * scale2); + canvas.height = Math.ceil(height * scale2); + const ctx = canvas.getContext("2d"); + if (!ctx || !("createConicGradient" in ctx)) return null; + ctx.scale(scale2, scale2); + const body = match[1]; + const parts = splitGradientArgs(body); + let startDeg = 0; + let stopsStart = 0; + const first = parts[0].trim(); + const fromMatch = first.match(/^from\s+(-?[\d.]+)(deg|rad|turn|grad)/); + if (fromMatch) { + startDeg = parseAngle(fromMatch[1] + fromMatch[2]); + stopsStart = 1; + } + const cx = width / 2; + const cy = height / 2; + const startRad = (startDeg - 90) * Math.PI / 180; + const gradient = ctx.createConicGradient(startRad, cx, cy); + const rawStops = parts.slice(stopsStart); + for (let i = 0; i < rawStops.length; i++) { + const stop = rawStops[i].trim(); + const { color, position } = parseColorStop(stop, i, rawStops.length); + try { + gradient.addColorStop(position, color); + } catch { + } + } + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + return canvas.toDataURL("image/png"); +} +function rasterizeRadialGradient(value, width, height) { + const match = value.match(/radial-gradient\((.+)\)/); + if (!match) return null; + const scale2 = 2; + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(width * scale2); + canvas.height = Math.ceil(height * scale2); + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + ctx.scale(scale2, scale2); + const body = match[1]; + const parts = splitGradientArgs(body); + let isCircle = false; + let stopsStart = 0; + let customCx = null; + let customCy = null; + const first = parts[0].trim(); + if (first === "circle" || first.startsWith("circle ")) { + isCircle = true; + stopsStart = 1; + } else if (first === "ellipse" || first.startsWith("ellipse ")) { + stopsStart = 1; + } else if (first.includes("at ") && !first.includes("#") && !first.match(/^(rgb|hsl)/)) { + stopsStart = 1; + } + if (stopsStart === 1) { + const atMatch = first.match(/at\s+(.+)/); + if (atMatch) { + const posParts = atMatch[1].trim().split(/\s+/); + customCx = parseLengthOrPercent(posParts[0], width); + customCy = parseLengthOrPercent(posParts[1] ?? posParts[0], height); + } + } + const cx = customCx ?? width / 2; + const cy = customCy ?? height / 2; + const rx = width / 2; + const ry = height / 2; + const radius = isCircle ? Math.sqrt(rx * rx + ry * ry) : Math.max(rx, ry); + ctx.save(); + if (!isCircle && rx !== ry) { + ctx.translate(cx, cy); + ctx.scale(rx / radius, ry / radius); + ctx.translate(-cx, -cy); + } + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + const rawStops = parts.slice(stopsStart); + for (let i = 0; i < rawStops.length; i++) { + const stop = rawStops[i].trim(); + const { color, position } = parseColorStop(stop, i, rawStops.length); + try { + gradient.addColorStop(position, color); + } catch { + } + } + ctx.fillStyle = gradient; + if (!isCircle && rx !== ry) { + const sx = radius / rx; + const sy = radius / ry; + ctx.fillRect(cx * (1 - sx), cy * (1 - sy), width * sx, height * sy); + } else { + ctx.fillRect(0, 0, width, height); + } + ctx.restore(); + return canvas.toDataURL("image/png"); +} +function parseColorStop(stop, index, total) { + const lastParen = stop.lastIndexOf(")"); + const tail = lastParen >= 0 ? stop.slice(lastParen + 1) : stop; + const posMatch = tail.match(/\s+([\d.]+%)\s*$/); + if (posMatch) { + const posStr = posMatch[1]; + const colorEnd = stop.length - posMatch[0].length; + return { + color: stop.slice(0, colorEnd).trim(), + position: parseFloat(posStr) / 100 + }; + } + if (lastParen < 0) { + const spaceIdx = stop.lastIndexOf(" "); + if (spaceIdx > 0 && stop.slice(spaceIdx).match(/[\d.]+%/)) { + return { + color: stop.slice(0, spaceIdx).trim(), + position: parseFloat(stop.slice(spaceIdx)) / 100 + }; + } + } + return { + color: stop, + position: total > 1 ? index / (total - 1) : 0 + }; +} +function splitGradientArgs(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} +function directionToAngle(dir) { + const map = { + "to top": 0, + "to right": 90, + "to bottom": 180, + "to left": 270, + "to top right": 45, + "to top left": 315, + "to bottom right": 135, + "to bottom left": 225 + }; + return map[dir] ?? 180; +} +function parseAngle(value) { + if (value.endsWith("deg")) return parseFloat(value); + if (value.endsWith("rad")) return parseFloat(value) * 180 / Math.PI; + if (value.endsWith("turn")) return parseFloat(value) * 360; + if (value.endsWith("grad")) return parseFloat(value) * 0.9; + return parseFloat(value); +} +function parseLengthOrPercent(value, containerSize) { + if (value === "center") return containerSize / 2; + if (value === "left" || value === "top") return 0; + if (value === "right" || value === "bottom") return containerSize; + if (value.endsWith("%")) return parseFloat(value) / 100 * containerSize; + const num = parseFloat(value); + return isNaN(num) ? null : num; +} + +// src/assets/images.ts +var IMAGE_TIMEOUT_MS = 1e4; +var MAX_CANVAS_DIM = 4096; +async function imageToDataUrl(url) { + if (url.startsWith("data:")) return url; + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + const timer = setTimeout(() => { + console.warn(`dom2svg: Image load timed out after ${IMAGE_TIMEOUT_MS}ms, using original URL: ${url}`); + img.onload = null; + img.onerror = null; + resolve(url); + }, IMAGE_TIMEOUT_MS); + img.onload = () => { + clearTimeout(timer); + try { + const canvas = document.createElement("canvas"); + let w = img.naturalWidth; + let h = img.naturalHeight; + if (w > MAX_CANVAS_DIM || h > MAX_CANVAS_DIM) { + const scale2 = MAX_CANVAS_DIM / Math.max(w, h); + w = Math.round(w * scale2); + h = Math.round(h * scale2); + } + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, 0, 0, w, h); + resolve(canvas.toDataURL("image/png")); + } else { + resolve(url); + } + } catch { + console.warn(`dom2svg: CORS prevented inlining image, external URL will remain in SVG: ${url}`); + resolve(url); + } + }; + img.onerror = () => { + clearTimeout(timer); + console.warn(`dom2svg: Failed to load image, external URL will remain in SVG: ${url}`); + resolve(url); + }; + img.src = url; + }); +} +function extractUrlFromCss(value) { + const match = value.match(/url\(["']?([^"')]+)["']?\)/); + return match?.[1] ?? null; +} +function canvasToDataUrl(canvas) { + try { + return canvas.toDataURL("image/png"); + } catch { + return ""; + } +} + +// src/transforms/parse.ts +function parseTransform(value) { + if (!value || value === "none") return []; + const functions = []; + const regex = /(\w+)\(([^)]+)\)/g; + let match; + while ((match = regex.exec(value)) !== null) { + const name = match[1]; + const args = match[2].split(",").map((s) => s.trim()); + switch (name) { + case "matrix": { + const vals = args.map(parseFloat); + if (vals.length === 6) { + functions.push({ + type: "matrix", + values: vals + }); + } + break; + } + case "translate": { + const x = parseLengthValue(args[0]); + const y = args[1] ? parseLengthValue(args[1]) : 0; + functions.push({ type: "translate", x, y }); + break; + } + case "translateX": { + functions.push({ type: "translate", x: parseLengthValue(args[0]), y: 0 }); + break; + } + case "translateY": { + functions.push({ type: "translate", x: 0, y: parseLengthValue(args[0]) }); + break; + } + case "scale": { + const sx = parseFloat(args[0]); + const sy = args[1] ? parseFloat(args[1]) : sx; + functions.push({ type: "scale", x: sx, y: sy }); + break; + } + case "scaleX": { + functions.push({ type: "scale", x: parseFloat(args[0]), y: 1 }); + break; + } + case "scaleY": { + functions.push({ type: "scale", x: 1, y: parseFloat(args[0]) }); + break; + } + case "rotate": { + functions.push({ type: "rotate", angle: parseAngleValue(args[0]) }); + break; + } + case "skewX": { + functions.push({ type: "skewX", angle: parseAngleValue(args[0]) }); + break; + } + case "skewY": { + functions.push({ type: "skewY", angle: parseAngleValue(args[0]) }); + break; + } + } + } + return functions; +} +function parseLengthValue(value) { + return parseFloat(value) || 0; +} +function parseAngleValue(value) { + value = value.trim(); + if (value.endsWith("rad")) return parseFloat(value) * 180 / Math.PI; + if (value.endsWith("turn")) return parseFloat(value) * 360; + if (value.endsWith("grad")) return parseFloat(value) * 0.9; + return parseFloat(value) || 0; +} + +// src/transforms/matrix.ts +function identity() { + return [1, 0, 0, 1, 0, 0]; +} +function multiply(a, b) { + return [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5] + ]; +} +function translate(tx, ty) { + return [1, 0, 0, 1, tx, ty]; +} +function scale(sx, sy) { + return [sx, 0, 0, sy, 0, 0]; +} +function rotate(angleDeg) { + const rad = angleDeg * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return [cos, sin, -sin, cos, 0, 0]; +} +function skewX(angleDeg) { + const rad = angleDeg * Math.PI / 180; + return [1, 0, Math.tan(rad), 1, 0, 0]; +} +function skewY(angleDeg) { + const rad = angleDeg * Math.PI / 180; + return [1, Math.tan(rad), 0, 1, 0, 0]; +} +function isIdentity(m) { + return Math.abs(m[0] - 1) < 1e-10 && Math.abs(m[1]) < 1e-10 && Math.abs(m[2]) < 1e-10 && Math.abs(m[3] - 1) < 1e-10 && Math.abs(m[4]) < 1e-10 && Math.abs(m[5]) < 1e-10; +} +function toSvgTransform(m) { + return `matrix(${m.map((v) => v.toFixed(6)).join(",")})`; +} + +// src/transforms/svg.ts +function cssTransformToSvg(cssTransform, transformOrigin, box) { + const functions = parseTransform(cssTransform); + if (functions.length === 0) return null; + const [ox, oy] = parseTransformOrigin(transformOrigin, box); + let result = identity(); + result = multiply(result, translate(ox, oy)); + for (const fn of functions) { + result = multiply(result, transformFunctionToMatrix(fn)); + } + result = multiply(result, translate(-ox, -oy)); + if (isIdentity(result)) return null; + return toSvgTransform(result); +} +function transformFunctionToMatrix(fn) { + switch (fn.type) { + case "matrix": + return fn.values; + case "translate": + return translate(fn.x, fn.y); + case "scale": + return scale(fn.x, fn.y); + case "rotate": + return rotate(fn.angle); + case "skewX": + return skewX(fn.angle); + case "skewY": + return skewY(fn.angle); + } +} +function parseTransformOrigin(origin, box) { + const parts = origin.split(/\s+/); + const x = parseOriginValue(parts[0] ?? "50%", box.width, box.x); + const y = parseOriginValue(parts[1] ?? "50%", box.height, box.y); + return [x, y]; +} +function parseOriginValue(value, size, offset) { + if (value === "left" || value === "top") return offset; + if (value === "right" || value === "bottom") return offset + size; + if (value === "center") return offset + size / 2; + if (value.endsWith("%")) { + return offset + parseFloat(value) / 100 * size; + } + return offset + parseFloat(value); +} + +// src/assets/filters.ts +function createSvgFilter(filterValue, ctx) { + const functions = parseCssFilterFunctions(filterValue); + if (functions.length === 0) return null; + const id = ctx.idGenerator.next("filter"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + setAttributes(filter, { + id, + x: "-50%", + y: "-50%", + width: "200%", + height: "200%" + }); + let hasAny = false; + for (const fn of functions) { + const primitives = createFilterPrimitives(fn, ctx); + for (const prim of primitives) { + filter.appendChild(prim); + hasAny = true; + } + } + if (!hasAny) return null; + ctx.defs.appendChild(filter); + return id; +} +function parseFilterAmount(raw) { + const trimmed = raw.trim(); + if (trimmed.endsWith("%")) { + return (parseFloat(trimmed) || 0) / 100; + } + return parseFloat(trimmed) || 0; +} +function parseAngle2(raw) { + const trimmed = raw.trim(); + if (trimmed.endsWith("rad")) return (parseFloat(trimmed) || 0) * (180 / Math.PI); + if (trimmed.endsWith("grad")) return (parseFloat(trimmed) || 0) * 0.9; + if (trimmed.endsWith("turn")) return (parseFloat(trimmed) || 0) * 360; + return parseFloat(trimmed) || 0; +} +function createFilterPrimitives(fn, ctx) { + switch (fn.name) { + case "blur": { + const radius = parseFloat(fn.args) || 0; + const blur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(blur, { stdDeviation: radius }); + return [blur]; + } + case "brightness": { + const amount = parseFilterAmount(fn.args); + return [createComponentTransfer(ctx, { slope: amount })]; + } + case "contrast": { + const amount = parseFilterAmount(fn.args); + const intercept = 0.5 - 0.5 * amount; + return [createComponentTransfer(ctx, { slope: amount, intercept })]; + } + case "drop-shadow": { + const parsed = parseDropShadow(`drop-shadow(${fn.args})`); + if (!parsed) return []; + const shadow = createSvgElement(ctx.svgDocument, "feDropShadow"); + setAttributes(shadow, { + dx: parsed.offsetX, + dy: parsed.offsetY, + stdDeviation: parsed.blur / 2, + "flood-color": parsed.color, + "flood-opacity": 1 + }); + return [shadow]; + } + case "grayscale": { + const amount = parseFilterAmount(fn.args); + const s = Math.max(0, Math.min(1, 1 - amount)); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "saturate", values: s }); + return [matrix]; + } + case "hue-rotate": { + const degrees = parseAngle2(fn.args); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "hueRotate", values: degrees }); + return [matrix]; + } + case "invert": { + const amount = parseFilterAmount(fn.args); + const lo = amount; + const hi = 1 - amount; + return [createComponentTransfer(ctx, { + type: "table", + tableValues: `${lo} ${hi}` + })]; + } + case "opacity": { + const amount = parseFilterAmount(fn.args); + const transfer = createSvgElement(ctx.svgDocument, "feComponentTransfer"); + const funcA = createSvgElement(ctx.svgDocument, "feFuncA"); + setAttributes(funcA, { type: "linear", slope: amount, intercept: 0 }); + transfer.appendChild(funcA); + return [transfer]; + } + case "saturate": { + const amount = parseFilterAmount(fn.args); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "saturate", values: amount }); + return [matrix]; + } + case "sepia": { + const amount = Math.max(0, Math.min(1, parseFilterAmount(fn.args))); + const a = amount; + const b = 1 - amount; + const values = [ + b + a * 0.393, + a * 0.769, + a * 0.189, + 0, + 0, + a * 0.349, + b + a * 0.686, + a * 0.168, + 0, + 0, + a * 0.272, + a * 0.534, + b + a * 0.131, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ].map((v) => v.toFixed(4)).join(" "); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "matrix", values }); + return [matrix]; + } + default: + return []; + } +} +function createComponentTransfer(ctx, opts) { + const transfer = createSvgElement(ctx.svgDocument, "feComponentTransfer"); + for (const channel of ["feFuncR", "feFuncG", "feFuncB"]) { + const func = createSvgElement(ctx.svgDocument, channel); + if (opts.type === "table" && opts.tableValues) { + setAttributes(func, { type: "table", tableValues: opts.tableValues }); + } else { + const attrs = { + type: "linear", + slope: opts.slope ?? 1 + }; + if (opts.intercept !== void 0) attrs.intercept = opts.intercept; + setAttributes(func, attrs); + } + transfer.appendChild(func); + } + return transfer; +} +function parseCssFilterFunctions(value) { + const results = []; + const regex = /([a-z-]+)\(/gi; + let match; + while ((match = regex.exec(value)) !== null) { + const name = match[1]; + const argsStart = match.index + match[0].length; + let depth = 1; + let i = argsStart; + for (; i < value.length && depth > 0; i++) { + if (value[i] === "(") depth++; + else if (value[i] === ")") depth--; + } + const args = value.slice(argsStart, i - 1).trim(); + results.push({ name: name.toLowerCase(), args }); + regex.lastIndex = i; + } + return results; +} +function parseDropShadow(value) { + const startIdx = value.indexOf("drop-shadow("); + if (startIdx === -1) return null; + const argsStart = startIdx + "drop-shadow(".length; + let depth = 1; + let argsEnd = argsStart; + for (let i = argsStart; i < value.length && depth > 0; i++) { + if (value[i] === "(") depth++; + else if (value[i] === ")") depth--; + if (depth > 0) argsEnd = i + 1; + } + const args = value.slice(argsStart, argsEnd).trim(); + if (!args) return null; + const parts = []; + let current = ""; + let parenDepth = 0; + for (const char of args) { + if (char === "(") parenDepth++; + else if (char === ")") parenDepth--; + if (char === " " && parenDepth === 0 && current) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + if (parts.length < 2) return null; + const numericParts = []; + let color = "rgba(0,0,0,0.3)"; + for (const part of parts) { + const num = parseFloat(part); + if (!isNaN(num) && (part.endsWith("px") || part.match(/^-?[\d.]+$/))) { + numericParts.push(num); + } else { + color = part; + } + } + return { + offsetX: numericParts[0] ?? 0, + offsetY: numericParts[1] ?? 0, + blur: numericParts[2] ?? 0, + color + }; +} + +// src/assets/box-shadow.ts +function parseBoxShadows(value) { + if (!value || value === "none") return []; + const shadows = []; + const parts = splitTopLevelCommas(value); + for (const part of parts) { + const shadow = parseSingleShadow(part.trim()); + if (shadow) shadows.push(shadow); + } + return shadows; +} +function splitTopLevelCommas(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} +function parseSingleShadow(value) { + let inset = false; + let working = value; + if (working.startsWith("inset ")) { + inset = true; + working = working.slice(6).trim(); + } else if (working.endsWith(" inset")) { + inset = true; + working = working.slice(0, -6).trim(); + } + const tokens = []; + let current = ""; + let depth = 0; + for (const char of working) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === " " && depth === 0 && current) { + tokens.push(current); + current = ""; + } else { + current += char; + } + } + if (current) tokens.push(current); + const numericValues = []; + const colorParts = []; + for (const token of tokens) { + const num = parseFloat(token); + if (!isNaN(num) && (token.endsWith("px") || token.match(/^-?[\d.]+$/))) { + numericValues.push(num); + } else { + colorParts.push(token); + } + } + if (numericValues.length < 2) return null; + return { + inset, + offsetX: numericValues[0], + offsetY: numericValues[1], + blur: numericValues[2] ?? 0, + spread: numericValues[3] ?? 0, + color: colorParts.join(" ") || "rgba(0, 0, 0, 0.3)" + }; +} +function renderBoxShadows(shadows, box, radii, ctx, group) { + for (let i = shadows.length - 1; i >= 0; i--) { + const shadow = shadows[i]; + if (shadow.inset) { + renderInsetShadow(shadow, box, radii, ctx, group); + } else { + renderOuterShadow(shadow, box, radii, ctx, group); + } + } +} +function renderOuterShadow(shadow, box, radii, ctx, group) { + const spreadBox = { + x: box.x + shadow.offsetX - shadow.spread, + y: box.y + shadow.offsetY - shadow.spread, + width: box.width + shadow.spread * 2, + height: box.height + shadow.spread * 2 + }; + const spreadRadii = expandRadii(radii, shadow.spread); + const shape = createShadowShape(spreadBox, spreadRadii, ctx); + shape.setAttribute("fill", shadow.color); + if (shadow.blur > 0) { + const filterId = ctx.idGenerator.next("shadow"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + const margin = shadow.blur * 2 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + shadow.spread; + const safeW = Math.max(spreadBox.width, 1); + const safeH = Math.max(spreadBox.height, 1); + setAttributes(filter, { + id: filterId, + x: `-${(margin / safeW * 100 + 10).toFixed(0)}%`, + y: `-${(margin / safeH * 100 + 10).toFixed(0)}%`, + width: `${(200 + margin / safeW * 200 + 20).toFixed(0)}%`, + height: `${(200 + margin / safeH * 200 + 20).toFixed(0)}%` + }); + const feGaussianBlur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(feGaussianBlur, { + in: "SourceGraphic", + stdDeviation: shadow.blur / 2 + }); + filter.appendChild(feGaussianBlur); + ctx.defs.appendChild(filter); + shape.setAttribute("filter", `url(#${filterId})`); + } + group.insertBefore(shape, group.firstChild); +} +function renderInsetShadow(shadow, box, radii, ctx, group) { + const clipId = ctx.idGenerator.next("inset-clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createShadowShape(box, radii, ctx); + clipPath.appendChild(clipShape); + ctx.defs.appendChild(clipPath); + const innerBox = { + x: box.x + shadow.offsetX + shadow.spread, + y: box.y + shadow.offsetY + shadow.spread, + width: Math.max(0, box.width - shadow.spread * 2), + height: Math.max(0, box.height - shadow.spread * 2) + }; + const innerRadii = expandRadii(radii, -shadow.spread); + const g = createSvgElement(ctx.svgDocument, "g"); + g.setAttribute("clip-path", `url(#${clipId})`); + const outerRect = createSvgElement(ctx.svgDocument, "rect"); + const pad = shadow.blur * 3 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + 100; + setAttributes(outerRect, { + x: box.x - pad, + y: box.y - pad, + width: box.width + pad * 2, + height: box.height + pad * 2, + fill: shadow.color + }); + const innerShape = createShadowShape(innerBox, innerRadii, ctx); + innerShape.setAttribute("fill", shadow.color); + const maskId = ctx.idGenerator.next("inset-mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + const maskWhite = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(maskWhite, { x: box.x - pad, y: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, fill: "white" }); + const maskBlack = createShadowShape(innerBox, innerRadii, ctx); + maskBlack.setAttribute("fill", "black"); + mask.appendChild(maskWhite); + mask.appendChild(maskBlack); + ctx.defs.appendChild(mask); + outerRect.setAttribute("mask", `url(#${maskId})`); + if (shadow.blur > 0) { + const filterId = ctx.idGenerator.next("inset-blur"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + setAttributes(filter, { id: filterId, x: "-50%", y: "-50%", width: "200%", height: "200%" }); + const feBlur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(feBlur, { in: "SourceGraphic", stdDeviation: shadow.blur / 2 }); + filter.appendChild(feBlur); + ctx.defs.appendChild(filter); + outerRect.setAttribute("filter", `url(#${filterId})`); + } + g.appendChild(outerRect); + group.insertBefore(g, group.firstChild); +} +function createShadowShape(box, radii, ctx) { + if (hasRadius(radii) && !isUniformRadius(radii)) { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii)); + return path; + } + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { x: box.x, y: box.y, width: box.width, height: box.height }); + if (hasRadius(radii) && isUniformRadius(radii)) { + setAttributes(rect, { rx: radii.topLeft[0], ry: radii.topLeft[1] }); + } + return rect; +} +function expandRadii(radii, amount) { + return { + topLeft: [Math.max(0, radii.topLeft[0] + amount), Math.max(0, radii.topLeft[1] + amount)], + topRight: [Math.max(0, radii.topRight[0] + amount), Math.max(0, radii.topRight[1] + amount)], + bottomRight: [Math.max(0, radii.bottomRight[0] + amount), Math.max(0, radii.bottomRight[1] + amount)], + bottomLeft: [Math.max(0, radii.bottomLeft[0] + amount), Math.max(0, radii.bottomLeft[1] + amount)] + }; +} + +// src/assets/clip-path.ts +function parseLengthValue2(raw) { + const trimmed = raw.trim(); + if (trimmed.endsWith("%")) { + return { value: parseFloat(trimmed) || 0, isPct: true }; + } + return { value: parseFloat(trimmed) || 0, isPct: false }; +} +function parseClipPath(value) { + if (!value || value === "none") return null; + const insetMatch = value.match(/^inset\((.+)\)$/); + if (insetMatch) return parseInset(insetMatch[1]); + const circleMatch = value.match(/^circle\((.+)\)$/); + if (circleMatch) return parseCircle(circleMatch[1]); + const ellipseMatch = value.match(/^ellipse\((.+)\)$/); + if (ellipseMatch) return parseEllipse(ellipseMatch[1]); + const polygonMatch = value.match(/^polygon\((.+)\)$/); + if (polygonMatch) return parsePolygon(polygonMatch[1]); + const pathMatch = value.match(/^path\(["']?(.+?)["']?\)$/); + if (pathMatch) return { type: "path", d: pathMatch[1] }; + return null; +} +function parseInset(args) { + const roundIdx = args.indexOf(" round "); + let insetPart = args; + let round; + if (roundIdx >= 0) { + insetPart = args.slice(0, roundIdx); + round = args.slice(roundIdx + 7).trim(); + } + const values = insetPart.trim().split(/\s+/).map((v) => parseFloat(v) || 0); + const top = values[0] ?? 0; + const right = values[1] ?? top; + const bottom = values[2] ?? top; + const left = values[3] ?? right; + return { type: "inset", top, right, bottom, left, round }; +} +function parseCircle(args) { + const atIdx = args.indexOf(" at "); + let radius = 0; + let cx = 0; + let cy = 0; + let cxPct = false; + let cyPct = false; + if (atIdx >= 0) { + radius = parseFloat(args.slice(0, atIdx)) || 0; + const center = args.slice(atIdx + 4).trim().split(/\s+/); + const cxVal = parseLengthValue2(center[0]); + const cyVal = parseLengthValue2(center[1]); + cx = cxVal.value; + cxPct = cxVal.isPct; + cy = cyVal.value; + cyPct = cyVal.isPct; + } else { + radius = parseFloat(args) || 0; + cx = 50; + cy = 50; + cxPct = true; + cyPct = true; + } + return { type: "circle", radius, cx, cy, cxPct, cyPct }; +} +function parseEllipse(args) { + const atIdx = args.indexOf(" at "); + let rx = 0; + let ry = 0; + let cx = 0; + let cy = 0; + let cxPct = false; + let cyPct = false; + if (atIdx >= 0) { + const radii = args.slice(0, atIdx).trim().split(/\s+/); + rx = parseFloat(radii[0]) || 0; + ry = parseFloat(radii[1]) || 0; + const center = args.slice(atIdx + 4).trim().split(/\s+/); + const cxVal = parseLengthValue2(center[0]); + const cyVal = parseLengthValue2(center[1]); + cx = cxVal.value; + cxPct = cxVal.isPct; + cy = cyVal.value; + cyPct = cyVal.isPct; + } else { + const parts = args.trim().split(/\s+/); + rx = parseFloat(parts[0]) || 0; + ry = parseFloat(parts[1]) || 0; + cx = 50; + cy = 50; + cxPct = true; + cyPct = true; + } + return { type: "ellipse", rx, ry, cx, cy, cxPct, cyPct }; +} +function parsePolygon(args) { + let cleaned = args.trim(); + if (cleaned.startsWith("nonzero,") || cleaned.startsWith("evenodd,")) { + cleaned = cleaned.slice(cleaned.indexOf(",") + 1).trim(); + } + const points = []; + const pairs = cleaned.split(","); + for (const pair of pairs) { + const parts = pair.trim().split(/\s+/); + if (parts.length >= 2) { + points.push([parseFloat(parts[0]) || 0, parseFloat(parts[1]) || 0]); + } + } + if (points.length < 3) return null; + return { type: "polygon", points }; +} +function createSvgClipPath(shape, box, ctx) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const svgShape = shapeToSvg(shape, box, ctx); + if (!svgShape) return null; + clipPath.appendChild(svgShape); + ctx.defs.appendChild(clipPath); + return clipId; +} +function shapeToSvg(shape, box, ctx) { + switch (shape.type) { + case "inset": { + const x = box.x + shape.left; + const y = box.y + shape.top; + const w = Math.max(0, box.width - shape.left - shape.right); + const h = Math.max(0, box.height - shape.top - shape.bottom); + if (shape.round) { + const radiiValues = shape.round.split("/").map( + (part) => part.trim().split(/\s+/).map((v) => parseFloat(v) || 0) + ); + const h_values = radiiValues[0] ?? [0]; + const v_values = radiiValues[1] ?? h_values; + const radii = { + topLeft: [h_values[0] ?? 0, v_values[0] ?? 0], + topRight: [h_values[1] ?? h_values[0] ?? 0, v_values[1] ?? v_values[0] ?? 0], + bottomRight: [h_values[2] ?? h_values[0] ?? 0, v_values[2] ?? v_values[0] ?? 0], + bottomLeft: [h_values[3] ?? h_values[1] ?? h_values[0] ?? 0, v_values[3] ?? v_values[1] ?? v_values[0] ?? 0] + }; + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", buildRoundedRectPath(x, y, w, h, radii)); + return path; + } + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { x, y, width: w, height: h }); + return rect; + } + case "circle": { + const resolvedCx = shape.cxPct ? shape.cx / 100 * box.width : shape.cx; + const resolvedCy = shape.cyPct ? shape.cy / 100 * box.height : shape.cy; + const circle = createSvgElement(ctx.svgDocument, "circle"); + setAttributes(circle, { + cx: box.x + resolvedCx, + cy: box.y + resolvedCy, + r: shape.radius + }); + return circle; + } + case "ellipse": { + const resolvedCx = shape.cxPct ? shape.cx / 100 * box.width : shape.cx; + const resolvedCy = shape.cyPct ? shape.cy / 100 * box.height : shape.cy; + const ellipse = createSvgElement(ctx.svgDocument, "ellipse"); + setAttributes(ellipse, { + cx: box.x + resolvedCx, + cy: box.y + resolvedCy, + rx: shape.rx, + ry: shape.ry + }); + return ellipse; + } + case "polygon": { + const polygon = createSvgElement(ctx.svgDocument, "polygon"); + const pointsStr = shape.points.map(([x, y]) => `${box.x + x},${box.y + y}`).join(" "); + polygon.setAttribute("points", pointsStr); + return polygon; + } + case "path": { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", shape.d); + path.setAttribute("transform", `translate(${box.x}, ${box.y})`); + return path; + } + default: + return null; + } +} + +// src/renderers/html-element.ts +async function renderHtmlElement(element, rootElement, ctx) { + const group = createSvgElement(ctx.svgDocument, "g"); + const styles = window.getComputedStyle(element); + const box = getRelativeBox(element, rootElement); + const radii = clampRadii(parseBorderRadii(styles), box.width, box.height); + if (!ctx.options.flattenTransforms && styles.transform && styles.transform !== "none") { + const svgTransform = cssTransformToSvg( + styles.transform, + styles.transformOrigin, + box + ); + if (svgTransform) { + group.setAttribute("transform", svgTransform); + } + } + const clipPathValue = styles.clipPath; + if (clipPathValue && clipPathValue !== "none") { + const shape = parseClipPath(clipPathValue); + if (shape) { + const clipId = createSvgClipPath(shape, box, ctx); + if (clipId) group.setAttribute("clip-path", `url(#${clipId})`); + } + } + const hidden = isVisibilityHidden(styles); + if (!hidden) { + if (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); + } + } + const bgColor = parseBackgroundColor(styles); + if (bgColor) { + const rect = createBoxShape(box, radii, ctx); + rect.setAttribute("fill", bgColor); + group.appendChild(rect); + } + if (hasBackgroundImage(styles)) { + await renderBackgroundImages(styles, box, radii, ctx, group); + } + const borders = parseBorders(styles); + if (hasBorder(borders)) { + renderBorders(group, box, borders, radii, ctx); + } + renderOutline(styles, box, radii, ctx, group); + if (isImageElement(element) && element.src) { + const dataUrl = await imageToDataUrl(element.src); + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + href: dataUrl + }); + const objectFit = styles.objectFit || element.style.objectFit; + if (objectFit === "fill" || objectFit === "") { + imgEl.setAttribute("preserveAspectRatio", "none"); + } else if (objectFit === "contain" || objectFit === "scale-down") { + imgEl.setAttribute("preserveAspectRatio", "xMidYMid meet"); + } else if (objectFit === "cover") { + imgEl.setAttribute("preserveAspectRatio", "xMidYMid slice"); + } + if (hasRadius(radii)) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createSvgElement(ctx.svgDocument, "path"); + clipShape.setAttribute("d", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii)); + clipPath.appendChild(clipShape); + ctx.defs.appendChild(clipPath); + imgEl.setAttribute("clip-path", `url(#${clipId})`); + } + group.appendChild(imgEl); + } + if (isCanvasElement(element)) { + const dataUrl = canvasToDataUrl(element); + if (dataUrl) { + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + href: dataUrl + }); + group.appendChild(imgEl); + } + } + if (isFormElement(element)) { + renderFormContent(element, styles, box, ctx, group); + } + 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); + } + await renderPseudoElement(element, "::before", rootElement, ctx, group); + } + if (hasOverflowClip(styles) && element !== rootElement) { + const maskGroup = createOverflowMask(box, radii, ctx); + group.appendChild(maskGroup); + group.__childTarget = maskGroup; + } + return group; +} +async function renderPseudoAfter(element, rootElement, ctx, group) { + await renderPseudoElement(element, "::after", rootElement, ctx, group); +} +function getChildTarget(group) { + return group.__childTarget ?? group; +} +function createBoxShape(box, radii, ctx) { + if (hasRadius(radii) && !isUniformRadius(radii)) { + return createRoundedRectPath(box, radii, ctx); + } + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { + x: box.x, + y: box.y, + width: box.width, + height: box.height + }); + if (hasRadius(radii) && isUniformRadius(radii)) { + setAttributes(rect, { + rx: radii.topLeft[0], + ry: radii.topLeft[1] + }); + } + return rect; +} +function createRoundedRectPath(box, radii, ctx) { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii)); + return path; +} +function borderDashArray(style, width) { + if (style === "dashed") return `${width * 3} ${width * 2}`; + if (style === "dotted") return `${width} ${width}`; + return null; +} +function renderBorders(group, box, borders, radii, ctx) { + if (borders.top.width === borders.right.width && borders.right.width === borders.bottom.width && borders.bottom.width === borders.left.width && borders.top.color === borders.right.color && borders.right.color === borders.bottom.color && borders.bottom.color === borders.left.color && borders.top.style === borders.right.style && borders.right.style === borders.bottom.style && borders.bottom.style === borders.left.style && borders.top.width > 0 && borders.top.style !== "none") { + const halfW = borders.top.width / 2; + const insetBox = { + x: box.x + halfW, + y: box.y + halfW, + width: Math.max(0, box.width - borders.top.width), + height: Math.max(0, box.height - borders.top.width) + }; + const insetRadii = { + topLeft: [Math.max(0, radii.topLeft[0] - halfW), Math.max(0, radii.topLeft[1] - halfW)], + topRight: [Math.max(0, radii.topRight[0] - halfW), Math.max(0, radii.topRight[1] - halfW)], + bottomRight: [Math.max(0, radii.bottomRight[0] - halfW), Math.max(0, radii.bottomRight[1] - halfW)], + bottomLeft: [Math.max(0, radii.bottomLeft[0] - halfW), Math.max(0, radii.bottomLeft[1] - halfW)] + }; + const shape = createBoxShape(insetBox, insetRadii, ctx); + setAttributes(shape, { + fill: "none", + stroke: borders.top.color, + "stroke-width": borders.top.width + }); + const dash = borderDashArray(borders.top.style, borders.top.width); + if (dash) shape.setAttribute("stroke-dasharray", dash); + group.appendChild(shape); + return; + } + const { x, y, width, height } = box; + const bT = borders.top.width; + const bR = borders.right.width; + const bB = borders.bottom.width; + const bL = borders.left.width; + const ox0 = x, oy0 = y; + const ox1 = x + width, oy1 = y + height; + const ix0 = x + bL, iy0 = y + bT; + const ix1 = x + width - bR, iy1 = y + height - bB; + const sides = [ + { w: bT, side: borders.top, trapD: `M ${ox0} ${oy0} L ${ox1} ${oy0} L ${ix1} ${iy0} L ${ix0} ${iy0} Z`, lineD: `M ${ix0} ${oy0 + bT / 2} L ${ix1} ${oy0 + bT / 2}` }, + { w: bR, side: borders.right, trapD: `M ${ox1} ${oy0} L ${ox1} ${oy1} L ${ix1} ${iy1} L ${ix1} ${iy0} Z`, lineD: `M ${ox1 - bR / 2} ${iy0} L ${ox1 - bR / 2} ${iy1}` }, + { w: bB, side: borders.bottom, trapD: `M ${ox1} ${oy1} L ${ox0} ${oy1} L ${ix0} ${iy1} L ${ix1} ${iy1} Z`, lineD: `M ${ix1} ${oy1 - bB / 2} L ${ix0} ${oy1 - bB / 2}` }, + { w: bL, side: borders.left, trapD: `M ${ox0} ${oy1} L ${ox0} ${oy0} L ${ix0} ${iy0} L ${ix0} ${iy1} Z`, lineD: `M ${ox0 + bL / 2} ${iy1} L ${ox0 + bL / 2} ${iy0}` } + ]; + for (const { w, side, trapD, lineD } of sides) { + if (w <= 0 || side.style === "none") continue; + const dash = borderDashArray(side.style, w); + if (dash) { + const line = createSvgElement(ctx.svgDocument, "path"); + setAttributes(line, { d: lineD, fill: "none", stroke: side.color, "stroke-width": w }); + line.setAttribute("stroke-dasharray", dash); + group.appendChild(line); + } else { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", trapD); + path.setAttribute("fill", side.color); + group.appendChild(path); + } + } +} +function createOverflowMask(box, radii, ctx) { + const maskId = ctx.idGenerator.next("mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + const maskRect = createBoxShape(box, radii, ctx); + maskRect.setAttribute("fill", "white"); + mask.appendChild(maskRect); + ctx.defs.appendChild(mask); + const masked = createSvgElement(ctx.svgDocument, "g"); + masked.setAttribute("mask", `url(#${maskId})`); + return masked; +} +function applyClipMask(target, box, radii, ctx, group) { + const maskId = ctx.idGenerator.next("mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + const maskRect = createBoxShape(box, radii, ctx); + maskRect.setAttribute("fill", "white"); + mask.appendChild(maskRect); + ctx.defs.appendChild(mask); + const wrapper = createSvgElement(ctx.svgDocument, "g"); + wrapper.setAttribute("mask", `url(#${maskId})`); + wrapper.appendChild(target); + group.appendChild(wrapper); +} +async function applyMaskImage(maskImage, styles, box, ctx, group) { + const url = extractUrlFromCss(maskImage); + if (!url) return; + let imageUrl = url; + if (!url.startsWith("data:")) { + try { + imageUrl = await imageToDataUrl(url); + } catch { + return; + } + } + const maskSize = styles.webkitMaskSize || styles.maskSize || "auto"; + let imgWidth = box.width; + let imgHeight = box.height; + if (maskSize !== "auto" && maskSize !== "contain" && maskSize !== "cover") { + const parts = maskSize.split(/\s+/); + const w = parseFloat(parts[0]); + const h = parseFloat(parts[1] || parts[0]); + if (!isNaN(w)) imgWidth = w; + if (!isNaN(h)) imgHeight = h; + } + const maskId = ctx.idGenerator.next("mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + mask.setAttribute("style", "mask-type: alpha"); + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: box.x, + y: box.y, + width: imgWidth, + height: imgHeight, + href: imageUrl + }); + mask.appendChild(imgEl); + ctx.defs.appendChild(mask); + group.setAttribute("mask", `url(#${maskId})`); +} +function renderListMarker(element, styles, box, ctx, group) { + let markerText = ""; + try { + const markerStyles = window.getComputedStyle(element, "::marker"); + const content = markerStyles.content; + if (content && content !== "none" && content !== "normal") { + markerText = content.replace(/^["']|["']$/g, ""); + } + } catch { + } + if (!markerText) { + const listStyleType = styles.listStyleType; + if (listStyleType === "none") return; + if (listStyleType === "disc") { + markerText = "\u2022"; + } else if (listStyleType === "circle") { + markerText = "\u25CB"; + } else if (listStyleType === "square") { + markerText = "\u25A0"; + } else if (listStyleType === "decimal" || listStyleType === "" || !listStyleType) { + let count = 1; + let sibling = element.previousElementSibling; + while (sibling) { + const sibStyles = window.getComputedStyle(sibling); + if (sibStyles.display === "list-item") count++; + sibling = sibling.previousElementSibling; + } + markerText = `${count}.`; + } else { + markerText = "\u2022"; + } + } + if (!markerText) return; + const fontSize = parseFloat(styles.fontSize) || 16; + const paddingLeft = parseFloat(styles.paddingLeft) || 0; + const paddingTop = parseFloat(styles.paddingTop) || 0; + const lineHeight = parseFloat(styles.lineHeight) || fontSize * 1.2; + const markerX = box.x + paddingLeft - 6; + const markerY = box.y + paddingTop + (lineHeight - fontSize) / 2 + fontSize * 0.8; + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + x: markerX.toFixed(2), + y: markerY.toFixed(2), + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + fill: styles.color, + "text-anchor": "end" + }); + textEl.textContent = markerText; + group.appendChild(textEl); +} +function renderFormContent(element, styles, box, ctx, group) { + let text = ""; + let isPlaceholder = false; + if (element instanceof HTMLSelectElement) { + const selected = element.selectedOptions[0]; + text = selected?.text ?? ""; + } else { + text = element.value; + if (!text && element.placeholder) { + text = element.placeholder; + isPlaceholder = true; + } + } + if (!text) return; + const fontSize = parseFloat(styles.fontSize) || 16; + const paddingLeft = parseFloat(styles.paddingLeft) || 0; + const paddingTop = parseFloat(styles.paddingTop) || 0; + const borderTop = parseFloat(styles.borderTopWidth) || 0; + const lineHeight = parseFloat(styles.lineHeight) || fontSize * 1.2; + const fillColor = isPlaceholder ? "gray" : styles.color; + const textX = box.x + paddingLeft; + if (element instanceof HTMLTextAreaElement) { + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]; + if (!lineText) continue; + const topPadding = (lineHeight - fontSize) / 2; + const y = box.y + borderTop + paddingTop + i * lineHeight + topPadding + fontSize * 0.8; + const textEl2 = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl2, { + x: textX.toFixed(2), + y: y.toFixed(2), + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: fillColor + }); + if (isPlaceholder) textEl2.setAttribute("opacity", "0.54"); + textEl2.textContent = lineText; + group.appendChild(textEl2); + } + return; + } + const borderBottom = parseFloat(styles.borderBottomWidth) || 0; + const innerHeight = box.height - borderTop - borderBottom; + const baselineY2 = box.y + borderTop + innerHeight / 2 + fontSize * 0.35; + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + x: textX.toFixed(2), + y: baselineY2.toFixed(2), + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: fillColor + }); + if (isPlaceholder) { + textEl.setAttribute("opacity", "0.54"); + } + textEl.textContent = text; + group.appendChild(textEl); +} +function renderOutline(styles, box, radii, ctx, group) { + const outlineStyle = styles.outlineStyle; + if (!outlineStyle || outlineStyle === "none") return; + const outlineWidth = parseFloat(styles.outlineWidth) || 0; + if (outlineWidth <= 0) return; + const outlineColor = styles.outlineColor || styles.color; + const outlineOffset = parseFloat(styles.outlineOffset) || 0; + const expand = outlineOffset + outlineWidth / 2; + const outlineBox = { + x: box.x - expand, + y: box.y - expand, + width: box.width + expand * 2, + height: box.height + expand * 2 + }; + const outlineRadii = { + topLeft: [Math.max(0, radii.topLeft[0] + expand), Math.max(0, radii.topLeft[1] + expand)], + topRight: [Math.max(0, radii.topRight[0] + expand), Math.max(0, radii.topRight[1] + expand)], + bottomRight: [Math.max(0, radii.bottomRight[0] + expand), Math.max(0, radii.bottomRight[1] + expand)], + bottomLeft: [Math.max(0, radii.bottomLeft[0] + expand), Math.max(0, radii.bottomLeft[1] + expand)] + }; + const shape = createBoxShape(outlineBox, outlineRadii, ctx); + setAttributes(shape, { + fill: "none", + stroke: outlineColor, + "stroke-width": outlineWidth + }); + const dash = borderDashArray(outlineStyle, outlineWidth); + if (dash) shape.setAttribute("stroke-dasharray", dash); + group.appendChild(shape); +} +function splitCssValueList(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + } else { + current += char; + } + } + if (current.trim()) parts.push(current.trim()); + return parts; +} +function computeBackgroundPlacement(bgSize, bgPosition, box) { + let width = box.width; + let height = box.height; + let par = "none"; + if (bgSize === "contain") { + par = "xMidYMid meet"; + } else if (bgSize === "cover") { + par = "xMidYMid slice"; + } else if (bgSize && bgSize !== "auto") { + const sizeParts = bgSize.split(/\s+/); + const w = parseBgDimension(sizeParts[0], box.width); + const h = parseBgDimension(sizeParts[1] ?? "auto", box.height); + if (w !== null) width = w; + if (h !== null) height = h; + } + let x = box.x; + let y = box.y; + if (bgPosition && bgPosition !== "0% 0%") { + const posParts = bgPosition.split(/\s+/); + x = box.x + parseBgOffset(posParts[0] ?? "0px", box.width, width); + y = box.y + parseBgOffset(posParts[1] ?? "0px", box.height, height); + } + return { x, y, width, height, preserveAspectRatio: par }; +} +function parseBgDimension(value, containerSize) { + if (value === "auto") return null; + if (value.endsWith("%")) return parseFloat(value) / 100 * containerSize; + return parseFloat(value) || null; +} +function parseBgOffset(value, containerSize, imageSize) { + if (value.endsWith("%")) { + const pct = parseFloat(value) / 100; + return pct * (containerSize - imageSize); + } + return parseFloat(value) || 0; +} +async function renderBackgroundImages(styles, box, radii, ctx, group) { + const bgImages = splitCssValueList(styles.backgroundImage); + const bgSizes = splitCssValueList(styles.backgroundSize); + const bgPositions = splitCssValueList(styles.backgroundPosition); + for (let i = bgImages.length - 1; i >= 0; i--) { + const bgImage = bgImages[i]; + if (bgImage === "none") continue; + const bgSize = bgSizes[i] ?? bgSizes[bgSizes.length - 1] ?? "auto"; + const bgPosition = bgPositions[i] ?? bgPositions[bgPositions.length - 1] ?? "0% 0%"; + const placement = computeBackgroundPlacement(bgSize, bgPosition, box); + await renderSingleBackgroundLayer(bgImage, placement, box, radii, ctx, group); + } +} +async function renderSingleBackgroundLayer(bgImage, placement, box, radii, ctx, group) { + const gradient = parseLinearGradient(bgImage); + if (gradient) { + const gradientEl = createSvgLinearGradient(gradient, box, ctx); + const rect = createBoxShape(box, radii, ctx); + rect.setAttribute("fill", `url(#${gradientEl.getAttribute("id")})`); + group.appendChild(rect); + return; + } + const rasterized = rasterizeGradient(bgImage, placement.width, placement.height); + if (rasterized) { + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: placement.x, + y: placement.y, + width: placement.width, + height: placement.height, + href: rasterized, + preserveAspectRatio: placement.preserveAspectRatio + }); + if (hasRadius(radii)) { + applyClipMask(imgEl, box, radii, ctx, group); + } else { + group.appendChild(imgEl); + } + return; + } + const url = extractUrlFromCss(bgImage); + if (url) { + const dataUrl = await imageToDataUrl(url); + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: placement.x, + y: placement.y, + width: placement.width, + height: placement.height, + href: dataUrl, + preserveAspectRatio: placement.preserveAspectRatio + }); + if (hasRadius(radii)) { + applyClipMask(imgEl, box, radii, ctx, group); + } else { + group.appendChild(imgEl); + } + } +} +function hasVisualProperties(styles) { + if (parseBackgroundColor(styles)) return true; + if (hasBackgroundImage(styles)) return true; + const clipPath = styles.clipPath || styles.webkitClipPath; + if (clipPath && clipPath !== "none") return true; + return false; +} +function measurePseudoBox(element, pseudo, styles, rootElement) { + const marker = document.createElement("span"); + marker.style.cssText = ` + position: ${styles.position}; + display: ${styles.display === "none" ? "none" : styles.display}; + top: ${styles.top}; right: ${styles.right}; + bottom: ${styles.bottom}; left: ${styles.left}; + width: ${styles.width}; height: ${styles.height}; + margin: ${styles.margin}; padding: ${styles.padding}; + box-sizing: ${styles.boxSizing}; + visibility: hidden; + pointer-events: none; + `; + if (pseudo === "::before") { + element.insertBefore(marker, element.firstChild); + } else { + element.appendChild(marker); + } + const rect = marker.getBoundingClientRect(); + element.removeChild(marker); + if (rect.width === 0 && rect.height === 0) return null; + const rootRect = rootElement.getBoundingClientRect(); + return { + x: rect.left - rootRect.left, + y: rect.top - rootRect.top, + width: rect.width, + height: rect.height + }; +} +async function renderPseudoElement(element, pseudo, rootElement, ctx, group) { + const styles = getPseudoStyles(element, pseudo); + const content = styles.content; + if (!content || content === "none" || content === "normal") { + return; + } + const text = content.replace(/^["']|["']$/g, ""); + const hasVisuals = hasVisualProperties(styles); + if (!text && !hasVisuals) return; + if (hasVisuals) { + const pseudoBox = measurePseudoBox(element, pseudo, styles, rootElement); + if (pseudoBox) { + const bgColor = parseBackgroundColor(styles); + if (bgColor) { + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { + x: pseudoBox.x, + y: pseudoBox.y, + width: pseudoBox.width, + height: pseudoBox.height, + fill: bgColor + }); + const clipPathValue = styles.clipPath || styles.webkitClipPath; + if (clipPathValue && clipPathValue !== "none") { + const shape = parseClipPath(clipPathValue); + if (shape) { + const clipId = createSvgClipPath(shape, pseudoBox, ctx); + if (clipId) rect.setAttribute("clip-path", `url(#${clipId})`); + } + } + group.appendChild(rect); + } + } + } + if (!text) return; + const rootRect = rootElement.getBoundingClientRect(); + const fontSize = parseFloat(styles.fontSize) || 16; + const marker = document.createElement("span"); + marker.style.cssText = ` + font-family: ${styles.fontFamily}; + font-size: ${styles.fontSize}; + font-weight: ${styles.fontWeight}; + font-style: ${styles.fontStyle}; + letter-spacing: ${styles.letterSpacing}; + visibility: hidden; + pointer-events: none; + `; + marker.textContent = text; + if (pseudo === "::before") { + element.insertBefore(marker, element.firstChild); + } else { + element.appendChild(marker); + } + const markerRect = marker.getBoundingClientRect(); + const markerX = markerRect.left - rootRect.left; + const markerWidth = markerRect.width; + const markerHeight = markerRect.height; + const topPadding = (markerHeight - fontSize) / 2; + const baselineY2 = markerRect.top - rootRect.top + topPadding + fontSize * 0.8; + element.removeChild(marker); + if (!hasVisuals) { + const bgColor = parseBackgroundColor(styles); + if (bgColor) { + const bgRect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(bgRect, { + x: markerX, + y: markerRect.top - rootRect.top, + width: markerWidth, + height: markerHeight, + fill: bgColor + }); + group.appendChild(bgRect); + } + } + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: styles.color, + x: markerX.toFixed(2), + y: baselineY2.toFixed(2) + }); + if (styles.letterSpacing && styles.letterSpacing !== "normal") { + textEl.setAttribute("letter-spacing", styles.letterSpacing); + } + textEl.textContent = text; + group.appendChild(textEl); +} + +// src/renderers/svg-element.ts +function renderSvgElement(element, ctx) { + const computedColor = window.getComputedStyle(element).color || "rgb(0, 0, 0)"; + const clone = cloneWithNamespace(element, ctx); + resolveCurrentColor(clone, computedColor); + rewriteIds(clone, ctx); + return clone; +} +function cloneWithNamespace(node, ctx, resolveDepth = 0) { + if (node.localName === "use" && resolveDepth < 5) { + const resolved = resolveUseElement(node, ctx, resolveDepth); + if (resolved) return resolved; + } + const clone = ctx.svgDocument.createElementNS( + node.namespaceURI || SVG_NS, + node.localName + ); + for (const attr of Array.from(node.attributes)) { + if (attr.namespaceURI === XLINK_NS) { + clone.setAttributeNS(XLINK_NS, attr.localName, attr.value); + } else if (attr.namespaceURI) { + clone.setAttributeNS(attr.namespaceURI, attr.localName, attr.value); + } else { + clone.setAttribute(attr.localName, attr.value); + } + } + inlineSvgPresentationStyles(node, clone); + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE) { + clone.appendChild(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) { + const href = useEl.getAttribute("href") || useEl.getAttributeNS(XLINK_NS, "href"); + if (!href || !href.startsWith("#")) return null; + const refId = href.slice(1); + const refEl = document.getElementById(refId); + if (!refEl) return null; + const group = ctx.svgDocument.createElementNS(SVG_NS, "g"); + const skipAttrs = /* @__PURE__ */ new Set(["href", "xlink:href", "x", "y", "width", "height"]); + for (const attr of Array.from(useEl.attributes)) { + if (skipAttrs.has(attr.localName)) continue; + if (attr.namespaceURI === XLINK_NS) continue; + if (attr.namespaceURI) { + group.setAttributeNS(attr.namespaceURI, attr.localName, attr.value); + } else { + group.setAttribute(attr.localName, attr.value); + } + } + const x = parseFloat(useEl.getAttribute("x") || "0") || 0; + const y = parseFloat(useEl.getAttribute("y") || "0") || 0; + if (x !== 0 || y !== 0) { + const existing = group.getAttribute("transform") || ""; + group.setAttribute("transform", `translate(${x},${y}) ${existing}`.trim()); + } + inlineSvgPresentationStyles(useEl, group); + if (refEl.localName === "symbol") { + const viewBox = refEl.getAttribute("viewBox"); + const width = useEl.getAttribute("width") || refEl.getAttribute("width"); + const height = useEl.getAttribute("height") || refEl.getAttribute("height"); + const wrapper = ctx.svgDocument.createElementNS(SVG_NS, "svg"); + if (viewBox) wrapper.setAttribute("viewBox", viewBox); + if (width) wrapper.setAttribute("width", width); + if (height) wrapper.setAttribute("height", height); + 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)); + } + } + group.appendChild(wrapper); + } else { + group.appendChild(cloneWithNamespace(refEl, ctx, resolveDepth + 1)); + } + return group; +} +function inlineSvgPresentationStyles(source, clone) { + 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); + } + } + if (!clone.hasAttribute("opacity")) { + const opacity = styles.opacity; + if (opacity && opacity !== "1") { + clone.setAttribute("opacity", opacity); + } + } +} +function rewriteIds(root, ctx) { + const idMap = /* @__PURE__ */ new Map(); + const allElements = root.querySelectorAll("[id]"); + for (const el of Array.from(allElements)) { + const oldId = el.getAttribute("id"); + const newId = ctx.idGenerator.next("svg"); + idMap.set(oldId, newId); + el.setAttribute("id", newId); + } + if (root.hasAttribute("id")) { + const oldId = root.getAttribute("id"); + if (!idMap.has(oldId)) { + const newId = ctx.idGenerator.next("svg"); + idMap.set(oldId, newId); + root.setAttribute("id", newId); + } + } + if (idMap.size === 0) return; + rewriteUrlReferences(root, idMap); +} +function rewriteUrlReferences(element, idMap) { + for (const attr of Array.from(element.attributes)) { + if (attr.value.includes("url(#")) { + let newValue = attr.value; + for (const [oldId, newId] of idMap) { + newValue = newValue.replace( + new RegExp(`url\\(#${escapeRegex(oldId)}\\)`, "g"), + `url(#${newId})` + ); + } + if (newValue !== attr.value) { + element.setAttribute(attr.localName, newValue); + } + } + if ((attr.localName === "href" || attr.localName === "xlink:href") && attr.value.startsWith("#")) { + const refId = attr.value.slice(1); + if (idMap.has(refId)) { + if (attr.namespaceURI === XLINK_NS) { + element.setAttributeNS(XLINK_NS, "href", `#${idMap.get(refId)}`); + } else { + element.setAttribute(attr.localName, `#${idMap.get(refId)}`); + } + } + } + } + for (const child of Array.from(element.children)) { + if (child instanceof SVGElement) { + rewriteUrlReferences(child, idMap); + } + } +} +function resolveCurrentColor(element, color) { + for (const attr of Array.from(element.attributes)) { + if (attr.value === "currentColor") { + element.setAttribute(attr.localName, color); + } + } + for (const child of Array.from(element.children)) { + if (child instanceof SVGElement) { + resolveCurrentColor(child, color); + } + } +} +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 []; + const shadows = []; + const parts = splitTopLevelCommas2(value); + for (const part of parts) { + const shadow = parseSingleTextShadow(part.trim()); + if (shadow) shadows.push(shadow); + } + return shadows; +} +function splitTopLevelCommas2(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} +function parseSingleTextShadow(value) { + const tokens = []; + let current = ""; + let depth = 0; + for (const char of value) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === " " && depth === 0 && current) { + tokens.push(current); + current = ""; + } else { + current += char; + } + } + if (current) tokens.push(current); + const numericValues = []; + const colorParts = []; + for (const token of tokens) { + const num = parseFloat(token); + if (!isNaN(num) && (token.endsWith("px") || token.match(/^-?[\d.]+$/))) { + numericValues.push(num); + } else { + colorParts.push(token); + } + } + if (numericValues.length < 2) return null; + return { + offsetX: numericValues[0], + offsetY: numericValues[1], + blur: numericValues[2] ?? 0, + color: colorParts.join(" ") || "rgba(0, 0, 0, 0.5)" + }; +} +function createTextShadowFilter(shadows, ctx) { + if (shadows.length === 0) return null; + const id = ctx.idGenerator.next("tshadow"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + setAttributes(filter, { + id, + x: "-50%", + y: "-50%", + width: "200%", + height: "200%" + }); + if (shadows.length === 1) { + const s = shadows[0]; + const feDrop = createSvgElement(ctx.svgDocument, "feDropShadow"); + setAttributes(feDrop, { + dx: s.offsetX, + dy: s.offsetY, + stdDeviation: s.blur / 2, + "flood-color": s.color, + "flood-opacity": 1 + }); + filter.appendChild(feDrop); + } else { + const mergeInputs = []; + for (let i = 0; i < shadows.length; i++) { + const s = shadows[i]; + const suffix = String(i); + const feOffset = createSvgElement(ctx.svgDocument, "feOffset"); + setAttributes(feOffset, { + in: "SourceAlpha", + dx: s.offsetX, + dy: s.offsetY, + result: `off${suffix}` + }); + filter.appendChild(feOffset); + const feBlur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(feBlur, { + in: `off${suffix}`, + stdDeviation: s.blur / 2, + result: `blur${suffix}` + }); + filter.appendChild(feBlur); + const feFlood = createSvgElement(ctx.svgDocument, "feFlood"); + setAttributes(feFlood, { + "flood-color": s.color, + "flood-opacity": 1, + result: `color${suffix}` + }); + filter.appendChild(feFlood); + const feComp = createSvgElement(ctx.svgDocument, "feComposite"); + setAttributes(feComp, { + in: `color${suffix}`, + in2: `blur${suffix}`, + operator: "in", + result: `shadow${suffix}` + }); + filter.appendChild(feComp); + mergeInputs.push(`shadow${suffix}`); + } + const feMerge = createSvgElement(ctx.svgDocument, "feMerge"); + for (const input of mergeInputs) { + const node = createSvgElement(ctx.svgDocument, "feMergeNode"); + node.setAttribute("in", input); + feMerge.appendChild(node); + } + const srcNode = createSvgElement(ctx.svgDocument, "feMergeNode"); + srcNode.setAttribute("in", "SourceGraphic"); + feMerge.appendChild(srcNode); + filter.appendChild(feMerge); + } + ctx.defs.appendChild(filter); + return id; +} + +// src/renderers/text-node.ts +async function renderTextNode(textNode, rootElement, ctx) { + const text = textNode.textContent; + if (!text || !text.trim()) return null; + const parent = textNode.parentElement; + if (!parent) return null; + const styles = window.getComputedStyle(parent); + if (styles.visibility === "hidden") return null; + const whiteSpace = styles.whiteSpace; + const rootRect = rootElement.getBoundingClientRect(); + let rects; + try { + const range = document.createRange(); + range.selectNodeContents(textNode); + rects = range.getClientRects(); + } catch { + return null; + } + if (rects.length === 0) return null; + const group = createSvgElement(ctx.svgDocument, "g"); + const usePathMode = ctx.options.textToPath && ctx.fontCache; + const fontFamily = cleanFontFamily(styles.fontFamily); + const fontSize = parseFloat(styles.fontSize) || 16; + const fontWeight = styles.fontWeight; + const fontStyle = styles.fontStyle; + let font = null; + if (usePathMode && ctx.fontCache?.has(fontFamily)) { + font = await ctx.fontCache.getFont(fontFamily, fontWeight, fontStyle); + } + let ascenderRatio = 0.8; + if (font && font.ascender && font.unitsPerEm) { + ascenderRatio = font.ascender / font.unitsPerEm; + } + const lines = getTextLines(textNode, rootRect, ascenderRatio, whiteSpace); + const textTransform = styles.textTransform; + const needsEllipsis = styles.textOverflow === "ellipsis" && styles.overflow !== "visible" && styles.whiteSpace === "nowrap" && parent.scrollWidth > parent.clientWidth; + for (const line of lines) { + let displayText = applyTextTransform(line.text, textTransform); + if (needsEllipsis && line === lines[lines.length - 1]) { + displayText = displayText.trimEnd() + "\u2026"; + } + if (font) { + const pathData = textToPath(font, displayText, line.x, line.y, fontSize); + if (pathData) { + const pathEl = createSvgElement(ctx.svgDocument, "path"); + setAttributes(pathEl, { + d: pathData, + fill: styles.color + }); + group.appendChild(pathEl); + } + } else { + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + x: line.x.toFixed(2), + y: line.y.toFixed(2) + }); + applyTextStyles(textEl, styles); + 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})`); + } + } + return group; +} +function baselineY(rectTop, rectHeight, fontSize, rootTop, ascenderRatio = 0.8, parentRect) { + let effectiveTop = rectTop; + let effectiveHeight = rectHeight; + if (parentRect && rectHeight > fontSize * 2 && parentRect.height < rectHeight * 0.8) { + effectiveTop = parentRect.top; + effectiveHeight = parentRect.height; + } + const topPadding = (effectiveHeight - fontSize) / 2; + return effectiveTop - rootTop + topPadding + fontSize * ascenderRatio; +} +function getTextLines(textNode, rootRect, ascenderRatio = 0.8, whiteSpace = "normal") { + const lines = []; + const text = textNode.textContent || ""; + if (!text) return lines; + const parent = textNode.parentElement; + if (!parent) return lines; + const styles = window.getComputedStyle(parent); + const fontSize = parseFloat(styles.fontSize) || 16; + const pRect = parent.getBoundingClientRect(); + const parentRect = { top: pRect.top, height: pRect.height }; + const range = document.createRange(); + range.selectNodeContents(textNode); + const rects = range.getClientRects(); + if (rects.length === 0) return lines; + let lineRects; + if (rects.length === 1) { + const rect = rects[0]; + if (text.length > 1 && textActuallyWraps(textNode, range, text.length, fontSize)) { + lineRects = discoverLineRects(textNode, range, text.length, fontSize); + } else { + lines.push({ + text: normalizeWhitespace(text, whiteSpace), + x: rect.left - rootRect.left, + y: baselineY(rect.top, rect.height, fontSize, rootRect.top, ascenderRatio, parentRect) + }); + return lines; + } + } else { + lineRects = Array.from(rects); + } + let charStart = 0; + for (let lineIdx = 0; lineIdx < lineRects.length; lineIdx++) { + const lineRect = lineRects[lineIdx]; + const isLastLine = lineIdx === lineRects.length - 1; + let charEnd; + if (isLastLine) { + charEnd = text.length; + } else { + const currentTop = lineRect.top; + charEnd = binarySearchLineBreak(textNode, range, charStart, text.length, currentTop, fontSize); + } + const lineText = normalizeWhitespace(text.slice(charStart, charEnd), whiteSpace); + if (lineText) { + lines.push({ + text: lineText, + x: lineRect.left - rootRect.left, + y: baselineY(lineRect.top, lineRect.height, fontSize, rootRect.top, ascenderRatio, parentRect) + }); + } + charStart = charEnd; + } + return lines; +} +function binarySearchLineBreak(textNode, range, start, end, currentLineTop, fontSize) { + while (start < end) { + const mid = Math.floor((start + end) / 2); + try { + range.setStart(textNode, mid); + range.setEnd(textNode, mid + 1); + } catch { + start = mid + 1; + continue; + } + const rects = range.getClientRects(); + if (rects.length === 0) { + start = mid + 1; + continue; + } + if (Math.abs(rects[0].top - currentLineTop) > fontSize * 0.5) { + end = mid; + } else { + start = mid + 1; + } + } + return start; +} +function textActuallyWraps(textNode, range, textLength, fontSize) { + if (textLength <= 1) return false; + try { + range.setStart(textNode, 0); + range.setEnd(textNode, 1); + const firstRects = range.getClientRects(); + range.setStart(textNode, textLength - 1); + range.setEnd(textNode, textLength); + const lastRects = range.getClientRects(); + if (firstRects.length > 0 && lastRects.length > 0) { + return Math.abs(lastRects[0].top - firstRects[0].top) > fontSize * 0.5; + } + } catch { + } + return false; +} +function discoverLineRects(textNode, range, textLength, fontSize) { + const lineRects = []; + let currentLineTop = -Infinity; + for (let i = 0; i < textLength; i++) { + try { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const charRects = range.getClientRects(); + if (charRects.length === 0) continue; + const charRect = charRects[0]; + if (Math.abs(charRect.top - currentLineTop) > fontSize * 0.5) { + lineRects.push(charRect); + currentLineTop = charRect.top; + } + } catch { + continue; + } + } + return lineRects; +} +function normalizeWhitespace(text, whiteSpace) { + const preserves = whiteSpace === "pre" || whiteSpace === "pre-wrap" || whiteSpace === "break-spaces"; + if (preserves) return text; + if (whiteSpace === "pre-line") { + return text.replace(/[^\S\n]+/g, " "); + } + return text.replace(/\s+/g, " "); +} +function applyTextTransform(text, transform) { + switch (transform) { + case "uppercase": + return text.toUpperCase(); + case "lowercase": + return text.toLowerCase(); + case "capitalize": + return text.replace(/\b\w/g, (c) => c.toUpperCase()); + default: + return text; + } +} +function applyTextStyles(textEl, styles) { + setAttributes(textEl, { + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: styles.color + }); + textEl.setAttribute("xml:space", "preserve"); + if (styles.letterSpacing && styles.letterSpacing !== "normal") { + textEl.setAttribute("letter-spacing", styles.letterSpacing); + } + if (styles.wordSpacing && styles.wordSpacing !== "normal") { + textEl.setAttribute("word-spacing", styles.wordSpacing); + } + if (styles.textDecoration && styles.textDecoration !== "none") { + const decs = []; + if (styles.textDecoration.includes("underline")) decs.push("underline"); + if (styles.textDecoration.includes("line-through")) decs.push("line-through"); + if (decs.length > 0) { + textEl.setAttribute("text-decoration", decs.join(" ")); + } + } +} + +// src/core/traversal.ts +async function walkElement(element, rootElement, ctx) { + const styles = window.getComputedStyle(element); + if (isInvisible(styles)) return null; + if (shouldExclude(element, ctx)) return null; + if (ctx.options.handler) { + try { + const result = ctx.options.handler(element, ctx); + if (result !== null) return result; + } catch (err) { + console.warn("dom2svg: Custom handler threw for element:", element, err); + } + } + if (isSvgElement(element) && element !== rootElement) { + const box = getRelativeBox(element, rootElement); + const clone = renderSvgElement(element, ctx); + if (element.tagName.toLowerCase() === "svg") { + clone.setAttribute("x", String(box.x)); + clone.setAttribute("y", String(box.y)); + clone.setAttribute("width", String(box.width)); + clone.setAttribute("height", String(box.height)); + if (styles.overflow === "visible") { + clone.setAttribute("overflow", "visible"); + } + } + return clone; + } + const group = await renderHtmlElement(element, rootElement, ctx); + const childTarget = getChildTarget(group); + const opacity = parseFloat(styles.opacity); + if (opacity < 1) { + group.setAttribute("opacity", String(opacity)); + } + const sortedChildren = sortChildrenByPaintOrder(element); + for (const child of sortedChildren) { + if (isTextNode(child)) { + const textSvg = await renderTextNode(child, rootElement, ctx); + if (textSvg) childTarget.appendChild(textSvg); + } else if (isElement(child)) { + const childSvg = await walkElement(child, rootElement, ctx); + if (childSvg) childTarget.appendChild(childSvg); + } + } + await renderPseudoAfter(element, rootElement, ctx, group); + return group; +} +function sortChildrenByPaintOrder(element) { + const children = Array.from(element.childNodes); + if (!children.some((c) => isElement(c))) return children; + const negativeZIndex = []; + const blocks = []; + const floats = []; + const inlinesAndText = []; + const positioned = []; + const positiveZIndex = []; + for (const child of children) { + if (isTextNode(child)) { + inlinesAndText.push(child); + continue; + } + if (!isElement(child)) continue; + const childStyles = window.getComputedStyle(child); + const z = getZIndex(childStyles); + const hasStackingCtx = createsStackingContext(childStyles); + const pos = isPositioned(childStyles); + if (hasStackingCtx && z < 0) { + negativeZIndex.push({ node: child, z }); + } else if (hasStackingCtx && z > 0) { + positiveZIndex.push({ node: child, z }); + } else if (pos || hasStackingCtx) { + positioned.push(child); + } else if (isFloat(childStyles)) { + floats.push(child); + } else if (isInlineLevel(childStyles)) { + inlinesAndText.push(child); + } else { + blocks.push(child); + } + } + negativeZIndex.sort((a, b) => a.z - b.z); + positiveZIndex.sort((a, b) => a.z - b.z); + const result = []; + for (const { node } of negativeZIndex) result.push(node); + for (const node of blocks) result.push(node); + for (const node of floats) result.push(node); + for (const node of inlinesAndText) result.push(node); + for (const node of positioned) result.push(node); + for (const { node } of positiveZIndex) result.push(node); + return result; +} +function shouldExclude(element, ctx) { + const exclude = ctx.options.exclude; + if (!exclude) return false; + if (typeof exclude === "string") { + return element.matches(exclude); + } + return exclude(element); +} + +// src/index.ts +async function domToSvg(element, options = {}) { + const padding = options.padding ?? 0; + const rect = element.getBoundingClientRect(); + const width = rect.width + padding * 2; + const height = rect.height + padding * 2; + const svgDocument = document.implementation.createDocument(SVG_NS, "svg", null); + const svg = svgDocument.documentElement; + svg.setAttribute("xmlns", SVG_NS); + svg.setAttributeNS(XMLNS_NS, "xmlns:xlink", "http://www.w3.org/1999/xlink"); + setAttributes(svg, { + width, + height, + viewBox: `${-padding} ${-padding} ${width} ${height}` + }); + const defs = createSvgElement(svgDocument, "defs"); + svg.appendChild(defs); + if (options.background) { + const bgRect = createSvgElement(svgDocument, "rect"); + setAttributes(bgRect, { + x: -padding, + y: -padding, + width, + height, + fill: options.background + }); + svg.appendChild(bgRect); + } + const ctx = { + svgDocument, + defs, + idGenerator: createIdGenerator(), + options, + opacity: 1 + }; + if (options.textToPath && options.fonts) { + ctx.fontCache = createFontCache(options.fonts); + } + const rootGroup = await walkElement(element, element, ctx); + if (rootGroup) { + const rootStyles = window.getComputedStyle(element); + const rootRadii = clampRadii(parseBorderRadii(rootStyles), rect.width, rect.height); + if (hasOverflowClip(rootStyles) && hasRadius(rootRadii)) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createRootClipShape(svgDocument, rect.width, rect.height, rootRadii); + clipPath.appendChild(clipShape); + defs.appendChild(clipPath); + rootGroup.setAttribute("clip-path", `url(#${clipId})`); + } + svg.appendChild(rootGroup); + } + if (defs.childNodes.length === 0) { + svg.removeChild(defs); + } + return createResult(svg); +} +function createRootClipShape(doc, width, height, radii) { + if (isUniformRadius(radii)) { + const rect = createSvgElement(doc, "rect"); + setAttributes(rect, { x: 0, y: 0, width, height, rx: radii.topLeft[0], ry: radii.topLeft[1] }); + return rect; + } + const path = createSvgElement(doc, "path"); + path.setAttribute("d", buildRoundedRectPath(0, 0, width, height, radii)); + return path; +} +function createResult(svg) { + return { + svg, + toString() { + const serializer = new XMLSerializer(); + const xmlStr = serializer.serializeToString(svg); + return ` +${xmlStr}`; + }, + toBlob() { + const str = this.toString(); + return new Blob([str], { type: "image/svg+xml;charset=utf-8" }); + }, + download(filename = "export.svg") { + const blob = this.toBlob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 6e4); + } + }; +} +export { + domToSvg +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/lib/export/dom2svg/index.js.map b/src/lib/export/dom2svg/index.js.map new file mode 100644 index 0000000..cf0f37a --- /dev/null +++ b/src/lib/export/dom2svg/index.js.map @@ -0,0 +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 (,