|
@@ -18,6 +18,8 @@ import {
|
|
|
SVG_NS,
|
|
|
THEME,
|
|
|
THEME_FILTER,
|
|
|
+ MIME_TYPES,
|
|
|
+ EXPORT_DATA_TYPES,
|
|
|
} from "../constants";
|
|
|
import { getDefaultAppState } from "../appState";
|
|
|
import { serializeAsJSON } from "../data/json";
|
|
@@ -39,8 +41,7 @@ import type { RenderableElementsMap } from "./types";
|
|
|
import { syncInvalidIndices } from "../fractionalIndex";
|
|
|
import { renderStaticScene } from "../renderer/staticScene";
|
|
|
import { Fonts } from "../fonts";
|
|
|
-
|
|
|
-const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
|
|
+import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
|
|
|
|
|
|
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
|
|
if (element.width <= maxWidth) {
|
|
@@ -254,6 +255,13 @@ export const exportToCanvas = async (
|
|
|
return canvas;
|
|
|
};
|
|
|
|
|
|
+const createHTMLComment = (text: string) => {
|
|
|
+ // surrounding with spaces to maintain prettified consistency with previous
|
|
|
+ // iterations
|
|
|
+ // <!-- comment -->
|
|
|
+ return document.createComment(` ${text} `);
|
|
|
+};
|
|
|
+
|
|
|
export const exportToSvg = async (
|
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
|
|
appState: {
|
|
@@ -302,87 +310,128 @@ export const exportToSvg = async (
|
|
|
exportPadding = 0;
|
|
|
}
|
|
|
|
|
|
- let metadata = "";
|
|
|
+ const [minX, minY, width, height] = getCanvasSize(
|
|
|
+ exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
|
|
+ exportPadding,
|
|
|
+ );
|
|
|
+
|
|
|
+ const offsetX = -minX + exportPadding;
|
|
|
+ const offsetY = -minY + exportPadding;
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // initialize SVG root element
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+ const svgRoot = document.createElementNS(SVG_NS, "svg");
|
|
|
+
|
|
|
+ svgRoot.setAttribute("version", "1.1");
|
|
|
+ svgRoot.setAttribute("xmlns", SVG_NS);
|
|
|
+ svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
|
+ svgRoot.setAttribute("width", `${width * exportScale}`);
|
|
|
+ svgRoot.setAttribute("height", `${height * exportScale}`);
|
|
|
+ if (exportWithDarkMode) {
|
|
|
+ svgRoot.setAttribute("filter", THEME_FILTER);
|
|
|
+ }
|
|
|
+
|
|
|
+ const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
|
|
+
|
|
|
+ const metadataElement = svgRoot.ownerDocument.createElementNS(
|
|
|
+ SVG_NS,
|
|
|
+ "metadata",
|
|
|
+ );
|
|
|
+
|
|
|
+ svgRoot.appendChild(createHTMLComment("svg-source:excalidraw"));
|
|
|
+ svgRoot.appendChild(metadataElement);
|
|
|
+ svgRoot.appendChild(defsElement);
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // scene embed
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
|
|
|
// we need to serialize the "original" elements before we put them through
|
|
|
// the tempScene hack which duplicates and regenerates ids
|
|
|
if (exportEmbedScene) {
|
|
|
try {
|
|
|
- metadata = (await import("../data/image")).encodeSvgMetadata({
|
|
|
+ encodeSvgBase64Payload({
|
|
|
+ metadataElement,
|
|
|
// when embedding scene, we want to embed the origionally supplied
|
|
|
// elements which don't contain the temp frame labels.
|
|
|
// But it also requires that the exportToSvg is being supplied with
|
|
|
// only the elements that we're exporting, and no extra.
|
|
|
- text: serializeAsJSON(elements, appState, files || {}, "local"),
|
|
|
+ payload: serializeAsJSON(elements, appState, files || {}, "local"),
|
|
|
});
|
|
|
} catch (error: any) {
|
|
|
console.error(error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- const [minX, minY, width, height] = getCanvasSize(
|
|
|
- exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
|
|
- exportPadding,
|
|
|
- );
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // frame clip paths
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
|
|
|
- // initialize SVG root
|
|
|
- const svgRoot = document.createElementNS(SVG_NS, "svg");
|
|
|
- svgRoot.setAttribute("version", "1.1");
|
|
|
- svgRoot.setAttribute("xmlns", SVG_NS);
|
|
|
- svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
|
- svgRoot.setAttribute("width", `${width * exportScale}`);
|
|
|
- svgRoot.setAttribute("height", `${height * exportScale}`);
|
|
|
- if (exportWithDarkMode) {
|
|
|
- svgRoot.setAttribute("filter", THEME_FILTER);
|
|
|
- }
|
|
|
+ const frameElements = getFrameLikeElements(elements);
|
|
|
|
|
|
- const offsetX = -minX + exportPadding;
|
|
|
- const offsetY = -minY + exportPadding;
|
|
|
+ if (frameElements.length) {
|
|
|
+ const elementsMap = arrayToMap(elements);
|
|
|
+
|
|
|
+ for (const frame of frameElements) {
|
|
|
+ const clipPath = svgRoot.ownerDocument.createElementNS(
|
|
|
+ SVG_NS,
|
|
|
+ "clipPath",
|
|
|
+ );
|
|
|
+
|
|
|
+ clipPath.setAttribute("id", frame.id);
|
|
|
+
|
|
|
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
|
|
+ const cx = (x2 - x1) / 2 - (frame.x - x1);
|
|
|
+ const cy = (y2 - y1) / 2 - (frame.y - y1);
|
|
|
+
|
|
|
+ const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
|
|
+ rect.setAttribute(
|
|
|
+ "transform",
|
|
|
+ `translate(${frame.x + offsetX} ${frame.y + offsetY}) rotate(${
|
|
|
+ frame.angle
|
|
|
+ } ${cx} ${cy})`,
|
|
|
+ );
|
|
|
+ rect.setAttribute("width", `${frame.width}`);
|
|
|
+ rect.setAttribute("height", `${frame.height}`);
|
|
|
+
|
|
|
+ if (!exportingFrame) {
|
|
|
+ rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
|
|
|
+ rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
|
|
|
+ }
|
|
|
|
|
|
- const frameElements = getFrameLikeElements(elements);
|
|
|
+ clipPath.appendChild(rect);
|
|
|
|
|
|
- let exportingFrameClipPath = "";
|
|
|
- const elementsMap = arrayToMap(elements);
|
|
|
- for (const frame of frameElements) {
|
|
|
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
|
|
- const cx = (x2 - x1) / 2 - (frame.x - x1);
|
|
|
- const cy = (y2 - y1) / 2 - (frame.y - y1);
|
|
|
-
|
|
|
- exportingFrameClipPath += `<clipPath id=${frame.id}>
|
|
|
- <rect transform="translate(${frame.x + offsetX} ${
|
|
|
- frame.y + offsetY
|
|
|
- }) rotate(${frame.angle} ${cx} ${cy})"
|
|
|
- width="${frame.width}"
|
|
|
- height="${frame.height}"
|
|
|
- ${
|
|
|
- exportingFrame
|
|
|
- ? ""
|
|
|
- : `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
|
|
- }
|
|
|
- >
|
|
|
- </rect>
|
|
|
- </clipPath>`;
|
|
|
+ defsElement.appendChild(clipPath);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // inline font faces
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
const fontFaces = !opts?.skipInliningFonts
|
|
|
? await Fonts.generateFontFaceDeclarations(elements)
|
|
|
: [];
|
|
|
|
|
|
const delimiter = "\n "; // 6 spaces
|
|
|
|
|
|
- svgRoot.innerHTML = `
|
|
|
- ${SVG_EXPORT_TAG}
|
|
|
- ${metadata}
|
|
|
- <defs>
|
|
|
- <style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
|
|
|
- </style>
|
|
|
- ${exportingFrameClipPath}
|
|
|
- </defs>
|
|
|
- `;
|
|
|
+ const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
|
|
|
+ style.classList.add("style-fonts");
|
|
|
+ style.appendChild(
|
|
|
+ document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
|
|
|
+ );
|
|
|
+
|
|
|
+ defsElement.appendChild(style);
|
|
|
+
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // background
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
|
|
|
// render background rect
|
|
|
if (appState.exportBackground && viewBackgroundColor) {
|
|
|
- const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
|
|
+ const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
|
|
rect.setAttribute("x", "0");
|
|
|
rect.setAttribute("y", "0");
|
|
|
rect.setAttribute("width", `${width}`);
|
|
@@ -391,6 +440,10 @@ export const exportToSvg = async (
|
|
|
svgRoot.appendChild(rect);
|
|
|
}
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // render elements
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
const rsvg = rough.svg(svgRoot);
|
|
|
|
|
|
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
|
@@ -420,9 +473,66 @@ export const exportToSvg = async (
|
|
|
},
|
|
|
);
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
return svgRoot;
|
|
|
};
|
|
|
|
|
|
+export const encodeSvgBase64Payload = ({
|
|
|
+ payload,
|
|
|
+ metadataElement,
|
|
|
+}: {
|
|
|
+ payload: string;
|
|
|
+ metadataElement: SVGMetadataElement;
|
|
|
+}) => {
|
|
|
+ const base64 = stringToBase64(
|
|
|
+ JSON.stringify(encode({ text: payload })),
|
|
|
+ true /* is already byte string */,
|
|
|
+ );
|
|
|
+
|
|
|
+ metadataElement.appendChild(
|
|
|
+ createHTMLComment(`payload-type:${MIME_TYPES.excalidraw}`),
|
|
|
+ );
|
|
|
+ metadataElement.appendChild(createHTMLComment("payload-version:2"));
|
|
|
+ metadataElement.appendChild(createHTMLComment("payload-start"));
|
|
|
+ metadataElement.appendChild(document.createTextNode(base64));
|
|
|
+ metadataElement.appendChild(createHTMLComment("payload-end"));
|
|
|
+};
|
|
|
+
|
|
|
+export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
|
|
|
+ if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
|
|
+ const match = svg.match(
|
|
|
+ /<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
|
|
+ );
|
|
|
+ if (!match) {
|
|
|
+ throw new Error("INVALID");
|
|
|
+ }
|
|
|
+ const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
|
|
|
+ const version = versionMatch?.[1] || "1";
|
|
|
+ const isByteString = version !== "1";
|
|
|
+
|
|
|
+ try {
|
|
|
+ const json = base64ToString(match[1], isByteString);
|
|
|
+ const encodedData = JSON.parse(json);
|
|
|
+ if (!("encoded" in encodedData)) {
|
|
|
+ // legacy, un-encoded scene JSON
|
|
|
+ if (
|
|
|
+ "type" in encodedData &&
|
|
|
+ encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
|
|
+ ) {
|
|
|
+ return json;
|
|
|
+ }
|
|
|
+ throw new Error("FAILED");
|
|
|
+ }
|
|
|
+ return decode(encodedData);
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error(error);
|
|
|
+ throw new Error("FAILED");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new Error("INVALID");
|
|
|
+};
|
|
|
+
|
|
|
// calculate smallest area to fit the contents in
|
|
|
const getCanvasSize = (
|
|
|
elements: readonly NonDeletedExcalidrawElement[],
|