snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
933
packages/excalidraw/renderer/staticSvgScene.ts
Normal file
933
packages/excalidraw/renderer/staticSvgScene.ts
Normal file
@@ -0,0 +1,933 @@
|
||||
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<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: Readonly<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 "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 <g>, 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 <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 (
|
||||
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<NonDeletedExcalidrawElement>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user