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

pull/8976/head
David Luzar 4 weeks ago committed by GitHub
parent 798c795405
commit 36274f1f3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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,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");
};

@ -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");

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

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

File diff suppressed because one or more lines are too long

@ -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(() => {

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

File diff suppressed because one or more lines are too long

@ -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(

Loading…
Cancel
Save