mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: cleanup svg export and move payload to <metadata>
(#8975)
This commit is contained in:
parent
798c795405
commit
36274f1f3e
10 changed files with 220 additions and 186 deletions
|
@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
|
||||||
import type { ExcalidrawElement, FileId } from "../element/types";
|
import type { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError, ImageSceneDataError } from "../errors";
|
import { CanvasError, ImageSceneDataError } from "../errors";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
|
import { decodeSvgBase64Payload } from "../scene/export";
|
||||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
import { bytesToHexString, isPromiseLike } from "../utils";
|
import { bytesToHexString, isPromiseLike } from "../utils";
|
||||||
|
@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return (await import("./image")).decodeSvgMetadata({
|
return decodeSvgBase64Payload({
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import decodePng from "png-chunks-extract";
|
import decodePng from "png-chunks-extract";
|
||||||
import tEXt from "png-chunk-text";
|
import tEXt from "png-chunk-text";
|
||||||
import encodePng from "png-chunks-encode";
|
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 { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
import { blobToArrayBuffer } from "./blob";
|
import { blobToArrayBuffer } from "./blob";
|
||||||
|
|
||||||
|
@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
|
||||||
}
|
}
|
||||||
throw new Error("INVALID");
|
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);
|
symbol.appendChild(image);
|
||||||
|
|
||||||
root.prepend(symbol);
|
(root.querySelector("defs") || root).prepend(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
||||||
|
|
|
@ -18,6 +18,8 @@ import {
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
THEME,
|
THEME,
|
||||||
THEME_FILTER,
|
THEME_FILTER,
|
||||||
|
MIME_TYPES,
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
|
@ -39,8 +41,7 @@ import type { RenderableElementsMap } from "./types";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { renderStaticScene } from "../renderer/staticScene";
|
import { renderStaticScene } from "../renderer/staticScene";
|
||||||
import { Fonts } from "../fonts";
|
import { Fonts } from "../fonts";
|
||||||
|
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
|
||||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
|
||||||
|
|
||||||
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
||||||
if (element.width <= maxWidth) {
|
if (element.width <= maxWidth) {
|
||||||
|
@ -254,6 +255,13 @@ export const exportToCanvas = async (
|
||||||
return canvas;
|
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 (
|
export const exportToSvg = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: {
|
appState: {
|
||||||
|
@ -302,31 +310,20 @@ export const exportToSvg = async (
|
||||||
exportPadding = 0;
|
exportPadding = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = "";
|
|
||||||
|
|
||||||
// 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({
|
|
||||||
// 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"),
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [minX, minY, width, height] = getCanvasSize(
|
const [minX, minY, width, height] = getCanvasSize(
|
||||||
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||||
exportPadding,
|
exportPadding,
|
||||||
);
|
);
|
||||||
|
|
||||||
// initialize SVG root
|
const offsetX = -minX + exportPadding;
|
||||||
|
const offsetY = -minY + exportPadding;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// initialize SVG root element
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
||||||
|
|
||||||
svgRoot.setAttribute("version", "1.1");
|
svgRoot.setAttribute("version", "1.1");
|
||||||
svgRoot.setAttribute("xmlns", SVG_NS);
|
svgRoot.setAttribute("xmlns", SVG_NS);
|
||||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
@ -336,53 +333,105 @@ export const exportToSvg = async (
|
||||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetX = -minX + exportPadding;
|
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
||||||
const offsetY = -minY + exportPadding;
|
|
||||||
|
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);
|
const frameElements = getFrameLikeElements(elements);
|
||||||
|
|
||||||
let exportingFrameClipPath = "";
|
if (frameElements.length) {
|
||||||
const elementsMap = arrayToMap(elements);
|
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}>
|
for (const frame of frameElements) {
|
||||||
<rect transform="translate(${frame.x + offsetX} ${
|
const clipPath = svgRoot.ownerDocument.createElementNS(
|
||||||
frame.y + offsetY
|
SVG_NS,
|
||||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
"clipPath",
|
||||||
width="${frame.width}"
|
);
|
||||||
height="${frame.height}"
|
|
||||||
${
|
clipPath.setAttribute("id", frame.id);
|
||||||
exportingFrame
|
|
||||||
? ""
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||||
}
|
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||||
>
|
|
||||||
</rect>
|
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
||||||
</clipPath>`;
|
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
|
const fontFaces = !opts?.skipInliningFonts
|
||||||
? await Fonts.generateFontFaceDeclarations(elements)
|
? await Fonts.generateFontFaceDeclarations(elements)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const delimiter = "\n "; // 6 spaces
|
const delimiter = "\n "; // 6 spaces
|
||||||
|
|
||||||
svgRoot.innerHTML = `
|
const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
|
||||||
${SVG_EXPORT_TAG}
|
style.classList.add("style-fonts");
|
||||||
${metadata}
|
style.appendChild(
|
||||||
<defs>
|
document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
|
||||||
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
|
);
|
||||||
</style>
|
|
||||||
${exportingFrameClipPath}
|
defsElement.appendChild(style);
|
||||||
</defs>
|
|
||||||
`;
|
// ---------------------------------------------------------------------------
|
||||||
|
// background
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// render background rect
|
// render background rect
|
||||||
if (appState.exportBackground && viewBackgroundColor) {
|
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("x", "0");
|
||||||
rect.setAttribute("y", "0");
|
rect.setAttribute("y", "0");
|
||||||
rect.setAttribute("width", `${width}`);
|
rect.setAttribute("width", `${width}`);
|
||||||
|
@ -391,6 +440,10 @@ export const exportToSvg = async (
|
||||||
svgRoot.appendChild(rect);
|
svgRoot.appendChild(rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// render elements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const rsvg = rough.svg(svgRoot);
|
const rsvg = rough.svg(svgRoot);
|
||||||
|
|
||||||
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
||||||
|
@ -420,9 +473,66 @@ export const exportToSvg = async (
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
return svgRoot;
|
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
|
// calculate smallest area to fit the contents in
|
||||||
const getCanvasSize = (
|
const getCanvasSize = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
|
|
@ -1006,14 +1006,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 1278240551,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -1042,14 +1042,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 449462985,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -9792,14 +9792,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 1278240551,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
@ -9826,14 +9826,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 449462985,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 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 { render, waitFor } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import {
|
import { encodePngMetadata } from "../data/image";
|
||||||
encodePngMetadata,
|
|
||||||
encodeSvgMetadata,
|
|
||||||
decodeSvgMetadata,
|
|
||||||
} from "../data/image";
|
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
import { exportToSvg } from "../scene/export";
|
import {
|
||||||
|
decodeSvgBase64Payload,
|
||||||
|
encodeSvgBase64Payload,
|
||||||
|
exportToSvg,
|
||||||
|
} from "../scene/export";
|
||||||
import type { FileId } from "../element/types";
|
import type { FileId } from "../element/types";
|
||||||
import { getDataURL } from "../data/blob";
|
import { getDataURL } from "../data/blob";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { SVG_NS } from "../constants";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
|
@ -62,15 +63,32 @@ describe("export", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test encoding/decoding scene for SVG export", async () => {
|
it("test encoding/decoding scene for SVG export", async () => {
|
||||||
const encoded = encodeSvgMetadata({
|
const metadataElement = document.createElementNS(SVG_NS, "metadata");
|
||||||
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
|
||||||
|
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(decoded.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
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 () => {
|
it("import embedded png (legacy v1)", async () => {
|
||||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
|
@ -220,7 +220,6 @@ export class API {
|
||||||
| "width"
|
| "width"
|
||||||
| "height"
|
| "height"
|
||||||
| "type"
|
| "type"
|
||||||
| "seed"
|
|
||||||
| "version"
|
| "version"
|
||||||
| "versionNonce"
|
| "versionNonce"
|
||||||
| "isDeleted"
|
| "isDeleted"
|
||||||
|
@ -228,6 +227,7 @@ export class API {
|
||||||
| "link"
|
| "link"
|
||||||
| "updated"
|
| "updated"
|
||||||
> = {
|
> = {
|
||||||
|
seed: 1,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
frameId: rest.frameId ?? null,
|
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 type { ImportedDataState } from "../excalidraw/data/types";
|
||||||
import * as utils from "../utils";
|
import * as utils from "../utils";
|
||||||
import { API } from "../excalidraw/tests/helpers/api";
|
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
|
// 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
|
// 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 svg = svgNode.outerHTML;
|
||||||
|
|
||||||
const parsedString = decodeSvgMetadata({ svg });
|
const parsedString = decodeSvgBase64Payload({ svg });
|
||||||
const importedData: ImportedDataState = JSON.parse(parsedString);
|
const importedData: ImportedDataState = JSON.parse(parsedString);
|
||||||
|
|
||||||
expect(sourceElements.map((x) => x.id)).toEqual(
|
expect(sourceElements.map((x) => x.id)).toEqual(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue