import { nanoid } from "nanoid"; import { IMAGE_MIME_TYPES, MIME_TYPES, bytesToHexString, isPromiseLike, } from "@excalidraw/common"; import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; import { cleanAppStateForExport } from "../appState"; import { CanvasError, ImageSceneDataError } from "../errors"; import { getScrollToContentState } from "../scene"; import { decodeSvgBase64Payload } from "../scene/export"; import { base64ToString, stringToBase64, toByteString } from "./encode"; import { nativeFileSystemSupported } from "./filesystem"; import { isValidExcalidrawData, isValidLibrary } from "./json"; import { restoreAppState, restoreElements, restoreLibraryItems, } from "./restore"; import type { AppState, DataURL, LibraryItem } from "../types"; import type { ImportedLibraryData } from "./types"; const parseFileContents = async (blob: Blob | File): Promise => { let contents: string; if (blob.type === MIME_TYPES.png) { try { return await (await import("./image")).decodePngMetadata(blob); } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { throw new ImageSceneDataError("Error: cannot restore image"); } } } else { if ("text" in Blob) { contents = await blob.text(); } else { contents = await new Promise((resolve) => { const reader = new FileReader(); reader.readAsText(blob, "utf8"); reader.onloadend = () => { if (reader.readyState === FileReader.DONE) { resolve(reader.result as string); } }; }); } if (blob.type === MIME_TYPES.svg) { try { return decodeSvgBase64Payload({ svg: contents, }); } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { throw new ImageSceneDataError("Error: cannot restore image"); } } } } return contents; }; export const getMimeType = (blob: Blob | string): string => { let name: string; if (typeof blob === "string") { name = blob; } else { if (blob.type) { return blob.type; } name = blob.name || ""; } if (/\.(excalidraw|json)$/.test(name)) { return MIME_TYPES.json; } else if (/\.png$/.test(name)) { return MIME_TYPES.png; } else if (/\.jpe?g$/.test(name)) { return MIME_TYPES.jpg; } else if (/\.svg$/.test(name)) { return MIME_TYPES.svg; } else if (/\.excalidrawlib$/.test(name)) { return MIME_TYPES.excalidrawlib; } return ""; }; export const getFileHandleType = (handle: FileSystemFileHandle | null) => { if (!handle) { return null; } return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null; }; export const isImageFileHandleType = ( type: string | null, ): type is "png" | "svg" => { return type === "png" || type === "svg"; }; export const isImageFileHandle = ( handle: FileSystemFileHandle | null, ): handle is FileSystemFileHandle => { const type = getFileHandleType(handle); return type === "png" || type === "svg"; }; export const isSupportedImageFileType = (type: string | null | undefined) => { return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type); }; export const isSupportedImageFile = ( blob: Blob | null | undefined, ): blob is Blob & { type: ValueOf } => { const { type } = blob || {}; return isSupportedImageFileType(type); }; export const loadSceneOrLibraryFromBlob = async ( blob: Blob | File, /** @see restore.localAppState */ localAppState: AppState | null, localElements: readonly ExcalidrawElement[] | null, /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */ fileHandle?: FileSystemFileHandle | null, ) => { const contents = await parseFileContents(blob); let data; try { try { data = JSON.parse(contents); } catch (error: any) { if (isSupportedImageFile(blob)) { throw new ImageSceneDataError( "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } throw error; } if (isValidExcalidrawData(data)) { const elements = restoreElements(data.elements, localElements, { repairBindings: true, deleteInvisibleElements: true, }); return { type: MIME_TYPES.excalidraw, data: { elements, appState: restoreAppState( { theme: localAppState?.theme, fileHandle: fileHandle || blob.handle || null, ...cleanAppStateForExport(data.appState || {}), ...(localAppState ? getScrollToContentState(elements, localAppState) : {}), }, localAppState, ), files: data.files || {}, }, }; } else if (isValidLibrary(data)) { return { type: MIME_TYPES.excalidrawlib, data, }; } throw new Error("Error: invalid file"); } catch (error: any) { if (error instanceof ImageSceneDataError) { throw error; } throw new Error("Error: invalid file"); } }; export const loadFromBlob = async ( blob: Blob, /** @see restore.localAppState */ localAppState: AppState | null, localElements: readonly ExcalidrawElement[] | null, /** FileSystemFileHandle. Defaults to `blob.handle` if defined, otherwise null. */ fileHandle?: FileSystemFileHandle | null, ) => { const ret = await loadSceneOrLibraryFromBlob( blob, localAppState, localElements, fileHandle, ); if (ret.type !== MIME_TYPES.excalidraw) { throw new Error("Error: invalid file"); } return ret.data; }; export const parseLibraryJSON = ( json: string, defaultStatus: LibraryItem["status"] = "unpublished", ) => { const data: ImportedLibraryData | undefined = JSON.parse(json); if (!isValidLibrary(data)) { throw new Error("Invalid library"); } const libraryItems = data.libraryItems || data.library; return restoreLibraryItems(libraryItems, defaultStatus); }; export const loadLibraryFromBlob = async ( blob: Blob, defaultStatus: LibraryItem["status"] = "unpublished", ) => { return parseLibraryJSON(await parseFileContents(blob), defaultStatus); }; export const canvasToBlob = async ( canvas: HTMLCanvasElement | Promise, ): Promise => { return new Promise(async (resolve, reject) => { try { if (isPromiseLike(canvas)) { canvas = await canvas; } canvas.toBlob((blob) => { if (!blob) { return reject( new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"), ); } resolve(blob); }); } catch (error: any) { reject(error); } }); }; /** generates SHA-1 digest from supplied file (if not supported, falls back to a 40-char base64 random id) */ export const generateIdFromFile = async (file: File): Promise => { try { const hashBuffer = await window.crypto.subtle.digest( "SHA-1", await blobToArrayBuffer(file), ); return bytesToHexString(new Uint8Array(hashBuffer)) as FileId; } catch (error: any) { console.error(error); // length 40 to align with the HEX length of SHA-1 (which is 160 bit) return nanoid(40) as FileId; } }; /** async. For sync variant, use getDataURL_sync */ export const getDataURL = async (file: Blob | File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const dataURL = reader.result as DataURL; resolve(dataURL); }; reader.onerror = (error) => reject(error); reader.readAsDataURL(file); }); }; export const getDataURL_sync = ( data: string | Uint8Array | ArrayBuffer, mimeType: ValueOf, ): DataURL => { return `data:${mimeType};base64,${stringToBase64( toByteString(data), true, )}` as DataURL; }; export const dataURLToFile = (dataURL: DataURL, filename = "") => { const dataIndexStart = dataURL.indexOf(","); const byteString = atob(dataURL.slice(dataIndexStart + 1)); const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0]; const ab = new ArrayBuffer(byteString.length); const ia = new Uint8Array(ab); for (let i = 0; i < byteString.length; i++) { ia[i] = byteString.charCodeAt(i); } return new File([ab], filename, { type: mimeType }); }; export const dataURLToString = (dataURL: DataURL) => { return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1)); }; const getImageFileDimensions = async (file: File) => { const browserURL = typeof window !== "undefined" ? window.URL : undefined; let objectURL: string | null = null; let imageSource: string; try { imageSource = browserURL?.createObjectURL ? (objectURL = browserURL.createObjectURL(file)) : await getDataURL(file); } catch { objectURL = null; imageSource = await getDataURL(file); } return new Promise<{ width: number; height: number }>((resolve, reject) => { const image = new Image(); const cleanup = () => { image.onload = null; image.onerror = null; if (objectURL && browserURL?.revokeObjectURL) { browserURL.revokeObjectURL(objectURL); } }; image.onload = () => { cleanup(); resolve({ width: image.naturalWidth || image.width, height: image.naturalHeight || image.height, }); }; image.onerror = (error) => { cleanup(); reject(error); }; image.src = imageSource; }); }; export const resizeImageFile = async ( file: File, opts: { /** undefined indicates auto */ outputType?: typeof MIME_TYPES["jpg"]; maxWidthOrHeight: number; }, ): Promise => { // SVG files shouldn't a can't be resized if (file.type === MIME_TYPES.svg) { return file; } if (!isSupportedImageFile(file)) { throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); } if (!opts.outputType || opts.outputType === file.type) { const dimensions = await getImageFileDimensions(file); if ( Math.max(dimensions.width, dimensions.height) <= opts.maxWidthOrHeight ) { return file; } } const [pica, imageBlobReduce] = await Promise.all([ import("pica").then((res) => res.default), // a wrapper for pica for better API import("image-blob-reduce").then((res) => res.default), ]); // CRA's minification settings break pica in WebWorkers, so let's disable // them for now // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513 const reduce = imageBlobReduce({ pica: pica({ features: ["js", "wasm"] }), }); if (opts.outputType) { const { outputType } = opts; reduce._create_blob = function (env) { return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => { env.out_blob = blob; return env; }); }; } return new File( [await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })], file.name, { type: opts.outputType || file.type, }, ); }; export const SVGStringToFile = (SVGString: string, filename: string = "") => { return new File([new TextEncoder().encode(SVGString)], filename, { type: MIME_TYPES.svg, }) as File & { type: typeof MIME_TYPES.svg }; }; export const ImageURLToFile = async ( imageUrl: string, filename: string = "", ): Promise => { let response; try { response = await fetch(imageUrl); } catch (error: any) { throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } if (!response.ok) { throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } const blob = await response.blob(); if (blob.type && isSupportedImageFile(blob)) { const name = filename || blob.name || ""; return new File([blob], name, { type: blob.type }); } throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); }; export const getFileHandle = async ( event: DragEvent | React.DragEvent | DataTransferItem, ): Promise => { if (nativeFileSystemSupported) { try { const dataTransferItem = event instanceof DataTransferItem ? event : (event as DragEvent).dataTransfer?.items?.[0]; const handle: FileSystemFileHandle | null = (await (dataTransferItem as any).getAsFileSystemHandle()) || null; return handle; } catch (error: any) { console.warn(error.name, error.message); return null; } } return null; }; /** * attempts to detect if a buffer is a valid image by checking its leading bytes */ const getActualMimeTypeFromImage = async (file: Blob | File) => { let mimeType: ValueOf< Pick > | null = null; const leadingBytes = [ ...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))), ].join(" "); // uint8 leading bytes const bytes = { // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header png: /^137 80 78 71 13 10 26 10\b/, // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure // jpg is a bit wonky. Checking the first three bytes should be enough, // but may yield false positives. (https://stackoverflow.com/a/23360709/927631) jpg: /^255 216 255\b/, // https://en.wikipedia.org/wiki/GIF#Example_GIF_file gif: /^71 73 70 56 57 97\b/, // 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56\b/, }; for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) { if (leadingBytes.match(bytes[type])) { mimeType = MIME_TYPES[type]; break; } } return mimeType || file.type || null; }; export const createFile = ( blob: File | Blob | ArrayBuffer, mimeType: string, name: string | undefined, ) => { return new File([blob], name || "", { type: mimeType, }); }; const normalizedFileSymbol = Symbol("fileNormalized"); /** attempts to detect correct mimeType if none is set, or if an image * has an incorrect extension. * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ export const normalizeFile = async (file: File) => { // to prevent double normalization (perf optim) if ((file as any)[normalizedFileSymbol]) { return file; } if (file?.name?.endsWith(".excalidrawlib")) { file = createFile(file, MIME_TYPES.excalidrawlib, file.name); } else if (file?.name?.endsWith(".excalidraw")) { file = createFile(file, MIME_TYPES.excalidraw, file.name); } else if (!file.type || file.type?.startsWith("image/")) { // when the file is an image, make sure the extension corresponds to the // actual mimeType (this is an edge case, but happens - especially // with AI generated images) const mimeType = await getActualMimeTypeFromImage(file); if (mimeType && mimeType !== file.type) { file = createFile(file, mimeType, file.name); } } (file as any)[normalizedFileSymbol] = true; return file; }; export 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); }); };