diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/renderer/staticSvgScene.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/renderer/staticSvgScene.ts')
| -rw-r--r-- | packages/excalidraw/renderer/staticSvgScene.ts | 744 |
1 files changed, 744 insertions, 0 deletions
diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts new file mode 100644 index 0000000..b14faf7 --- /dev/null +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -0,0 +1,744 @@ +import type { Drawable } from "roughjs/bin/core"; +import type { RoughSVG } from "roughjs/bin/svg"; +import { + FRAME_STYLE, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, +} from "../constants"; +import { normalizeLink, toValidURL } from "../data/url"; +import { getElementAbsoluteCoords, hashString } from "../element"; +import { + createPlaceholderEmbeddableLabel, + getEmbedLink, +} from "../element/embeddable"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + getBoundTextElement, + getContainerElement, +} from "../element/textElement"; +import { + isArrowElement, + isIframeLikeElement, + isInitializedImageElement, + isTextElement, +} from "../element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getContainingFrame } from "../frame"; +import { ShapeCache } from "../scene/ShapeCache"; +import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; +import type { AppState, BinaryFiles } from "../types"; +import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; +import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; +import { getVerticalOffset } from "../fonts"; +import { getCornerRadius, isPathALoop } from "../shapes"; +import { getUncroppedWidthAndHeight } from "../element/cropElement"; +import { getLineHeightInPx } from "../element/textMeasurements"; + +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: NonDeletedExcalidrawElement, + 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: NonDeletedExcalidrawElement, + 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 "rectangle": + case "diamond": + case "ellipse": { + const shape = ShapeCache.generateElementShape(element, null); + 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: ExcalidrawElement = + 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; + 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, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + 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 backgroundFillShape = ShapeCache.generateElementShape( + element, + renderConfig, + ); + const node = backgroundFillShape + ? roughSVGDrawWithPrecision( + rsvg, + backgroundFillShape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ) + : 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})`, + ); + node.setAttribute("stroke", "none"); + const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", element.strokeColor); + path.setAttribute("d", getFreeDrawSvgPath(element)); + node.appendChild(path); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, 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}`); + + // in dark theme, revert the image color filter + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { + use.setAttribute("filter", IMAGE_INVERT_FILTER); + } + + 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 <use> element, then apply translation and rotation + // on the <g> element which wraps the <use>. + // 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 (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}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + 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", FRAME_STYLE.strokeColor); + 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", element.strokeColor); + 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) { + renderElementToSvg( + boundTextElement, + elementsMap, + rsvg, + svgRoot, + files, + boundTextElement.x + renderConfig.offsetX, + boundTextElement.y + renderConfig.offsetY, + renderConfig, + ); + } + } 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); + } + } + }); +}; |
