From b3263c2a69ba893ced7fb79bc004d240bdd7fcd8 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Thu, 15 Oct 2020 21:31:21 +0200 Subject: [PATCH] fix encoding of embed data & compress (#2240) --- package-lock.json | 6 + package.json | 2 + src/base64.ts | 40 ------ src/data/blob.ts | 31 +++-- src/data/encode.ts | 116 ++++++++++++++++ src/data/image.ts | 130 ++++++++++++++++++ src/data/index.ts | 24 ++-- src/data/png.ts | 42 ------ src/tests/export.test.tsx | 155 ++++++++++++++++++++++ src/tests/fixtures/smiley.png | Bin 0 -> 2237 bytes src/tests/fixtures/smiley_embedded_v2.png | Bin 0 -> 3079 bytes src/tests/fixtures/smiley_embedded_v2.svg | 16 +++ src/tests/fixtures/test_embedded_v1.png | Bin 0 -> 3100 bytes src/tests/fixtures/test_embedded_v1.svg | 16 +++ src/tests/helpers/api.ts | 27 ++-- 15 files changed, 483 insertions(+), 122 deletions(-) delete mode 100644 src/base64.ts create mode 100644 src/data/encode.ts create mode 100644 src/data/image.ts delete mode 100644 src/data/png.ts create mode 100644 src/tests/export.test.tsx create mode 100644 src/tests/fixtures/smiley.png create mode 100644 src/tests/fixtures/smiley_embedded_v2.png create mode 100644 src/tests/fixtures/smiley_embedded_v2.svg create mode 100644 src/tests/fixtures/test_embedded_v1.png create mode 100644 src/tests/fixtures/test_embedded_v1.svg diff --git a/package-lock.json b/package-lock.json index 3419fbe7e..42db29856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3003,6 +3003,12 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-13.5.1.tgz", "integrity": "sha512-Jj2W7VWQ2uM83f8Ls5ON9adxN98MvyJsMSASYFuSvrov8RMRY64Ayay7KV35ph1TSGIJ2gG9ZVDdEq3c3zaydA==" }, + "@types/pako": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.1.tgz", + "integrity": "sha512-GdZbRSJ3Cv5fiwT6I0SQ3ckeN2PWNqxd26W9Z2fCK1tGrrasGy4puvNFtnddqH9UJFMQYXxEuuB7B8UK+LLwSg==", + "dev": true + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", diff --git a/package.json b/package.json index 628c64cbb..cf340f0c3 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "nanoid": "2.1.11", "node-sass": "4.14.1", "open-color": "1.7.0", + "pako": "1.0.11", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", "png-chunks-extract": "1.0.0", @@ -49,6 +50,7 @@ }, "devDependencies": { "@types/lodash.throttle": "4.1.6", + "@types/pako": "1.0.1", "asar": "3.0.3", "eslint": "6.8.0", "eslint-config-prettier": "6.12.0", diff --git a/src/base64.ts b/src/base64.ts deleted file mode 100644 index 78e464fd6..000000000 --- a/src/base64.ts +++ /dev/null @@ -1,40 +0,0 @@ -// `btoa(unescape(encodeURIComponent(str)))` hack doesn't work in edge cases and -// `unescape` API shouldn't be used anyway. -// This implem is ~10x faster than using fromCharCode in a loop (in Chrome). -const stringToByteString = (str: string): Promise => { - return new Promise((resolve, reject) => { - const blob = new Blob([new TextEncoder().encode(str)]); - const reader = new FileReader(); - reader.onload = function (event) { - if (!event.target || typeof event.target.result !== "string") { - return reject(new Error("couldn't convert to byte string")); - } - resolve(event.target.result); - }; - reader.readAsBinaryString(blob); - }); -}; - -function byteStringToArrayBuffer(byteString: string) { - const buffer = new ArrayBuffer(byteString.length); - const bufferView = new Uint8Array(buffer); - for (let i = 0, len = byteString.length; i < len; i++) { - bufferView[i] = byteString.charCodeAt(i); - } - return buffer; -} - -const byteStringToString = (byteString: string) => { - return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString)); -}; - -// ----------------------------------------------------------------------------- - -export const stringToBase64 = async (str: string) => { - return btoa(await stringToByteString(str)); -}; - -// async to align with stringToBase64 -export const base64ToString = async (base64: string) => { - return byteStringToString(atob(base64)); -}; diff --git a/src/data/blob.ts b/src/data/blob.ts index f2e84fb86..619955952 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -4,16 +4,20 @@ import { t } from "../i18n"; import { AppState } from "../types"; import { LibraryData, ImportedDataState } from "./types"; import { calculateScrollCenter } from "../scene"; -import { MIME_TYPES } from "../constants"; -import { base64ToString } from "../base64"; + export const parseFileContents = async (blob: Blob | File) => { let contents: string; + if (blob.type === "image/png") { - const metadata = await (await import("./png")).getTEXtChunk(blob); - if (metadata?.keyword === MIME_TYPES.excalidraw) { - return metadata.text; + try { + return await (await import("./image")).decodePngMetadata(blob); + } catch (error) { + if (error.message === "INVALID") { + throw new Error(t("alerts.imageDoesNotContainScene")); + } else { + throw new Error(t("alerts.cannotRestoreFromImage")); + } } - throw new Error(t("alerts.imageDoesNotContainScene")); } else { if ("text" in Blob) { contents = await blob.text(); @@ -29,16 +33,17 @@ export const parseFileContents = async (blob: Blob | File) => { }); } if (blob.type === "image/svg+xml") { - if (contents.includes(`payload-type:${MIME_TYPES.excalidraw}`)) { - const match = contents.match( - /(.+?)/, - ); - if (!match) { + try { + return await (await import("./image")).decodeSvgMetadata({ + svg: contents, + }); + } catch (error) { + if (error.message === "INVALID") { throw new Error(t("alerts.imageDoesNotContainScene")); + } else { + throw new Error(t("alerts.cannotRestoreFromImage")); } - return base64ToString(match[1]); } - throw new Error(t("alerts.imageDoesNotContainScene")); } } return contents; diff --git a/src/data/encode.ts b/src/data/encode.ts new file mode 100644 index 000000000..b3dd45842 --- /dev/null +++ b/src/data/encode.ts @@ -0,0 +1,116 @@ +import { deflate, inflate } from "pako"; + +// ----------------------------------------------------------------------------- +// byte (binary) strings +// ----------------------------------------------------------------------------- + +// fast, Buffer-compatible implem +export const toByteString = (data: string | Uint8Array): Promise => { + return new Promise((resolve, reject) => { + const blob = + typeof data === "string" + ? new Blob([new TextEncoder().encode(data)]) + : new Blob([data]); + const reader = new FileReader(); + reader.onload = (event) => { + if (!event.target || typeof event.target.result !== "string") { + return reject(new Error("couldn't convert to byte string")); + } + resolve(event.target.result); + }; + reader.readAsBinaryString(blob); + }); +}; + +const byteStringToArrayBuffer = (byteString: string) => { + const buffer = new ArrayBuffer(byteString.length); + const bufferView = new Uint8Array(buffer); + for (let i = 0, len = byteString.length; i < len; i++) { + bufferView[i] = byteString.charCodeAt(i); + } + return buffer; +}; + +const byteStringToString = (byteString: string) => { + return new TextDecoder("utf-8").decode(byteStringToArrayBuffer(byteString)); +}; + +// ----------------------------------------------------------------------------- +// base64 +// ----------------------------------------------------------------------------- + +/** + * @param isByteString set to true if already byte string to prevent bloat + * due to reencoding + */ +export const stringToBase64 = async (str: string, isByteString = false) => { + return isByteString ? btoa(str) : btoa(await toByteString(str)); +}; + +// async to align with stringToBase64 +export const base64ToString = async (base64: string, isByteString = false) => { + return isByteString ? atob(base64) : byteStringToString(atob(base64)); +}; + +// ----------------------------------------------------------------------------- +// text encoding +// ----------------------------------------------------------------------------- + +type EncodedData = { + encoded: string; + encoding: "bstring"; + /** whether text is compressed (zlib) */ + compressed: boolean; + /** version for potential migration purposes */ + version?: string; +}; + +/** + * Encodes (and potentially compresses via zlib) text to byte string + */ +export const encode = async ({ + text, + compress, +}: { + text: string; + /** defaults to `true`. If compression fails, falls back to bstring alone. */ + compress?: boolean; +}): Promise => { + let deflated!: string; + if (compress !== false) { + try { + deflated = await toByteString(deflate(text)); + } catch (error) { + console.error("encode: cannot deflate", error); + } + } + return { + version: "1", + encoding: "bstring", + compressed: !!deflated, + encoded: deflated || (await toByteString(text)), + }; +}; + +export const decode = async (data: EncodedData): Promise => { + let decoded: string; + + switch (data.encoding) { + case "bstring": + // if compressed, do not double decode the bstring + decoded = data.compressed + ? data.encoded + : await byteStringToString(data.encoded); + break; + default: + throw new Error(`decode: unknown encoding "${data.encoding}"`); + } + + if (data.compressed) { + return inflate(new Uint8Array(byteStringToArrayBuffer(decoded)), { + to: "string", + }); + } + + return decoded; +}; diff --git a/src/data/image.ts b/src/data/image.ts new file mode 100644 index 000000000..08db76495 --- /dev/null +++ b/src/data/image.ts @@ -0,0 +1,130 @@ +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 { MIME_TYPES } from "../constants"; + +// ----------------------------------------------------------------------------- +// PNG +// ----------------------------------------------------------------------------- + +const blobToArrayBuffer = (blob: Blob): Promise => { + if ("arrayBuffer" in blob) { + return blob.arrayBuffer(); + } + // Safari + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (!event.target?.result) { + return reject(new Error("couldn't convert blob to ArrayBuffer")); + } + resolve(event.target.result as ArrayBuffer); + }; + reader.readAsArrayBuffer(blob); + }); +}; + +export const getTEXtChunk = async ( + blob: Blob, +): Promise<{ keyword: string; text: string } | null> => { + const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); + const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt"); + if (metadataChunk) { + return tEXt.decode(metadataChunk.data); + } + return null; +}; + +export const encodePngMetadata = async ({ + blob, + metadata, +}: { + blob: Blob; + metadata: string; +}) => { + const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); + + const metadataChunk = tEXt.encode( + MIME_TYPES.excalidraw, + JSON.stringify( + await encode({ + text: metadata, + compress: true, + }), + ), + ); + // insert metadata before last chunk (iEND) + chunks.splice(-1, 0, metadataChunk); + + return new Blob([encodePng(chunks)], { type: "image/png" }); +}; + +export const decodePngMetadata = async (blob: Blob) => { + const metadata = await getTEXtChunk(blob); + if (metadata?.keyword === MIME_TYPES.excalidraw) { + try { + const encodedData = JSON.parse(metadata.text); + if (!("encoded" in encodedData)) { + // legacy, un-encoded scene JSON + if ("type" in encodedData && encodedData.type === "excalidraw") { + return metadata.text; + } + throw new Error("FAILED"); + } + return await decode(encodedData); + } catch (error) { + console.error(error); + throw new Error("FAILED"); + } + } + throw new Error("INVALID"); +}; + +// ----------------------------------------------------------------------------- +// SVG +// ----------------------------------------------------------------------------- + +export const encodeSvgMetadata = async ({ text }: { text: string }) => { + const base64 = await stringToBase64( + JSON.stringify(await encode({ text })), + true /* is already byte string */, + ); + + let metadata = ""; + metadata += ``; + metadata += ``; + metadata += ""; + metadata += base64; + metadata += ""; + return metadata; +}; + +export const decodeSvgMetadata = async ({ svg }: { svg: string }) => { + if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) { + const match = svg.match(/(.+?)/); + if (!match) { + throw new Error("INVALID"); + } + const versionMatch = svg.match(//); + const version = versionMatch?.[1] || "1"; + const isByteString = version !== "1"; + + try { + const json = await base64ToString(match[1], isByteString); + const encodedData = JSON.parse(json); + if (!("encoded" in encodedData)) { + // legacy, un-encoded scene JSON + if ("type" in encodedData && encodedData.type === "excalidraw") { + return json; + } + throw new Error("FAILED"); + } + return await decode(encodedData); + } catch (error) { + console.error(error); + throw new Error("FAILED"); + } + } + throw new Error("INVALID"); +}; diff --git a/src/data/index.ts b/src/data/index.ts index 87e61d767..44a58b31b 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -19,8 +19,6 @@ import { serializeAsJSON } from "./json"; import { ExportType } from "../scene/types"; import { restore } from "./restore"; import { ImportedDataState } from "./types"; -import { MIME_TYPES } from "../constants"; -import { stringToBase64 } from "../base64"; export { loadFromBlob } from "./blob"; export { saveAsJSON, loadFromJSON } from "./json"; @@ -302,21 +300,17 @@ export const exportCanvas = async ( return window.alert(t("alerts.cannotExportEmptyCanvas")); } if (type === "svg" || type === "clipboard-svg") { - let metadata = ""; - - if (appState.exportEmbedScene && type === "svg") { - metadata += ``; - metadata += ""; - metadata += await stringToBase64(serializeAsJSON(elements, appState)); - metadata += ""; - } - const tempSvg = exportToSvg(elements, { exportBackground, viewBackgroundColor, exportPadding, shouldAddWatermark, - metadata, + metadata: + appState.exportEmbedScene && type === "svg" + ? await (await import("./image")).encodeSvgMetadata({ + text: serializeAsJSON(elements, appState), + }) + : undefined, }); if (type === "svg") { await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), { @@ -345,9 +339,9 @@ export const exportCanvas = async ( tempCanvas.toBlob(async (blob) => { if (blob) { if (appState.exportEmbedScene) { - blob = await (await import("./png")).encodeTEXtChunk(blob, { - keyword: MIME_TYPES.excalidraw, - text: serializeAsJSON(elements, appState), + blob = await (await import("./image")).encodePngMetadata({ + blob, + metadata: serializeAsJSON(elements, appState), }); } diff --git a/src/data/png.ts b/src/data/png.ts deleted file mode 100644 index 133ad72e5..000000000 --- a/src/data/png.ts +++ /dev/null @@ -1,42 +0,0 @@ -import decodePng from "png-chunks-extract"; -import tEXt from "png-chunk-text"; -import encodePng from "png-chunks-encode"; - -const blobToArrayBuffer = (blob: Blob): Promise => { - if ("arrayBuffer" in blob) { - return blob.arrayBuffer(); - } - // Safari - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (event) => { - if (!event.target?.result) { - return reject(new Error("couldn't convert blob to ArrayBuffer")); - } - resolve(event.target.result as ArrayBuffer); - }; - reader.readAsArrayBuffer(blob); - }); -}; - -export const getTEXtChunk = async ( - blob: Blob, -): Promise<{ keyword: string; text: string } | null> => { - const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - const metadataChunk = chunks.find((chunk) => chunk.name === "tEXt"); - if (metadataChunk) { - return tEXt.decode(metadataChunk.data); - } - return null; -}; - -export const encodeTEXtChunk = async ( - blob: Blob, - chunk: { keyword: string; text: string }, -): Promise => { - const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - const metadata = tEXt.encode(chunk.keyword, chunk.text); - // insert metadata before last chunk (iEND) - chunks.splice(-1, 0, metadata); - return new Blob([encodePng(chunks)], { type: "image/png" }); -}; diff --git a/src/tests/export.test.tsx b/src/tests/export.test.tsx new file mode 100644 index 000000000..353af51f2 --- /dev/null +++ b/src/tests/export.test.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { render, waitFor } from "./test-utils"; +import App from "../components/App"; +import { API } from "./helpers/api"; +import { + encodePngMetadata, + encodeSvgMetadata, + decodeSvgMetadata, +} from "../data/image"; +import { serializeAsJSON } from "../data/json"; + +import fs from "fs"; +import util from "util"; +import path from "path"; + +const readFile = util.promisify(fs.readFile); + +const { h } = window; + +const testElements = [ + { + ...API.createElement({ + type: "text", + id: "A", + text: "😀", + }), + // can't get jsdom text measurement to work so this is a temp hack + // to ensure the element isn't stripped as invisible + width: 16, + height: 16, + }, +]; + +// tiny polyfill for TextDecoder.decode on which we depend +Object.defineProperty(window, "TextDecoder", { + value: class TextDecoder { + decode(ab: ArrayBuffer) { + return new Uint8Array(ab).reduce( + (acc, c) => acc + String.fromCharCode(c), + "", + ); + } + }, +}); + +describe("appState", () => { + beforeEach(() => { + render(); + }); + + it("export embedded png and reimport", async () => { + const pngBlob = new Blob( + [await readFile(path.resolve(__dirname, "./fixtures/smiley.png"))], + { type: "image/png" }, + ); + + const pngBlobEmbedded = await encodePngMetadata({ + blob: pngBlob, + metadata: serializeAsJSON(testElements, h.state), + }); + API.dropFile(pngBlobEmbedded); + + await waitFor(() => { + expect(h.elements).toEqual([ + expect.objectContaining({ type: "text", text: "😀" }), + ]); + }); + }); + + it("test encoding/decoding scene for SVG export", async () => { + const encoded = await encodeSvgMetadata({ + text: serializeAsJSON(testElements, h.state), + }); + const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded })); + expect(decoded.elements).toEqual([ + expect.objectContaining({ type: "text", text: "😀" }), + ]); + }); + + it("import embedded png (legacy v1)", async () => { + const pngBlob = new Blob( + [ + await readFile( + path.resolve(__dirname, "./fixtures/test_embedded_v1.png"), + ), + ], + { type: "image/png" }, + ); + + API.dropFile(pngBlob); + + await waitFor(() => { + expect(h.elements).toEqual([ + expect.objectContaining({ type: "text", text: "test" }), + ]); + }); + }); + + it("import embedded png (v2)", async () => { + const pngBlob = new Blob( + [ + await readFile( + path.resolve(__dirname, "./fixtures/smiley_embedded_v2.png"), + ), + ], + { type: "image/png" }, + ); + + API.dropFile(pngBlob); + + await waitFor(() => { + expect(h.elements).toEqual([ + expect.objectContaining({ type: "text", text: "😀" }), + ]); + }); + }); + + it("import embedded svg (legacy v1)", async () => { + const svgBlob = new Blob( + [ + await readFile( + path.resolve(__dirname, "./fixtures/test_embedded_v1.svg"), + ), + ], + { type: "image/svg+xml" }, + ); + + API.dropFile(svgBlob); + + await waitFor(() => { + expect(h.elements).toEqual([ + expect.objectContaining({ type: "text", text: "test" }), + ]); + }); + }); + + it("import embedded svg (v2)", async () => { + const svgBlob = new Blob( + [ + await readFile( + path.resolve(__dirname, "./fixtures/smiley_embedded_v2.svg"), + ), + ], + { type: "image/svg+xml" }, + ); + + API.dropFile(svgBlob); + + await waitFor(() => { + expect(h.elements).toEqual([ + expect.objectContaining({ type: "text", text: "😀" }), + ]); + }); + }); +}); diff --git a/src/tests/fixtures/smiley.png b/src/tests/fixtures/smiley.png new file mode 100644 index 0000000000000000000000000000000000000000..e59d1b26eb4684a7b1d317b0e9ff9f5c02f3294e GIT binary patch literal 2237 zcmV;u2txOXP)3$w6g}Pdh3=)aP+Dj~BW^Lq1-D3q;EpCPXauWRk+oujh+x$SSfpx9p`t;+B}UO7 z28@ZQ7*xPuL@nJ}O6g9S>2#f$XU;8eZ0WQ!^LWh^^Ip<4nfcy#@44rG=icv*kD@3F z{>P7x0q`Pc*I@7>ClCgM27{wK0!E6Ca)MmCXE5k8M5BCx!Qdz-$fbJ*gDyif$`=?6 zj&g!rx@R!xGDM?%fx)0Af)oWk4Gn19yBDqBeTUA93iP(N4%jL%F%h9trXY6aOhiqe z4*$qVO{QGEq%VVZJI?OkkJ_RlSpNA(Z2JZWBY6CHgkOF+d;$aIGiyr=+K(JT&*{@p ztXBB=`(w;4w;<)Y=MXY+BH-iW%5S&7*9~CTv16!Nvj*ma2jL$bjf4jtK-|6eA~-b_ z@bz`-5W#hnmg4l*tvIuHFJQMLX4WjEE?k+jcC}u9pP7ADNY3@C24m` zQD8oN7&WU`OZtvqxf1dB-FHDLs~I4{op|$20Y}Et^7B1rZKz1}HZ`F#FAtW#{z7^| zfe4Y-bEyrWqM+g9k0mV=9(e?5d3lnadOlR)^4wf>pE`xC&6^QEZJMU-H36hBl;!3k zZ0b}=OTX~&0mjL$y|c6Lb61LJx5H{S!#^TokQk|U0^{N^`MviL5FhU`NRI*9ySq{S z;)^i<{<}mrbn@hZMioWMME&;dm{e4Rs2gq=)~Uu%KEd&I>yW-?4dU*(XVBW#Z@)!V zUY?YYu{k-e19sjH9s_Lq@kdlHU5c>_7C1G?f_`+~JSpyRcin~bS6_vXpPy5ZHnSP! z3m3wC=n$fBz8RT?g@ev916s9W1v<*gkiB&)f>KgEV$majsN@TzPJ_3`I2N|Oy zN9WIn^~@PWPoIv=jT_+`65`aU-j)`WKK?kmYHARio{lNowjm%kcF;pNIV)7R7v2C^JLYdzyax4MEAt7&BwWfRZud0M#?dK=-e0 zXMg?~6^j>3FUnR*>F!2PeZ5?HSiEEIxC3FAUJC!HsQ)-p6j*{dyi6;AaQAVWLB(c6&8k(fl$Ijv*er{8CeiOcWte7D7;E>A6|ucxn*&WpECzRsi|oB<{L~bD3GRr>)GKX z%$bA4M;{d|?SKA>vL~O!xJ8Q`pSc6lZGhaY{-cjjw|R44Ey~W8hMLM6b^Y}YqXfvJ zOd(L~71!B1I&iYE5Wy2BApZXQ;TI7B-*Y1jmVkzxJCU(rgQHbD_R1^bzbYuK!3=O# zNr^`nVUGZ^XQYy{i(zC{203p@dh9WAi2Y^N3ol5d8HD~1ou&$_tf=3yw(`%Rj*24V zkC2QE*;maTvN%+WI24;60aQzcUs#xHD3bXD2~^ukn*^iJlvJDT^BH6Ybe5Onv=Nl9x|6^v(MEcsb0;aB{ z1Z>rYYY}x}kekR&`i(ryb?xD0}*8L|kp=bx7z)aCfY39Xs{I#M+^S5h`({dz=Q zcU_-~S}dsFwoPQ84kyi>E9u3;&$G)j8F%NMeT?qg2aYTP5)+ZSWJ%v(i8CA(-1_wx zbK7mw`<_4d8POC~H}5)rT)G&ZElRwM_s&hRd86QrvvtoNnD+0N;;jl0ouxX)+E0-@v$KQWn@YtQ>|LRq%(0=$Z;_kj% zS~9K7sXl5>(f%OqU0pc!?z@tj{DCF$p@)z-ckTdYhK#zRNX@I=vrk3iRh@PkedU0)!xQN`7U<$Yyqd& z4==i;=pbek1w5OLpM8dwJ$uCY;T{Ho^HBcvKZ~B6v*Fh2$KViFI1%HxfvVa6VVz^a zA8Mp{(E!xTQLX!oID=Xp)9XD0pk9&m);t**}C(Z+9UrYtQ00000 LNkvXXu0mjfOz}ge literal 0 HcmV?d00001 diff --git a/src/tests/fixtures/smiley_embedded_v2.png b/src/tests/fixtures/smiley_embedded_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..767249208ee80f0efff24908d734d7bce3da3c49 GIT binary patch literal 3079 zcmY*bX*^U98yyt>mQZ9V(x{Ma#yYkMDZ7yUy^L+fzK^j*7?gETkuAk!$!@Y6r6Obr zA+qn%AnRWkZ{6N6?}vMybIxPsKv^}#{_{uSaq~DO@2?r@5g|V{x@#B z7I{D*jL|xp>ZU=q6f0K`Q#7aQ03KZiAgKnRm03xY;LA=pMT8+ppkB^x+duc#^vzksf}bpz6cZbBY{A(33VQ|+k^_U)8py!1uC?+9JS_jee10v8^Ry}}tIM}$?(*rd2YvBOJ9TwuzFvpHIbPf?6vQp* zYZ~O8IqB3kCoJcLE~@vh z4jGl1|NHNuV~<3=;9}!4yR7%27G643wWG9w=+_XAO%X<)eB)p#EFuqeX55H{a2WQ(TVbm~(>WS?!_d zSq&PkC6(Y1z6-|nXyz>B2|)X}+KV4rNgjD5kyAkyjBuJ+=ty$;+R8s`VJm8Q`r1|3 zKwV=y16iwhd^sItI74RWY8$+B@=cFQlc|(%(=u zLewJ}Q%C$uSfA%VpPeUMl{hC5e9ARAIPF;o(sXuf0>I`e#gz4Cb09sl3Mq0lMA+lS zC8)=DQyslGo0~KpT@_>!X~W+l-lhJ%oBaD5P=^$r5*+RP9PEI+&(3~n1FlV=8ESb!#5)QJ$KbMi*c3;u$IFxPxr_8L*bpiea5wZ*=}VKH~l6 zmYMCd$0^l{iWPLYsWSp}9)~}*OeJmNtC`Ou%M`LF*_Eh7(!m&-4uBe z?;PWYKSRmj&fz?gK=s`OciCxudM`C2=|Amosu?rdAHF@3Z;pzV<&j1X zwp)evAJ61G@8sk}aPba5TpdLjr$53czSH>7^Yq0HweIl68uZ{r=kr``QnFZWp^ztW zRQjOxOx`%Q8{)Lf>+|yX5t6g<-j}wbL6~q_ej8sfxwmw<(WV-b zoz1Vn(b~_KU5ie*+0(T4-Df9Gm_2OB!0(gKzHhdAJLYEZE3>M{>Ci(wTlD47J8ZAE zx9v%mL}vSqF70j7Le52AD|WR&&vL9ti>~h_+nAt6v@an~hC#ig#N}Y=g1)#&N@2v> z*zA>_O#BC}OI+fV`_#A^;k*wGw-PSqHPQ`wMog4Z3kXK_!TuirzXQKj6L(kI}=-Qrbk`XewK7Aya8t@pN_FR{x9S zc<4MKC6Q&!qF0~>VVZ#8%od9U=8tpxQD(*1)x#N4qL;x$T8AtM zQ0^Z^`s$^3O1wNMt^90dBVj?T(vwWJeIntolceDA?voVTSc@{Gzs756 z3oD4HJceW4IRflcvO5h9Go5F+c}2zjo^|awT=@8@@{SrLE0Cfwl!1z>XG3ycnXZCy zaKDmh@4!eaVqU5cE`}QUcAYOe%_8Vr#J{*G68g(k6A0kC*1DG^1X*Wf84c+_b84<5-5PJ*SaY{o@MA8z|0nG%W1n|`(@bGQDI93xc_yLTlDOYzP#s(_lSm5a7kIk2kug`!#Gd<7 zsbeBsIp3RXHfR?1l)tRDNKbb`f!X<{L|~gM5`WBUbW1ElO6%kFebnaH-1(F5W~HCQ z|Ao!1xBkaky1b_Z>1H@U6{OhMAa>WA)3S~tul{`TAO#V)4#C7GHsx#gmQKF@$MeP! zWcss_V)H+2$l_0BX50~TM~Yfjl06J(w3XJMo*nar z-5PgTvBec~EurmsJ+v~z2Cq(ru9k$v^m5zH{k#QygI{<(+HjeUxw?9U7&7 zx)lPULt<}PV(oo=JX{^^v98`;QaCRsNpz5-y@#ulpM9X1o4>agBovB6`}zOgLY1I! zs5lhu<>>9?>gD`fI{0J#{xET7yBcjK{;rXPhQ_(KmZH| zhqWAv{UI>fC2Lt}iEpXi<&Quu4fsV7Yrza$R&4nZz z1EK!+wSx;7yxScl@*twKrT$*%1o$<#uMT4sZDwR^$Z=Ev=m6{B z1u*o=`z8QTsTzCjnzu9h1Hj5(x! + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nGVSy07jMFx1MDAxNN3zXHUwMDE1kdmOIE1IXztcdTAwMWVcdTAwMDNiw6ZcdTAwMTKjXHUwMDExXHUwMDFhjUxym1hcdTAwMTjbsm9pU4TEZ8xufpFPwNcpcVqyiHTPfVx1MDAxZJ9zX4+ShGFrgM1cdTAwMTNcdTAwMDabkktRWb5mP1xif1x1MDAwMeuEVj6VhdjplS1DZYNo5qenUvuGRjuc52madk0g4Vx1MDAxOVx1MDAxNDpf9uDjJHlccn+fXHUwMDExXHUwMDE1tS4up2Urr2/M9lwivV1v26vf8Pc+tIaiLy5cYlx1MDAxYozoxkPT6biPW1x1MDAxZufF5KTokbWosCE0XHUwMDE2NSDqXHUwMDA2PVZMeoyrWtL8tEdcdTAwMWNa/Vx1MDAwNJdaakt7j8tZxjNcdTAwMWVXP/LyqbZ6paq+XHUwMDA2LVfOcOufXHUwMDE565ZCylx1MDAwNbay04eXzcpcdTAwMDI72PJrR3J0gPd9Tnv9Y5dfWzdcbpzb69GGl1x1MDAwMkmCUVx1MDAxYd9BXHUwMDFjzW1cdTAwMTV0/3M4v+HW7OYwR8GAXHUwMDE5XHUwMDAw+ZJl6WSSj2dxzcD9/Fx1MDAxMLzTKlx1MDAxY0IxK/JiXHUwMDFj08Jdef8xTFxccukgykhcbv7sbqNjqVZSRtvJbk/u4/+/94GmWuFCbGHfV0Kv+bOQ7Z4sNOJcXIqaXHUwMDE4M1x0y4E3njVcbn+qfVx1MDAxYbVcdTAwMTk67EBcbkVbzkZcdTAwMDF88/+gIePGLJAj5bo7Zi9cdTAwMDLWXHUwMDE332/ieFx1MDAxOb7dVO+GqHbM6Z1HNPPtXHUwMDEz+I3nwSJ9 + + + + 😀 \ No newline at end of file diff --git a/src/tests/fixtures/test_embedded_v1.png b/src/tests/fixtures/test_embedded_v1.png new file mode 100644 index 0000000000000000000000000000000000000000..d5447223d2b4351093003640fd074d2d9a8b709a GIT binary patch literal 3100 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 + + + + test \ No newline at end of file diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 5a2e82c6b..478726621 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -138,19 +138,22 @@ export class API { return element as any; }; - static dropFile(sceneData: ImportedDataState) { + static dropFile(data: ImportedDataState | Blob) { const fileDropEvent = createEvent.drop(GlobalTestState.canvas); - const file = new Blob( - [ - JSON.stringify({ - type: "excalidraw", - ...sceneData, - }), - ], - { - type: "application/json", - }, - ); + const file = + data instanceof Blob + ? data + : new Blob( + [ + JSON.stringify({ + type: "excalidraw", + ...data, + }), + ], + { + type: "application/json", + }, + ); Object.defineProperty(fileDropEvent, "dataTransfer", { value: { files: [file],