Selaa lähdekoodia

feat: cleanup svg export and move payload to `<metadata>` (#8975)

David Luzar 7 kuukautta sitten
vanhempi
commit
36274f1f3e

+ 2 - 1
packages/excalidraw/data/blob.ts

@@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
 import type { ExcalidrawElement, FileId } from "../element/types";
 import { CanvasError, ImageSceneDataError } from "../errors";
 import { calculateScrollCenter } from "../scene";
+import { decodeSvgBase64Payload } from "../scene/export";
 import type { AppState, DataURL, LibraryItem } from "../types";
 import type { ValueOf } from "../utility-types";
 import { bytesToHexString, isPromiseLike } from "../utils";
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
     }
     if (blob.type === MIME_TYPES.svg) {
       try {
-        return (await import("./image")).decodeSvgMetadata({
+        return decodeSvgBase64Payload({
           svg: contents,
         });
       } catch (error: any) {

+ 1 - 54
packages/excalidraw/data/image.ts

@@ -1,7 +1,7 @@
 import decodePng from "png-chunks-extract";
 import tEXt from "png-chunk-text";
 import encodePng from "png-chunks-encode";
-import { stringToBase64, encode, decode, base64ToString } from "./encode";
+import { encode, decode } from "./encode";
 import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
 import { blobToArrayBuffer } from "./blob";
 
@@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
   }
   throw new Error("INVALID");
 };
-
-// -----------------------------------------------------------------------------
-// SVG
-// -----------------------------------------------------------------------------
-
-export const encodeSvgMetadata = ({ text }: { text: string }) => {
-  const base64 = stringToBase64(
-    JSON.stringify(encode({ text })),
-    true /* is already byte string */,
-  );
-
-  let metadata = "";
-  metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
-  metadata += `<!-- payload-version:2 -->`;
-  metadata += "<!-- payload-start -->";
-  metadata += base64;
-  metadata += "<!-- payload-end -->";
-  return metadata;
-};
-
-export const decodeSvgMetadata = ({ 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");
-};

+ 1 - 1
packages/excalidraw/renderer/staticSvgScene.ts

@@ -449,7 +449,7 @@ const renderElementToSvg = (
 
           symbol.appendChild(image);
 
-          root.prepend(symbol);
+          (root.querySelector("defs") || root).prepend(symbol);
         }
 
         const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");

+ 163 - 53
packages/excalidraw/scene/export.ts

@@ -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[],

+ 8 - 8
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -1006,14 +1006,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "roundness": {
     "type": 3,
   },
-  "seed": 1278240551,
+  "seed": 1,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 453191,
+  "versionNonce": 1278240551,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -1042,14 +1042,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "roundness": {
     "type": 3,
   },
-  "seed": 449462985,
+  "seed": 1,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 401146281,
+  "versionNonce": 449462985,
   "width": 100,
   "x": 0,
   "y": 0,
@@ -9792,14 +9792,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
   "roundness": {
     "type": 3,
   },
-  "seed": 1278240551,
+  "seed": 1,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 453191,
+  "versionNonce": 1278240551,
   "width": 200,
   "x": 0,
   "y": 0,
@@ -9826,14 +9826,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
   "roundness": {
     "type": 3,
   },
-  "seed": 449462985,
+  "seed": 1,
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "type": "rectangle",
   "updated": 1,
   "version": 2,
-  "versionNonce": 401146281,
+  "versionNonce": 449462985,
   "width": 200,
   "x": 0,
   "y": 0,

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 5 - 0
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap


+ 27 - 9
packages/excalidraw/tests/export.test.tsx

@@ -2,16 +2,17 @@ import React from "react";
 import { render, waitFor } from "./test-utils";
 import { Excalidraw } from "../index";
 import { API } from "./helpers/api";
-import {
-  encodePngMetadata,
-  encodeSvgMetadata,
-  decodeSvgMetadata,
-} from "../data/image";
+import { encodePngMetadata } from "../data/image";
 import { serializeAsJSON } from "../data/json";
-import { exportToSvg } from "../scene/export";
+import {
+  decodeSvgBase64Payload,
+  encodeSvgBase64Payload,
+  exportToSvg,
+} from "../scene/export";
 import type { FileId } from "../element/types";
 import { getDataURL } from "../data/blob";
 import { getDefaultAppState } from "../appState";
+import { SVG_NS } from "../constants";
 
 const { h } = window;
 
@@ -62,15 +63,32 @@ describe("export", () => {
   });
 
   it("test encoding/decoding scene for SVG export", async () => {
-    const encoded = encodeSvgMetadata({
-      text: serializeAsJSON(testElements, h.state, {}, "local"),
+    const metadataElement = document.createElementNS(SVG_NS, "metadata");
+
+    encodeSvgBase64Payload({
+      metadataElement,
+      payload: serializeAsJSON(testElements, h.state, {}, "local"),
     });
-    const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded }));
+
+    const decoded = JSON.parse(
+      decodeSvgBase64Payload({ svg: metadataElement.innerHTML }),
+    );
     expect(decoded.elements).toEqual([
       expect.objectContaining({ type: "text", text: "😀" }),
     ]);
   });
 
+  it("export svg-embedded scene", async () => {
+    const svg = await exportToSvg(
+      testElements,
+      { ...getDefaultAppState(), exportEmbedScene: true },
+      {},
+    );
+    const svgText = svg.outerHTML;
+
+    expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
+  });
+
   it("import embedded png (legacy v1)", async () => {
     await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
     await waitFor(() => {

+ 1 - 1
packages/excalidraw/tests/helpers/api.ts

@@ -220,7 +220,6 @@ export class API {
       | "width"
       | "height"
       | "type"
-      | "seed"
       | "version"
       | "versionNonce"
       | "isDeleted"
@@ -228,6 +227,7 @@ export class API {
       | "link"
       | "updated"
     > = {
+      seed: 1,
       x,
       y,
       frameId: rest.frameId ?? null,

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 7
packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap


+ 3 - 2
packages/utils/utils.unmocked.test.ts

@@ -1,7 +1,8 @@
-import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
 import type { ImportedDataState } from "../excalidraw/data/types";
 import * as utils from "../utils";
 import { API } from "../excalidraw/tests/helpers/api";
+import { decodeSvgBase64Payload } from "../excalidraw/scene/export";
+import { decodePngMetadata } from "../excalidraw/data/image";
 
 // NOTE this test file is using the actual API, unmocked. Hence splitting it
 // from the other test file, because I couldn't figure out how to test
@@ -27,7 +28,7 @@ describe("embedding scene data", () => {
 
       const svg = svgNode.outerHTML;
 
-      const parsedString = decodeSvgMetadata({ svg });
+      const parsedString = decodeSvgBase64Payload({ svg });
       const importedData: ImportedDataState = JSON.parse(parsedString);
 
       expect(sourceElements.map((x) => x.id)).toEqual(

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä