ソースを参照

SVG export (#598)

* first draft of export to SVG. WIP

* enabled text rendeing - which is not quite right atm

* placeholder svg icon

* size the canvas based on the bounding box of elements

* Do not add opacity attributes if default

* render background rect

* Ensure arrows are in the same SVG group

* parse font-size from font

* export web fonts

* use fixed locations for fonts

* Rename export functions

* renamed export file

* oops broke the icon.
Preet 5 年 前
コミット
97b11b0f53

+ 1 - 0
public/locales/de/translation.json

@@ -12,6 +12,7 @@
     "copyToClipboard": "In die Zwischenablage kopieren",
     "export": "Export",
     "exportToPng": "Als PNG exportieren",
+    "exportToSvg": "Als SVG exportieren",
     "getShareableLink": "Teilbaren Link erhalten",
     "load": "Laden",
     "save": "Speichern"

+ 1 - 0
public/locales/en/translation.json

@@ -45,6 +45,7 @@
     "clearReset": "Clear the canvas & reset background color",
     "export": "Export",
     "exportToPng": "Export to PNG",
+    "exportToSvg": "Export to SVG",
     "copyToClipboard": "Copy to clipboard",
     "save": "Save",
     "load": "Load",

+ 1 - 0
public/locales/es/translation.json

@@ -45,6 +45,7 @@
     "clearReset": "Limpiar lienzo y reiniciar el color de fondo",
     "export": "Exportar",
     "exportToPng": "Exportar a PNG",
+    "exportToSvg": "Exportar a SVG",
     "copyToClipboard": "Copiar al portapapeles",
     "save": "Guardar",
     "load": "Cargar",

+ 1 - 0
public/locales/fr/translation.json

@@ -41,6 +41,7 @@
     "clearReset": "Effacer le canvas & réinitialiser la couleur d'arrière-plan",
     "export": "Exporter",
     "exportToPng": "Exporter en PNG",
+    "exportToSvg": "Exporter en SVG",
     "copyToClipboard": "Copier dans le presse-papier",
     "save": "Sauvegarder",
     "load": "Ouvrir",

+ 1 - 0
public/locales/pt/translation.json

@@ -41,6 +41,7 @@
     "clearReset": "Limpar o canvas e redefinir a cor de fundo",
     "export": "Exportar",
     "exportToPng": "Exportar em PNG",
+    "exportToSvg": "Exportar em SVG",
     "copyToClipboard": "Copiar para o clipboard",
     "save": "Guardar",
     "load": "Carregar",

+ 15 - 3
src/components/ExportDialog.tsx

@@ -4,11 +4,11 @@ import React, { useState, useEffect, useRef } from "react";
 
 import { Modal } from "./Modal";
 import { ToolButton } from "./ToolButton";
-import { clipboard, exportFile, downloadFile, link } from "./icons";
+import { clipboard, exportFile, downloadFile, svgFile, link } from "./icons";
 import { Island } from "./Island";
 import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
-import { getExportCanvasPreview } from "../scene/getExportCanvasPreview";
+import { exportToCanvas } from "../scene/export";
 import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
 import Stack from "./Stack";
 
@@ -36,6 +36,7 @@ function ExportModal({
   actionManager,
   syncActionResult,
   onExportToPng,
+  onExportToSvg,
   onExportToClipboard,
   onExportToBackend,
   onCloseRequest,
@@ -46,6 +47,7 @@ function ExportModal({
   actionManager: ActionsManagerInterface;
   syncActionResult: UpdaterFn;
   onExportToPng: ExportCB;
+  onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
   onExportToBackend: ExportCB;
   onCloseRequest: () => void;
@@ -70,7 +72,7 @@ function ExportModal({
 
   useEffect(() => {
     const previewNode = previewRef.current;
-    const canvas = getExportCanvasPreview(exportedElements, {
+    const canvas = exportToCanvas(exportedElements, {
       exportBackground,
       viewBackgroundColor,
       exportPadding,
@@ -136,6 +138,13 @@ function ExportModal({
               onClick={() => onExportToPng(exportedElements, scale)}
               ref={pngButton}
             />
+            <ToolButton
+              type="button"
+              icon={svgFile}
+              title={t("buttons.exportToSvg")}
+              aria-label={t("buttons.exportToSvg")}
+              onClick={() => onExportToSvg(exportedElements, scale)}
+            />
             {probablySupportsClipboard && (
               <ToolButton
                 type="button"
@@ -213,6 +222,7 @@ export function ExportDialog({
   actionManager,
   syncActionResult,
   onExportToPng,
+  onExportToSvg,
   onExportToClipboard,
   onExportToBackend,
 }: {
@@ -222,6 +232,7 @@ export function ExportDialog({
   actionManager: ActionsManagerInterface;
   syncActionResult: UpdaterFn;
   onExportToPng: ExportCB;
+  onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
   onExportToBackend: ExportCB;
 }) {
@@ -257,6 +268,7 @@ export function ExportDialog({
             actionManager={actionManager}
             syncActionResult={syncActionResult}
             onExportToPng={onExportToPng}
+            onExportToSvg={onExportToSvg}
             onExportToClipboard={onExportToClipboard}
             onExportToBackend={onExportToBackend}
             onCloseRequest={handleClose}

+ 9 - 0
src/components/icons.tsx

@@ -85,3 +85,12 @@ export const downloadFile = (
     />
   </svg>
 );
+
+export const svgFile = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 50 50">
+    <path
+      fill="currentColor"
+      d="M 19 8 L 19 20 L 31 20 L 31 8 Z M 3 11 C 1.34375 11 0 12.34375 0 14 C 0 15.65625 1.34375 17 3 17 C 4.300781 17 5.398438 16.160156 5.8125 15 L 17 15 C 12.121094 17.609375 8.785156 22.492188 8.125 28 L 10.15625 28 C 10.804688 23.21875 13.734375 18.980469 18 16.71875 L 18 13 L 5.8125 13 C 5.398438 11.839844 4.300781 11 3 11 Z M 47 11 C 45.699219 11 44.601563 11.839844 44.1875 13 L 32 13 L 32 16.71875 C 36.269531 18.976563 39.195313 23.203125 39.84375 28 L 41.875 28 C 41.21875 22.476563 37.882813 17.609375 33 15 L 44.1875 15 C 44.601563 16.160156 45.699219 17 47 17 C 48.65625 17 50 15.65625 50 14 C 50 12.34375 48.65625 11 47 11 Z M 3 29 L 3 41 L 15 41 L 15 29 Z M 35 29 L 35 41 L 47 41 L 47 29 Z"
+    ></path>
+  </svg>
+);

+ 2 - 2
src/index-node.ts

@@ -1,4 +1,4 @@
-import { getExportCanvasPreview } from "../src/scene/getExportCanvasPreview";
+import { exportToCanvas } from "./scene/export";
 
 const { registerFont, createCanvas } = require("canvas");
 
@@ -58,7 +58,7 @@ const elements = [
 
 registerFont("./public/FG_Virgil.ttf", { family: "Virgil" });
 registerFont("./public/Cascadia.ttf", { family: "Cascadia" });
-const canvas = getExportCanvasPreview(
+const canvas = exportToCanvas(
   elements as any,
   {
     exportBackground: true,

+ 10 - 0
src/index.tsx

@@ -564,6 +564,16 @@ export class App extends React.Component<any, AppState> {
                   scale,
                 });
             }}
+            onExportToSvg={(exportedElements, scale) => {
+              if (this.canvas) {
+                exportCanvas("svg", exportedElements, this.canvas, {
+                  exportBackground: this.state.exportBackground,
+                  name: this.state.name,
+                  viewBackgroundColor: this.state.viewBackgroundColor,
+                  scale,
+                });
+              }
+            }}
             onExportToClipboard={(exportedElements, scale) => {
               if (this.canvas)
                 exportCanvas("clipboard", exportedElements, this.canvas, {

+ 241 - 132
src/renderer/renderElement.ts

@@ -7,154 +7,263 @@ import {
 } from "../element/bounds";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
+import { RoughSVG } from "roughjs/bin/svg";
+import { RoughGenerator } from "roughjs/bin/generator";
+import { SVG_NS } from "../utils";
 
-export function renderElement(
+function generateElement(
   element: ExcalidrawElement,
-  rc: RoughCanvas,
-  context: CanvasRenderingContext2D,
+  generator: RoughGenerator,
 ) {
-  const generator = rc.generator;
-  if (element.type === "selection") {
-    const fillStyle = context.fillStyle;
-    context.fillStyle = "rgba(0, 0, 255, 0.10)";
-    context.fillRect(0, 0, element.width, element.height);
-    context.fillStyle = fillStyle;
-  } else if (element.type === "rectangle") {
-    if (!element.shape) {
-      element.shape = generator.rectangle(0, 0, element.width, element.height, {
-        stroke: element.strokeColor,
-        fill:
-          element.backgroundColor === "transparent"
-            ? undefined
-            : element.backgroundColor,
-        fillStyle: element.fillStyle,
-        strokeWidth: element.strokeWidth,
-        roughness: element.roughness,
-        seed: element.seed,
-      });
-    }
-
-    context.globalAlpha = element.opacity / 100;
-    rc.draw(element.shape as Drawable);
-    context.globalAlpha = 1;
-  } else if (element.type === "diamond") {
-    if (!element.shape) {
-      const [
-        topX,
-        topY,
-        rightX,
-        rightY,
-        bottomX,
-        bottomY,
-        leftX,
-        leftY,
-      ] = getDiamondPoints(element);
-      element.shape = generator.polygon(
-        [
-          [topX, topY],
-          [rightX, rightY],
-          [bottomX, bottomY],
-          [leftX, leftY],
-        ],
-        {
+  if (!element.shape) {
+    switch (element.type) {
+      case "rectangle":
+        element.shape = generator.rectangle(
+          0,
+          0,
+          element.width,
+          element.height,
+          {
+            stroke: element.strokeColor,
+            fill:
+              element.backgroundColor === "transparent"
+                ? undefined
+                : element.backgroundColor,
+            fillStyle: element.fillStyle,
+            strokeWidth: element.strokeWidth,
+            roughness: element.roughness,
+            seed: element.seed,
+          },
+        );
+        break;
+      case "diamond": {
+        const [
+          topX,
+          topY,
+          rightX,
+          rightY,
+          bottomX,
+          bottomY,
+          leftX,
+          leftY,
+        ] = getDiamondPoints(element);
+        element.shape = generator.polygon(
+          [
+            [topX, topY],
+            [rightX, rightY],
+            [bottomX, bottomY],
+            [leftX, leftY],
+          ],
+          {
+            stroke: element.strokeColor,
+            fill:
+              element.backgroundColor === "transparent"
+                ? undefined
+                : element.backgroundColor,
+            fillStyle: element.fillStyle,
+            strokeWidth: element.strokeWidth,
+            roughness: element.roughness,
+            seed: element.seed,
+          },
+        );
+        break;
+      }
+      case "ellipse":
+        element.shape = generator.ellipse(
+          element.width / 2,
+          element.height / 2,
+          element.width,
+          element.height,
+          {
+            stroke: element.strokeColor,
+            fill:
+              element.backgroundColor === "transparent"
+                ? undefined
+                : element.backgroundColor,
+            fillStyle: element.fillStyle,
+            strokeWidth: element.strokeWidth,
+            roughness: element.roughness,
+            seed: element.seed,
+            curveFitting: 1,
+          },
+        );
+        break;
+      case "arrow": {
+        const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
+        const options = {
           stroke: element.strokeColor,
-          fill:
-            element.backgroundColor === "transparent"
-              ? undefined
-              : element.backgroundColor,
-          fillStyle: element.fillStyle,
           strokeWidth: element.strokeWidth,
           roughness: element.roughness,
           seed: element.seed,
-        },
-      );
-    }
-
-    context.globalAlpha = element.opacity / 100;
-    rc.draw(element.shape as Drawable);
-    context.globalAlpha = 1;
-  } else if (element.type === "ellipse") {
-    if (!element.shape) {
-      element.shape = generator.ellipse(
-        element.width / 2,
-        element.height / 2,
-        element.width,
-        element.height,
-        {
+        };
+        element.shape = [
+          //    \
+          generator.line(x3, y3, x2, y2, options),
+          // -----
+          generator.line(x1, y1, x2, y2, options),
+          //    /
+          generator.line(x4, y4, x2, y2, options),
+        ];
+        break;
+      }
+      case "line": {
+        const [x1, y1, x2, y2] = getLinePoints(element);
+        const options = {
           stroke: element.strokeColor,
-          fill:
-            element.backgroundColor === "transparent"
-              ? undefined
-              : element.backgroundColor,
-          fillStyle: element.fillStyle,
           strokeWidth: element.strokeWidth,
           roughness: element.roughness,
           seed: element.seed,
-          curveFitting: 1,
-        },
-      );
+        };
+        element.shape = generator.line(x1, y1, x2, y2, options);
+        break;
+      }
     }
+  }
+}
 
-    context.globalAlpha = element.opacity / 100;
-    rc.draw(element.shape as Drawable);
-    context.globalAlpha = 1;
-  } else if (element.type === "arrow") {
-    const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
-    const options = {
-      stroke: element.strokeColor,
-      strokeWidth: element.strokeWidth,
-      roughness: element.roughness,
-      seed: element.seed,
-    };
-
-    if (!element.shape) {
-      element.shape = [
-        //    \
-        generator.line(x3, y3, x2, y2, options),
-        // -----
-        generator.line(x1, y1, x2, y2, options),
-        //    /
-        generator.line(x4, y4, x2, y2, options),
-      ];
+export function renderElement(
+  element: ExcalidrawElement,
+  rc: RoughCanvas,
+  context: CanvasRenderingContext2D,
+) {
+  const generator = rc.generator;
+  switch (element.type) {
+    case "selection": {
+      const fillStyle = context.fillStyle;
+      context.fillStyle = "rgba(0, 0, 255, 0.10)";
+      context.fillRect(0, 0, element.width, element.height);
+      context.fillStyle = fillStyle;
+      break;
     }
-
-    context.globalAlpha = element.opacity / 100;
-    (element.shape as Drawable[]).forEach(shape => rc.draw(shape));
-    context.globalAlpha = 1;
-    return;
-  } else if (element.type === "line") {
-    const [x1, y1, x2, y2] = getLinePoints(element);
-    const options = {
-      stroke: element.strokeColor,
-      strokeWidth: element.strokeWidth,
-      roughness: element.roughness,
-      seed: element.seed,
-    };
-
-    if (!element.shape) {
-      element.shape = generator.line(x1, y1, x2, y2, options);
+    case "rectangle":
+    case "diamond":
+    case "ellipse":
+    case "line": {
+      generateElement(element, generator);
+      context.globalAlpha = element.opacity / 100;
+      rc.draw(element.shape as Drawable);
+      context.globalAlpha = 1;
+      break;
+    }
+    case "arrow": {
+      generateElement(element, generator);
+      context.globalAlpha = element.opacity / 100;
+      (element.shape as Drawable[]).forEach(shape => rc.draw(shape));
+      context.globalAlpha = 1;
+      break;
     }
+    default: {
+      if (isTextElement(element)) {
+        context.globalAlpha = element.opacity / 100;
+        const font = context.font;
+        context.font = element.font;
+        const fillStyle = context.fillStyle;
+        context.fillStyle = element.strokeColor;
+        // Canvas does not support multiline text by default
+        const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
+        const lineHeight = element.height / lines.length;
+        const offset = element.height - element.baseline;
+        for (let i = 0; i < lines.length; i++) {
+          context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
+        }
+        context.fillStyle = fillStyle;
+        context.font = font;
+        context.globalAlpha = 1;
+        break;
+      } else {
+        throw new Error("Unimplemented type " + element.type);
+      }
+    }
+  }
+}
 
-    context.globalAlpha = element.opacity / 100;
-    rc.draw(element.shape as Drawable);
-    context.globalAlpha = 1;
-  } else if (isTextElement(element)) {
-    context.globalAlpha = element.opacity / 100;
-    const font = context.font;
-    context.font = element.font;
-    const fillStyle = context.fillStyle;
-    context.fillStyle = element.strokeColor;
-    // Canvas does not support multiline text by default
-    const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
-    const lineHeight = element.height / lines.length;
-    const offset = element.height - element.baseline;
-    for (let i = 0; i < lines.length; i++) {
-      context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
+export function renderElementToSvg(
+  element: ExcalidrawElement,
+  rsvg: RoughSVG,
+  svgRoot: SVGElement,
+  offsetX?: number,
+  offsetY?: number,
+) {
+  const generator = rsvg.generator;
+  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":
+    case "line": {
+      generateElement(element, generator);
+      const node = rsvg.draw(element.shape as Drawable);
+      const opacity = element.opacity / 100;
+      if (opacity !== 1) {
+        node.setAttribute("stroke-opacity", `${opacity}`);
+        node.setAttribute("fill-opacity", `${opacity}`);
+      }
+      node.setAttribute(
+        "transform",
+        `translate(${offsetX || 0} ${offsetY || 0})`,
+      );
+      svgRoot.appendChild(node);
+      break;
+    }
+    case "arrow": {
+      generateElement(element, generator);
+      const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+      const opacity = element.opacity / 100;
+      (element.shape as Drawable[]).forEach(shape => {
+        const node = rsvg.draw(shape);
+        if (opacity !== 1) {
+          node.setAttribute("stroke-opacity", `${opacity}`);
+          node.setAttribute("fill-opacity", `${opacity}`);
+        }
+        node.setAttribute(
+          "transform",
+          `translate(${offsetX || 0} ${offsetY || 0})`,
+        );
+        group.appendChild(node);
+      });
+      svgRoot.appendChild(group);
+      break;
+    }
+    default: {
+      if (isTextElement(element)) {
+        const opacity = element.opacity / 100;
+        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})`,
+        );
+        const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
+        const lineHeight = element.height / lines.length;
+        const offset = element.height - element.baseline;
+        const fontSplit = element.font.split(" ").filter(d => !!d.trim());
+        let fontFamily = fontSplit[0];
+        let fontSize = "20px";
+        if (fontSplit.length > 1) {
+          fontFamily = fontSplit[1];
+          fontSize = fontSplit[0];
+        }
+        for (let i = 0; i < lines.length; i++) {
+          const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
+          text.textContent = lines[i];
+          text.setAttribute("x", "0");
+          text.setAttribute("y", `${(i + 1) * lineHeight - offset}`);
+          text.setAttribute("font-family", fontFamily);
+          text.setAttribute("font-size", fontSize);
+          text.setAttribute("fill", element.strokeColor);
+          node.appendChild(text);
+        }
+        svgRoot.appendChild(node);
+      } else {
+        throw new Error("Unimplemented type " + element.type);
+      }
     }
-    context.fillStyle = fillStyle;
-    context.font = font;
-    context.globalAlpha = 1;
-  } else {
-    throw new Error("Unimplemented type " + element.type);
   }
 }

+ 30 - 1
src/renderer/renderScene.ts

@@ -1,4 +1,5 @@
 import { RoughCanvas } from "roughjs/bin/canvas";
+import { RoughSVG } from "roughjs/bin/svg";
 
 import { ExcalidrawElement } from "../element/types";
 import { getElementAbsoluteCoords, handlerRectangles } from "../element";
@@ -11,7 +12,7 @@ import {
   SCROLLBAR_WIDTH,
 } from "../scene/scrollbars";
 
-import { renderElement } from "./renderElement";
+import { renderElement, renderElementToSvg } from "./renderElement";
 
 export function renderScene(
   elements: readonly ExcalidrawElement[],
@@ -154,3 +155,31 @@ function isVisibleElement(
   y2 += scrollY;
   return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
 }
+
+// This should be only called for exporting purposes
+export function renderSceneToSvg(
+  elements: readonly ExcalidrawElement[],
+  rsvg: RoughSVG,
+  svgRoot: SVGElement,
+  {
+    offsetX = 0,
+    offsetY = 0,
+  }: {
+    offsetX?: number;
+    offsetY?: number;
+  } = {},
+) {
+  if (!svgRoot) {
+    return;
+  }
+  // render elements
+  elements.forEach(element => {
+    renderElementToSvg(
+      element,
+      rsvg,
+      svgRoot,
+      element.x + offsetX,
+      element.y + offsetY,
+    );
+  });
+}

+ 14 - 2
src/scene/data.ts

@@ -4,7 +4,7 @@ import { getDefaultAppState } from "../appState";
 
 import { AppState } from "../types";
 import { ExportType } from "./types";
-import { getExportCanvasPreview } from "./getExportCanvasPreview";
+import { exportToCanvas, exportToSvg } from "./export";
 import nanoid from "nanoid";
 import { fileOpen, fileSave } from "browser-nativefs";
 import { getCommonBounds } from "../element";
@@ -194,7 +194,19 @@ export async function exportCanvas(
     return window.alert(i18n.t("alerts.cannotExportEmptyCanvas"));
   // calculate smallest area to fit the contents in
 
-  const tempCanvas = getExportCanvasPreview(elements, {
+  if (type === "svg") {
+    const tempSvg = exportToSvg(elements, {
+      exportBackground,
+      viewBackgroundColor,
+      exportPadding,
+    });
+    await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
+      fileName: `${name}.svg`,
+    });
+    return;
+  }
+
+  const tempCanvas = exportToCanvas(elements, {
     exportBackground,
     viewBackgroundColor,
     exportPadding,

+ 112 - 0
src/scene/export.ts

@@ -0,0 +1,112 @@
+import rough from "roughjs/bin/rough";
+import { ExcalidrawElement } from "../element/types";
+import { getCommonBounds } from "../element/bounds";
+import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
+import { distance, SVG_NS } from "../utils";
+
+export function exportToCanvas(
+  elements: readonly ExcalidrawElement[],
+  {
+    exportBackground,
+    exportPadding = 10,
+    viewBackgroundColor,
+    scale = 1,
+  }: {
+    exportBackground: boolean;
+    exportPadding?: number;
+    scale?: number;
+    viewBackgroundColor: string;
+  },
+  createCanvas: (width: number, height: number) => any = function(
+    width,
+    height,
+  ) {
+    const tempCanvas = document.createElement("canvas");
+    tempCanvas.width = width * scale;
+    tempCanvas.height = height * scale;
+    return tempCanvas;
+  },
+) {
+  // calculate smallest area to fit the contents in
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+  const width = distance(minX, maxX) + exportPadding * 2;
+  const height = distance(minY, maxY) + exportPadding * 2;
+
+  const tempCanvas: any = createCanvas(width, height);
+  tempCanvas.getContext("2d")?.scale(scale, scale);
+
+  renderScene(
+    elements,
+    rough.canvas(tempCanvas),
+    tempCanvas,
+    {
+      viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
+      scrollX: 0,
+      scrollY: 0,
+    },
+    {
+      offsetX: -minX + exportPadding,
+      offsetY: -minY + exportPadding,
+      renderScrollbars: false,
+      renderSelection: false,
+    },
+  );
+  return tempCanvas;
+}
+
+export function exportToSvg(
+  elements: readonly ExcalidrawElement[],
+  {
+    exportBackground,
+    exportPadding = 10,
+    viewBackgroundColor,
+  }: {
+    exportBackground: boolean;
+    exportPadding?: number;
+    viewBackgroundColor: string;
+  },
+): SVGSVGElement {
+  // calculate canvas dimensions
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+  const width = distance(minX, maxX) + exportPadding * 2;
+  const height = distance(minY, maxY) + exportPadding * 2;
+
+  // initialze 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.innerHTML = `
+  <defs>
+    <style>
+      @font-face {
+        font-family: "Virgil";
+        src: url("https://excalidraw.com/FG_Virgil.ttf");
+      }
+      @font-face {
+        font-family: "Cascadia";
+        src: url("https://excalidraw.com/Cascadia.ttf");
+      }
+    </style>
+  </defs>
+  `;
+
+  // render backgroiund rect
+  if (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);
+  }
+
+  const rsvg = rough.svg(svgRoot);
+  renderSceneToSvg(elements, rsvg, svgRoot, {
+    offsetX: -minX + exportPadding,
+    offsetY: -minY + exportPadding,
+  });
+  return svgRoot;
+}

+ 0 - 55
src/scene/getExportCanvasPreview.ts

@@ -1,55 +0,0 @@
-import rough from "roughjs/bin/rough";
-import { ExcalidrawElement } from "../element/types";
-import { getCommonBounds } from "../element/bounds";
-import { renderScene } from "../renderer/renderScene";
-import { distance } from "../utils";
-
-export function getExportCanvasPreview(
-  elements: readonly ExcalidrawElement[],
-  {
-    exportBackground,
-    exportPadding = 10,
-    viewBackgroundColor,
-    scale = 1,
-  }: {
-    exportBackground: boolean;
-    exportPadding?: number;
-    scale?: number;
-    viewBackgroundColor: string;
-  },
-  createCanvas: (width: number, height: number) => any = function(
-    width,
-    height,
-  ) {
-    const tempCanvas = document.createElement("canvas");
-    tempCanvas.width = width * scale;
-    tempCanvas.height = height * scale;
-    return tempCanvas;
-  },
-) {
-  // calculate smallest area to fit the contents in
-  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
-  const width = distance(minX, maxX) + exportPadding * 2;
-  const height = distance(minY, maxY) + exportPadding * 2;
-
-  const tempCanvas: any = createCanvas(width, height);
-  tempCanvas.getContext("2d")?.scale(scale, scale);
-
-  renderScene(
-    elements,
-    rough.canvas(tempCanvas),
-    tempCanvas,
-    {
-      viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
-      scrollX: 0,
-      scrollY: 0,
-    },
-    {
-      offsetX: -minX + exportPadding,
-      offsetY: -minY + exportPadding,
-      renderScrollbars: false,
-      renderSelection: false,
-    },
-  );
-  return tempCanvas;
-}

+ 1 - 1
src/scene/types.ts

@@ -16,4 +16,4 @@ export interface Scene {
   elements: ExcalidrawTextElement[];
 }
 
-export type ExportType = "png" | "clipboard" | "backend";
+export type ExportType = "png" | "clipboard" | "backend" | "svg";

+ 2 - 0
src/utils.ts

@@ -1,3 +1,5 @@
+export const SVG_NS = "http://www.w3.org/2000/svg";
+
 export function getDateTime() {
   const date = new Date();
   const year = date.getFullYear();