aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/scene/export.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/scene/export.ts')
-rw-r--r--packages/excalidraw/scene/export.ts558
1 files changed, 558 insertions, 0 deletions
diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts
new file mode 100644
index 0000000..7b33256
--- /dev/null
+++ b/packages/excalidraw/scene/export.ts
@@ -0,0 +1,558 @@
+import rough from "roughjs/bin/rough";
+import type {
+ ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+ ExcalidrawTextElement,
+ NonDeletedExcalidrawElement,
+ NonDeletedSceneElementsMap,
+} from "../element/types";
+import type { Bounds } from "../element/bounds";
+import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
+import { renderSceneToSvg } from "../renderer/staticSvgScene";
+import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
+import type { AppState, BinaryFiles } from "../types";
+import {
+ DEFAULT_EXPORT_PADDING,
+ FRAME_STYLE,
+ FONT_FAMILY,
+ SVG_NS,
+ THEME,
+ THEME_FILTER,
+ MIME_TYPES,
+ EXPORT_DATA_TYPES,
+} from "../constants";
+import { getDefaultAppState } from "../appState";
+import { serializeAsJSON } from "../data/json";
+import {
+ getInitializedImageElements,
+ updateImageCache,
+} from "../element/image";
+import {
+ getElementsOverlappingFrame,
+ getFrameLikeElements,
+ getFrameLikeTitle,
+ getRootElements,
+} from "../frame";
+import { newTextElement } from "../element";
+import { type Mutable } from "../utility-types";
+import { newElementWith } from "../element/mutateElement";
+import { isFrameLikeElement } from "../element/typeChecks";
+import type { RenderableElementsMap } from "./types";
+import { syncInvalidIndices } from "../fractionalIndex";
+import { renderStaticScene } from "../renderer/staticScene";
+import { Fonts } from "../fonts";
+import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
+
+const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
+ if (element.width <= maxWidth) {
+ return element;
+ }
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d")!;
+ ctx.font = getFontString({
+ fontFamily: element.fontFamily,
+ fontSize: element.fontSize,
+ });
+
+ let text = element.text;
+
+ const metrics = ctx.measureText(text);
+
+ if (metrics.width > maxWidth) {
+ // we iterate from the right, removing characters one by one instead
+ // of bulding the string up. This assumes that it's more likely
+ // your frame names will overflow by not that many characters
+ // (if ever), so it sohuld be faster this way.
+ for (let i = text.length; i > 0; i--) {
+ const newText = `${text.slice(0, i)}...`;
+ if (ctx.measureText(newText).width <= maxWidth) {
+ text = newText;
+ break;
+ }
+ }
+ }
+ return newElementWith(element, { text, width: maxWidth });
+};
+
+/**
+ * When exporting frames, we need to render frame labels which are currently
+ * being rendered in DOM when editing. Adding the labels as regular text
+ * elements seems like a simple hack. In the future we'll want to move to
+ * proper canvas rendering, even within editor (instead of DOM).
+ */
+const addFrameLabelsAsTextElements = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ opts: Pick<AppState, "exportWithDarkMode">,
+) => {
+ const nextElements: NonDeletedExcalidrawElement[] = [];
+ for (const element of elements) {
+ if (isFrameLikeElement(element)) {
+ let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
+ x: element.x,
+ y: element.y - FRAME_STYLE.nameOffsetY,
+ fontFamily: FONT_FAMILY.Helvetica,
+ fontSize: FRAME_STYLE.nameFontSize,
+ lineHeight:
+ FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
+ strokeColor: opts.exportWithDarkMode
+ ? FRAME_STYLE.nameColorDarkTheme
+ : FRAME_STYLE.nameColorLightTheme,
+ text: getFrameLikeTitle(element),
+ });
+ textElement.y -= textElement.height;
+
+ textElement = truncateText(textElement, element.width);
+
+ nextElements.push(textElement);
+ }
+ nextElements.push(element);
+ }
+
+ return nextElements;
+};
+
+const getFrameRenderingConfig = (
+ exportingFrame: ExcalidrawFrameLikeElement | null,
+ frameRendering: AppState["frameRendering"] | null,
+): AppState["frameRendering"] => {
+ frameRendering = frameRendering || getDefaultAppState().frameRendering;
+ return {
+ enabled: exportingFrame ? true : frameRendering.enabled,
+ outline: exportingFrame ? false : frameRendering.outline,
+ name: exportingFrame ? false : frameRendering.name,
+ clip: exportingFrame ? true : frameRendering.clip,
+ };
+};
+
+const prepareElementsForRender = ({
+ elements,
+ exportingFrame,
+ frameRendering,
+ exportWithDarkMode,
+}: {
+ elements: readonly ExcalidrawElement[];
+ exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
+ frameRendering: AppState["frameRendering"];
+ exportWithDarkMode: AppState["exportWithDarkMode"];
+}) => {
+ let nextElements: readonly ExcalidrawElement[];
+
+ if (exportingFrame) {
+ nextElements = getElementsOverlappingFrame(elements, exportingFrame);
+ } else if (frameRendering.enabled && frameRendering.name) {
+ nextElements = addFrameLabelsAsTextElements(elements, {
+ exportWithDarkMode,
+ });
+ } else {
+ nextElements = elements;
+ }
+
+ return nextElements;
+};
+
+export const exportToCanvas = async (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ {
+ exportBackground,
+ exportPadding = DEFAULT_EXPORT_PADDING,
+ viewBackgroundColor,
+ exportingFrame,
+ }: {
+ exportBackground: boolean;
+ exportPadding?: number;
+ viewBackgroundColor: string;
+ exportingFrame?: ExcalidrawFrameLikeElement | null;
+ },
+ createCanvas: (
+ width: number,
+ height: number,
+ ) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = width * appState.exportScale;
+ canvas.height = height * appState.exportScale;
+ return { canvas, scale: appState.exportScale };
+ },
+ loadFonts: () => Promise<void> = async () => {
+ await Fonts.loadElementsFonts(elements);
+ },
+) => {
+ // load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
+ await loadFonts();
+
+ const frameRendering = getFrameRenderingConfig(
+ exportingFrame ?? null,
+ appState.frameRendering ?? null,
+ );
+ // for canvas export, don't clip if exporting a specific frame as it would
+ // clip the corners of the content
+ if (exportingFrame) {
+ frameRendering.clip = false;
+ }
+
+ const elementsForRender = prepareElementsForRender({
+ elements,
+ exportingFrame,
+ exportWithDarkMode: appState.exportWithDarkMode,
+ frameRendering,
+ });
+
+ if (exportingFrame) {
+ exportPadding = 0;
+ }
+
+ const [minX, minY, width, height] = getCanvasSize(
+ exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
+ exportPadding,
+ );
+
+ const { canvas, scale = 1 } = createCanvas(width, height);
+
+ const defaultAppState = getDefaultAppState();
+
+ const { imageCache } = await updateImageCache({
+ imageCache: new Map(),
+ fileIds: getInitializedImageElements(elementsForRender).map(
+ (element) => element.fileId,
+ ),
+ files,
+ });
+
+ renderStaticScene({
+ canvas,
+ rc: rough.canvas(canvas),
+ elementsMap: toBrandedType<RenderableElementsMap>(
+ arrayToMap(elementsForRender),
+ ),
+ allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
+ arrayToMap(syncInvalidIndices(elements)),
+ ),
+ visibleElements: elementsForRender,
+ scale,
+ appState: {
+ ...appState,
+ frameRendering,
+ viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
+ scrollX: -minX + exportPadding,
+ scrollY: -minY + exportPadding,
+ zoom: defaultAppState.zoom,
+ shouldCacheIgnoreZoom: false,
+ theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
+ },
+ renderConfig: {
+ canvasBackgroundColor: viewBackgroundColor,
+ imageCache,
+ renderGrid: false,
+ isExporting: true,
+ // empty disables embeddable rendering
+ embedsValidationStatus: new Map(),
+ elementsPendingErasure: new Set(),
+ pendingFlowchartNodes: null,
+ },
+ });
+
+ 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: {
+ exportBackground: boolean;
+ exportPadding?: number;
+ exportScale?: number;
+ viewBackgroundColor: string;
+ exportWithDarkMode?: boolean;
+ exportEmbedScene?: boolean;
+ frameRendering?: AppState["frameRendering"];
+ },
+ files: BinaryFiles | null,
+ opts?: {
+ /**
+ * if true, all embeddables passed in will be rendered when possible.
+ */
+ renderEmbeddables?: boolean;
+ exportingFrame?: ExcalidrawFrameLikeElement | null;
+ skipInliningFonts?: true;
+ reuseImages?: boolean;
+ },
+): Promise<SVGSVGElement> => {
+ const frameRendering = getFrameRenderingConfig(
+ opts?.exportingFrame ?? null,
+ appState.frameRendering ?? null,
+ );
+
+ let {
+ exportPadding = DEFAULT_EXPORT_PADDING,
+ exportWithDarkMode = false,
+ viewBackgroundColor,
+ exportScale = 1,
+ exportEmbedScene,
+ } = appState;
+
+ const { exportingFrame = null } = opts || {};
+
+ const elementsForRender = prepareElementsForRender({
+ elements,
+ exportingFrame,
+ exportWithDarkMode,
+ frameRendering,
+ });
+
+ if (exportingFrame) {
+ exportPadding = 0;
+ }
+
+ 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 {
+ 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.
+ payload: serializeAsJSON(elements, appState, files || {}, "local"),
+ });
+ } catch (error: any) {
+ console.error(error);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // frame clip paths
+ // ---------------------------------------------------------------------------
+
+ const frameElements = getFrameLikeElements(elements);
+
+ 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}`);
+ }
+
+ clipPath.appendChild(rect);
+
+ defsElement.appendChild(clipPath);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // inline font faces
+ // ---------------------------------------------------------------------------
+
+ const fontFaces = !opts?.skipInliningFonts
+ ? await Fonts.generateFontFaceDeclarations(elements)
+ : [];
+
+ const delimiter = "\n "; // 6 spaces
+
+ 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");
+ rect.setAttribute("x", "0");
+ rect.setAttribute("y", "0");
+ rect.setAttribute("width", `${width}`);
+ rect.setAttribute("height", `${height}`);
+ rect.setAttribute("fill", viewBackgroundColor);
+ svgRoot.appendChild(rect);
+ }
+
+ // ---------------------------------------------------------------------------
+ // render elements
+ // ---------------------------------------------------------------------------
+
+ const rsvg = rough.svg(svgRoot);
+
+ const renderEmbeddables = opts?.renderEmbeddables ?? false;
+
+ renderSceneToSvg(
+ elementsForRender,
+ toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
+ rsvg,
+ svgRoot,
+ files || {},
+ {
+ offsetX,
+ offsetY,
+ isExporting: true,
+ exportWithDarkMode,
+ renderEmbeddables,
+ frameRendering,
+ canvasBackgroundColor: viewBackgroundColor,
+ embedsValidationStatus: renderEmbeddables
+ ? new Map(
+ elementsForRender
+ .filter((element) => isFrameLikeElement(element))
+ .map((element) => [element.id, true]),
+ )
+ : new Map(),
+ reuseImages: opts?.reuseImages ?? true,
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+
+ 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[],
+ exportPadding: number,
+): Bounds => {
+ const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+ const width = distance(minX, maxX) + exportPadding * 2;
+ const height = distance(minY, maxY) + exportPadding * 2;
+
+ return [minX, minY, width, height];
+};
+
+export const getExportSize = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ exportPadding: number,
+ scale: number,
+): [number, number] => {
+ const [, , width, height] = getCanvasSize(elements, exportPadding).map(
+ (dimension) => Math.trunc(dimension * scale),
+ );
+
+ return [width, height];
+};