import { BOUND_TEXT_PADDING, FRAME_STYLE, MAX_DECIMALS_FOR_SVG_EXPORT, SVG_NS, THEME, DARK_THEME_FILTER, getFontFamilyString, isRTL, isTestEnv, getVerticalOffset, applyDarkModeFilter, MIME_TYPES, STICKY_NOTE_EDGE_SHADOW_OPACITY, STICKY_NOTE_EDGE_SHADOW_WIDTH, STICKY_NOTE_FOOTER, STICKY_NOTE_SHADOW_OPACITY, } from "@excalidraw/common"; import { normalizeLink, toValidURL } from "@excalidraw/common"; import { hashString } from "@excalidraw/element"; import { getUncroppedWidthAndHeight } from "@excalidraw/element"; import { createPlaceholderEmbeddableLabel, getEmbedLink, } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element"; import { getBoundTextElement, getContainerElement } from "@excalidraw/element"; import { getLineHeightInPx } from "@excalidraw/element"; import { isArrowElement, isIframeLikeElement, isInitializedImageElement, isTextElement, } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element"; import { getCornerRadius, isPathALoop } from "@excalidraw/element"; import { ShapeCache } from "@excalidraw/element"; import { getStickyNoteFooter, getStickyNotePathCommands, type StickyNotePathCommand, } from "@excalidraw/element"; import { getElementAbsoluteCoords } from "@excalidraw/element"; import type { ExcalidrawElement, ExcalidrawTextElementWithContainer, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; import type { AppState, BinaryFiles } from "../types"; import type { Drawable } from "roughjs/bin/core"; import type { RoughSVG } from "roughjs/bin/svg"; const roughSVGDrawWithPrecision = ( rsvg: RoughSVG, drawable: Drawable, precision?: number, ) => { if (typeof precision === "undefined") { return rsvg.draw(drawable); } const pshape: Drawable = { sets: drawable.sets, shape: drawable.shape, options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, }; return rsvg.draw(pshape); }; const maybeWrapNodesInFrameClipPath = ( element: Readonly, root: SVGElement, nodes: SVGElement[], frameRendering: AppState["frameRendering"], elementsMap: RenderableElementsMap, ) => { if (!frameRendering.enabled || !frameRendering.clip) { return null; } const frame = getContainingFrame(element, elementsMap); if (frame) { const g = root.ownerDocument.createElementNS(SVG_NS, "g"); g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); nodes.forEach((node) => g.appendChild(node)); return g; } return null; }; const renderElementToSvg = ( element: Readonly, elementsMap: RenderableElementsMap, rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, offsetX: number, offsetY: number, renderConfig: SVGRenderConfig, ) => { const offset = { x: offsetX, y: offsetY }; const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); let cx = (x2 - x1) / 2 - (element.x - x1); let cy = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, elementsMap, ); cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); offsetX = offsetX + boundTextCoords.x - element.x; offsetY = offsetY + boundTextCoords.y - element.y; } } const degree = (180 * element.angle) / Math.PI; // element to append node to, most of the time svgRoot let root = svgRoot; // if the element has a link, create an anchor tag and make that the new root if (element.link) { const anchorTag = svgRoot.ownerDocument.createElementNS(SVG_NS, "a"); anchorTag.setAttribute("href", normalizeLink(element.link)); root.appendChild(anchorTag); root = anchorTag; } const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { if (isTestEnv()) { node.setAttribute("data-id", element.id); } root.appendChild(node); }; const opacity = ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * element.opacity) / 10000; switch (element.type) { case "selection": { // Since this is used only during editing experience, which is canvas based, // this should not happen throw new Error("Selection rendering is not supported for SVG"); } case "stickynote": { const getPathData = (commands: StickyNotePathCommand[]) => commands .map((command) => { if (command.type === "move") { return `M ${command.point.x} ${command.point.y}`; } if (command.type === "line") { return `L ${command.point.x} ${command.point.y}`; } return `Q ${command.control.x} ${command.control.y} ${command.point.x} ${command.point.y}`; }) .join(" ") .concat(" Z"); const createPath = ( commands: StickyNotePathCommand[], fill: string, fillOpacity?: number, ) => { const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path"); path.setAttribute("d", getPathData(commands)); path.setAttribute("fill", fill); if (typeof fillOpacity !== "undefined") { path.setAttribute("fill-opacity", `${fillOpacity}`); } path.setAttribute("stroke", "none"); return path; }; const group = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); group.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); if (opacity !== 1) { group.setAttribute("opacity", `${opacity}`); } const shadow = createPath( getStickyNotePathCommands(element, { shadow: true }), "#000", STICKY_NOTE_SHADOW_OPACITY, ); const commands = getStickyNotePathCommands(element); const rect = createPath( commands, applyDarkModeFilter( element.backgroundColor, renderConfig.theme === THEME.DARK, ), ); const clipPath = svgRoot.ownerDocument.createElementNS( SVG_NS, "clipPath", ); clipPath.setAttribute("id", `sticky-note-clipPath-${element.id}`); clipPath.setAttribute("clipPathUnits", "userSpaceOnUse"); clipPath.appendChild(createPath(commands, "#000")); addToRoot(clipPath, element); const edgeShadow = createPath(commands, "none"); edgeShadow.setAttribute("stroke", "#000"); edgeShadow.setAttribute( "stroke-opacity", `${STICKY_NOTE_EDGE_SHADOW_OPACITY}`, ); edgeShadow.setAttribute( "stroke-width", `${STICKY_NOTE_EDGE_SHADOW_WIDTH * 2}`, ); edgeShadow.setAttribute("clip-path", `url(#${clipPath.id})`); group.appendChild(shadow); group.appendChild(rect); group.appendChild(edgeShadow); const footer = getStickyNoteFooter(element); if (footer) { const dateText = svgRoot.ownerDocument.createElementNS(SVG_NS, "text"); dateText.setAttribute("x", `${footer.x}`); dateText.setAttribute("y", `${footer.y}`); dateText.setAttribute("font-family", STICKY_NOTE_FOOTER.fontFamily); dateText.setAttribute("font-size", `${STICKY_NOTE_FOOTER.fontSize}px`); // `text-anchor` is logical in SVG: pin the direction so an RTL host // page can't flip the label to the left edge dateText.setAttribute("text-anchor", "end"); dateText.setAttribute("direction", "ltr"); dateText.setAttribute( "fill", applyDarkModeFilter( element.strokeColor, renderConfig.theme === THEME.DARK, ), ); dateText.setAttribute("fill-opacity", `${STICKY_NOTE_FOOTER.opacity}`); dateText.textContent = footer.text; group.appendChild(dateText); } const g = maybeWrapNodesInFrameClipPath( element, root, [group], renderConfig.frameRendering, elementsMap, ); addToRoot(g || group, element); break; } case "rectangle": case "diamond": case "ellipse": { const shape = ShapeCache.generateElementShape(element, renderConfig); const node = roughSVGDrawWithPrecision( rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); if (opacity !== 1) { node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`); } node.setAttribute("stroke-linecap", "round"); node.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); const g = maybeWrapNodesInFrameClipPath( element, root, [node], renderConfig.frameRendering, elementsMap, ); addToRoot(g || node, element); break; } case "iframe": case "embeddable": { // render placeholder rectangle const shape = ShapeCache.generateElementShape(element, renderConfig); const node = roughSVGDrawWithPrecision( rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); const opacity = element.opacity / 100; if (opacity !== 1) { node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`); } node.setAttribute("stroke-linecap", "round"); node.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); addToRoot(node, element); const label = createPlaceholderEmbeddableLabel(element); renderElementToSvg( label, elementsMap, rsvg, root, files, label.x + offset.x - element.x, label.y + offset.y - element.y, renderConfig, ); // render embeddable element + iframe const embeddableNode = roughSVGDrawWithPrecision( rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); embeddableNode.setAttribute("stroke-linecap", "round"); embeddableNode.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); while (embeddableNode.firstChild) { embeddableNode.removeChild(embeddableNode.firstChild); } const radius = getCornerRadius( Math.min(element.width, element.height), element, ); const embedLink = getEmbedLink(toValidURL(element.link || "")); // if rendering embeddables explicitly disabled or // embedding documents via srcdoc (which doesn't seem to work for SVGs) // replace with a link instead if ( renderConfig.renderEmbeddables === false || embedLink?.type === "document" ) { const anchorTag = svgRoot.ownerDocument.createElementNS(SVG_NS, "a"); anchorTag.setAttribute("href", normalizeLink(element.link || "")); anchorTag.setAttribute("target", "_blank"); anchorTag.setAttribute("rel", "noopener noreferrer"); anchorTag.style.borderRadius = `${radius}px`; embeddableNode.appendChild(anchorTag); } else { const foreignObject = svgRoot.ownerDocument.createElementNS( SVG_NS, "foreignObject", ); foreignObject.style.width = `${element.width}px`; foreignObject.style.height = `${element.height}px`; foreignObject.style.border = "none"; const div = foreignObject.ownerDocument.createElementNS(SVG_NS, "div"); div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); div.style.width = "100%"; div.style.height = "100%"; const iframe = div.ownerDocument.createElement("iframe"); iframe.src = embedLink?.link ?? ""; iframe.style.width = "100%"; iframe.style.height = "100%"; iframe.style.border = "none"; iframe.style.borderRadius = `${radius}px`; iframe.style.top = "0"; iframe.style.left = "0"; iframe.allowFullscreen = true; div.appendChild(iframe); foreignObject.appendChild(div); embeddableNode.appendChild(foreignObject); } addToRoot(embeddableNode, element); break; } case "line": case "arrow": { const boundText = getBoundTextElement(element, elementsMap); const maskPath = svgRoot.ownerDocument.createElementNS(SVG_NS, "mask"); if (boundText) { maskPath.setAttribute("id", `mask-${element.id}`); const maskRectVisible = svgRoot.ownerDocument.createElementNS( SVG_NS, "rect", ); offsetX = offsetX || 0; offsetY = offsetY || 0; // Pin the mask to user space; the default maskUnits="objectBoundingBox" // collapses to zero area for axis-aligned arrows (zero-size bbox), // hiding the whole line from SVG exports (#11439). maskPath.setAttribute("maskUnits", "userSpaceOnUse"); maskPath.setAttribute("x", "0"); maskPath.setAttribute("y", "0"); maskPath.setAttribute("width", `${element.width + 100 + offsetX}`); maskPath.setAttribute("height", `${element.height + 100 + offsetY}`); maskRectVisible.setAttribute("x", "0"); maskRectVisible.setAttribute("y", "0"); maskRectVisible.setAttribute("fill", "#fff"); maskRectVisible.setAttribute( "width", `${element.width + 100 + offsetX}`, ); maskRectVisible.setAttribute( "height", `${element.height + 100 + offsetY}`, ); maskPath.appendChild(maskRectVisible); const maskRectInvisible = svgRoot.ownerDocument.createElementNS( SVG_NS, "rect", ); const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( element, boundText, elementsMap, ); // the same padded hole the canvas renderers cut around the label const maskX = offsetX + boundTextCoords.x - element.x - BOUND_TEXT_PADDING; const maskY = offsetY + boundTextCoords.y - element.y - BOUND_TEXT_PADDING; maskRectInvisible.setAttribute("x", maskX.toString()); maskRectInvisible.setAttribute("y", maskY.toString()); maskRectInvisible.setAttribute("fill", "#000"); maskRectInvisible.setAttribute( "width", `${boundText.width + BOUND_TEXT_PADDING * 2}`, ); maskRectInvisible.setAttribute( "height", `${boundText.height + BOUND_TEXT_PADDING * 2}`, ); maskRectInvisible.setAttribute("opacity", "1"); maskPath.appendChild(maskRectInvisible); } const group = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); if (boundText) { group.setAttribute("mask", `url(#mask-${element.id})`); } group.setAttribute("stroke-linecap", "round"); const shapes = ShapeCache.generateElementShape(element, renderConfig); shapes.forEach((shape) => { const node = roughSVGDrawWithPrecision( rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); if (opacity !== 1) { node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`); } node.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); if ( element.type === "line" && isPathALoop(element.points) && element.backgroundColor !== "transparent" ) { node.setAttribute("fill-rule", "evenodd"); } group.appendChild(node); }); const g = maybeWrapNodesInFrameClipPath( element, root, [group, maskPath], renderConfig.frameRendering, elementsMap, ); if (g) { addToRoot(g, element); root.appendChild(g); } else { addToRoot(group, element); root.append(maskPath); } break; } case "freedraw": { const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); const shapes = ShapeCache.generateElementShape(element, renderConfig); // always ordered as [background, stroke] for (const shape of shapes) { if (typeof shape === "string") { // stroke (SVGPathString) const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path"); path.setAttribute( "fill", applyDarkModeFilter( element.strokeColor, renderConfig.theme === THEME.DARK, ), ); path.setAttribute("d", shape); wrapper.appendChild(path); } else { // background (Drawable) const bgNode = roughSVGDrawWithPrecision( rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); // if children wrapped in , unwrap it if (bgNode.nodeName === "g") { while (bgNode.firstChild) { wrapper.appendChild(bgNode.firstChild); } } else { wrapper.appendChild(bgNode); } } } if (opacity !== 1) { wrapper.setAttribute("stroke-opacity", `${opacity}`); wrapper.setAttribute("fill-opacity", `${opacity}`); } wrapper.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); wrapper.setAttribute("stroke", "none"); const g = maybeWrapNodesInFrameClipPath( element, root, [wrapper], renderConfig.frameRendering, elementsMap, ); addToRoot(g || wrapper, element); break; } case "image": { const width = Math.round(element.width); const height = Math.round(element.height); const fileData = isInitializedImageElement(element) && files[element.fileId]; if (fileData) { const { reuseImages = true } = renderConfig; let symbolId = `image-${fileData.id}`; let uncroppedWidth = element.width; let uncroppedHeight = element.height; if (element.crop) { ({ width: uncroppedWidth, height: uncroppedHeight } = getUncroppedWidthAndHeight(element)); symbolId = `image-crop-${fileData.id}-${hashString( `${uncroppedWidth}x${uncroppedHeight}`, )}`; } if (!reuseImages) { symbolId = `image-${element.id}`; } let symbol = svgRoot.querySelector(`#${symbolId}`); if (!symbol) { symbol = svgRoot.ownerDocument.createElementNS(SVG_NS, "symbol"); symbol.id = symbolId; const image = svgRoot.ownerDocument.createElementNS(SVG_NS, "image"); image.setAttribute("href", fileData.dataURL); image.setAttribute("preserveAspectRatio", "none"); if (element.crop || !reuseImages) { image.setAttribute("width", `${uncroppedWidth}`); image.setAttribute("height", `${uncroppedHeight}`); } else { image.setAttribute("width", "100%"); image.setAttribute("height", "100%"); } symbol.appendChild(image); (root.querySelector("defs") || root).prepend(symbol); } const use = svgRoot.ownerDocument.createElementNS(SVG_NS, "use"); use.setAttribute("href", `#${symbolId}`); let normalizedCropX = 0; let normalizedCropY = 0; if (element.crop) { const { width: uncroppedWidth, height: uncroppedHeight } = getUncroppedWidthAndHeight(element); normalizedCropX = element.crop.x / (element.crop.naturalWidth / uncroppedWidth); normalizedCropY = element.crop.y / (element.crop.naturalHeight / uncroppedHeight); } const adjustedCenterX = cx + normalizedCropX; const adjustedCenterY = cy + normalizedCropY; use.setAttribute("width", `${width + normalizedCropX}`); use.setAttribute("height", `${height + normalizedCropY}`); use.setAttribute("opacity", `${opacity}`); // We first apply `scale` transforms (horizontal/vertical mirroring) // on the element, then apply translation and rotation // on the element which wraps the . // Doing this separately is a quick hack to to work around compositing // the transformations correctly (the transform-origin was not being // applied correctly). if (element.scale[0] !== 1 || element.scale[1] !== 1) { use.setAttribute( "transform", `translate(${adjustedCenterX} ${adjustedCenterY}) scale(${ element.scale[0] } ${ element.scale[1] }) translate(${-adjustedCenterX} ${-adjustedCenterY})`, ); } const g = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); if ( renderConfig.theme === THEME.DARK && fileData.mimeType === MIME_TYPES.svg ) { g.setAttribute("filter", DARK_THEME_FILTER); } if (element.crop) { const mask = svgRoot.ownerDocument.createElementNS(SVG_NS, "mask"); mask.setAttribute("id", `mask-image-crop-${element.id}`); mask.setAttribute("fill", "#fff"); const maskRect = svgRoot.ownerDocument.createElementNS( SVG_NS, "rect", ); maskRect.setAttribute("x", `${normalizedCropX}`); maskRect.setAttribute("y", `${normalizedCropY}`); maskRect.setAttribute("width", `${width}`); maskRect.setAttribute("height", `${height}`); mask.appendChild(maskRect); root.appendChild(mask); g.setAttribute("mask", `url(#${mask.id})`); } g.appendChild(use); g.setAttribute( "transform", `translate(${offsetX - normalizedCropX} ${ offsetY - normalizedCropY }) rotate(${degree} ${adjustedCenterX} ${adjustedCenterY})`, ); if (element.roundness) { const clipPath = svgRoot.ownerDocument.createElementNS( SVG_NS, "clipPath", ); clipPath.id = `image-clipPath-${element.id}`; clipPath.setAttribute("clipPathUnits", "userSpaceOnUse"); const clipRect = svgRoot.ownerDocument.createElementNS( SVG_NS, "rect", ); const radius = getCornerRadius( Math.min(element.width, element.height), element, ); const clipOffsetX = element.crop ? normalizedCropX : 0; const clipOffsetY = element.crop ? normalizedCropY : 0; clipRect.setAttribute("x", `${clipOffsetX}`); clipRect.setAttribute("y", `${clipOffsetY}`); clipRect.setAttribute("width", `${element.width}`); clipRect.setAttribute("height", `${element.height}`); clipRect.setAttribute("rx", `${radius}`); clipRect.setAttribute("ry", `${radius}`); clipPath.appendChild(clipRect); addToRoot(clipPath, element); g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); } const clipG = maybeWrapNodesInFrameClipPath( element, root, [g], renderConfig.frameRendering, elementsMap, ); addToRoot(clipG || g, element); } break; } // frames are not rendered and only acts as a container case "frame": case "magicframe": { if ( renderConfig.frameRendering.enabled && renderConfig.frameRendering.outline ) { const rect = document.createElementNS(SVG_NS, "rect"); rect.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); rect.setAttribute("width", `${element.width}px`); rect.setAttribute("height", `${element.height}px`); // Rounded corners rect.setAttribute("rx", FRAME_STYLE.radius.toString()); rect.setAttribute("ry", FRAME_STYLE.radius.toString()); rect.setAttribute("fill", "none"); rect.setAttribute( "stroke", applyDarkModeFilter( FRAME_STYLE.strokeColor, renderConfig.theme === THEME.DARK, ), ); rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); addToRoot(rect, element); } break; } default: { if (isTextElement(element)) { const node = svgRoot.ownerDocument.createElementNS(SVG_NS, "g"); if (opacity !== 1) { node.setAttribute("stroke-opacity", `${opacity}`); node.setAttribute("fill-opacity", `${opacity}`); } node.setAttribute( "transform", `translate(${offsetX || 0} ${ offsetY || 0 }) rotate(${degree} ${cx} ${cy})`, ); const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const lineHeightPx = getLineHeightInPx( element.fontSize, element.lineHeight, ); const horizontalOffset = element.textAlign === "center" ? element.width / 2 : element.textAlign === "right" ? element.width : 0; const verticalOffset = getVerticalOffset( element.fontFamily, element.fontSize, lineHeightPx, ); const direction = isRTL(element.text) ? "rtl" : "ltr"; const textAnchor = element.textAlign === "center" ? "middle" : element.textAlign === "right" || direction === "rtl" ? "end" : "start"; for (let i = 0; i < lines.length; i++) { const text = svgRoot.ownerDocument.createElementNS(SVG_NS, "text"); text.textContent = lines[i]; text.setAttribute("x", `${horizontalOffset}`); text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`); text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute( "fill", applyDarkModeFilter( element.strokeColor, renderConfig.theme === THEME.DARK, ), ); text.setAttribute("text-anchor", textAnchor); text.setAttribute("style", "white-space: pre;"); text.setAttribute("direction", direction); text.setAttribute("dominant-baseline", "alphabetic"); node.appendChild(text); } const g = maybeWrapNodesInFrameClipPath( element, root, [node], renderConfig.frameRendering, elementsMap, ); addToRoot(g || node, element); } else { // @ts-ignore throw new Error(`Unimplemented type ${element.type}`); } } } }; export const renderSceneToSvg = ( elements: readonly NonDeletedExcalidrawElement[], elementsMap: RenderableElementsMap, rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, renderConfig: SVGRenderConfig, ) => { if (!svgRoot) { return; } // render elements elements .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { if (!element.isDeleted) { if ( isTextElement(element) && element.containerId && elementsMap.has(element.containerId) ) { // will be rendered with the container return; } try { renderElementToSvg( element, elementsMap, rsvg, svgRoot, files, element.x + renderConfig.offsetX, element.y + renderConfig.offsetY, renderConfig, ); const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement?.isDeleted === false) { renderElementToSvg( boundTextElement as Readonly, elementsMap, rsvg, svgRoot, files, boundTextElement.x + renderConfig.offsetX, boundTextElement.y + renderConfig.offsetY, renderConfig, ); } else if (boundTextElement) { // SAFETY: This should never happen, but log it just in case console.error( "[NONDELETED][INVARIANT] Skipped rendering deleted bound text element", ); } } catch (error: any) { console.error(error); } } }); // render embeddables on top elements .filter((el) => isIframeLikeElement(el)) .forEach((element) => { if (!element.isDeleted) { try { renderElementToSvg( element, elementsMap, rsvg, svgRoot, files, element.x + renderConfig.offsetX, element.y + renderConfig.offsetY, renderConfig, ); } catch (error: any) { console.error(error); } } }); };