mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	feat: better file normalization (#10024)
* feat: better file normalization * fix lint * fix png detection * optimize * fix type
This commit is contained in:
		
				
					committed by
					
						
						Mark Tolmacs
					
				
			
			
				
	
			
			
			
						parent
						
							af4e1befef
						
					
				
				
					commit
					2fbfca5067
				
			@@ -470,13 +470,14 @@ export const parseDataTransferEvent = async (
 | 
			
		||||
      Array.from(items || []).map(
 | 
			
		||||
        async (item): Promise<ParsedDataTransferItem | null> => {
 | 
			
		||||
          if (item.kind === "file") {
 | 
			
		||||
            const file = item.getAsFile();
 | 
			
		||||
            let file = item.getAsFile();
 | 
			
		||||
            if (file) {
 | 
			
		||||
              const fileHandle = await getFileHandle(item);
 | 
			
		||||
              file = await normalizeFile(file);
 | 
			
		||||
              return {
 | 
			
		||||
                type: file.type,
 | 
			
		||||
                kind: "file",
 | 
			
		||||
                file: await normalizeFile(file),
 | 
			
		||||
                file,
 | 
			
		||||
                fileHandle,
 | 
			
		||||
              };
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -23,7 +23,7 @@ import { restore, restoreLibraryItems } from "./restore";
 | 
			
		||||
 | 
			
		||||
import type { AppState, DataURL, LibraryItem } from "../types";
 | 
			
		||||
 | 
			
		||||
import type { FileSystemHandle } from "./filesystem";
 | 
			
		||||
import type { FileSystemHandle } from "browser-fs-access";
 | 
			
		||||
import type { ImportedLibraryData } from "./types";
 | 
			
		||||
 | 
			
		||||
const parseFileContents = async (blob: Blob | File): Promise<string> => {
 | 
			
		||||
@@ -414,37 +414,42 @@ export const getFileHandle = async (
 | 
			
		||||
/**
 | 
			
		||||
 * attempts to detect if a buffer is a valid image by checking its leading bytes
 | 
			
		||||
 */
 | 
			
		||||
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
 | 
			
		||||
  let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
 | 
			
		||||
    null;
 | 
			
		||||
const getActualMimeTypeFromImage = async (file: Blob | File) => {
 | 
			
		||||
  let mimeType: ValueOf<
 | 
			
		||||
    Pick<typeof MIME_TYPES, "png" | "jpg" | "gif" | "webp">
 | 
			
		||||
  > | null = null;
 | 
			
		||||
 | 
			
		||||
  const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
 | 
			
		||||
  const leadingBytes = [
 | 
			
		||||
    ...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))),
 | 
			
		||||
  ].join(" ");
 | 
			
		||||
 | 
			
		||||
  // uint8 leading bytes
 | 
			
		||||
  const headerBytes = {
 | 
			
		||||
  const bytes = {
 | 
			
		||||
    // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
 | 
			
		||||
    png: "137 80 78 71 13 10 26 10 ",
 | 
			
		||||
    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 ",
 | 
			
		||||
    jpg: /^255 216 255\b/,
 | 
			
		||||
    // https://en.wikipedia.org/wiki/GIF#Example_GIF_file
 | 
			
		||||
    gif: "71 73 70 56 57 97 ",
 | 
			
		||||
    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/,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (first8Bytes === headerBytes.png) {
 | 
			
		||||
    mimeType = MIME_TYPES.png;
 | 
			
		||||
  } else if (first8Bytes.startsWith(headerBytes.jpg)) {
 | 
			
		||||
    mimeType = MIME_TYPES.jpg;
 | 
			
		||||
  } else if (first8Bytes.startsWith(headerBytes.gif)) {
 | 
			
		||||
    mimeType = MIME_TYPES.gif;
 | 
			
		||||
  for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) {
 | 
			
		||||
    if (leadingBytes.match(bytes[type])) {
 | 
			
		||||
      mimeType = MIME_TYPES[type];
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return mimeType;
 | 
			
		||||
 | 
			
		||||
  return mimeType || file.type || null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const createFile = (
 | 
			
		||||
  blob: File | Blob | ArrayBuffer,
 | 
			
		||||
  mimeType: ValueOf<typeof MIME_TYPES>,
 | 
			
		||||
  mimeType: string,
 | 
			
		||||
  name: string | undefined,
 | 
			
		||||
) => {
 | 
			
		||||
  return new File([blob], name || "", {
 | 
			
		||||
@@ -452,40 +457,33 @@ export const createFile = (
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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) => {
 | 
			
		||||
  if (!file.type) {
 | 
			
		||||
    if (file?.name?.endsWith(".excalidrawlib")) {
 | 
			
		||||
      file = createFile(
 | 
			
		||||
        await blobToArrayBuffer(file),
 | 
			
		||||
        MIME_TYPES.excalidrawlib,
 | 
			
		||||
        file.name,
 | 
			
		||||
      );
 | 
			
		||||
    } else if (file?.name?.endsWith(".excalidraw")) {
 | 
			
		||||
      file = createFile(
 | 
			
		||||
        await blobToArrayBuffer(file),
 | 
			
		||||
        MIME_TYPES.excalidraw,
 | 
			
		||||
        file.name,
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      const buffer = await blobToArrayBuffer(file);
 | 
			
		||||
      const mimeType = getActualMimeTypeFromImage(buffer);
 | 
			
		||||
      if (mimeType) {
 | 
			
		||||
        file = createFile(buffer, mimeType, file.name);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  // 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 sometime)
 | 
			
		||||
  } else if (isSupportedImageFile(file)) {
 | 
			
		||||
    const buffer = await blobToArrayBuffer(file);
 | 
			
		||||
    const mimeType = getActualMimeTypeFromImage(buffer);
 | 
			
		||||
    // 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(buffer, mimeType, file.name);
 | 
			
		||||
      file = createFile(file, mimeType, file.name);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  (file as any)[normalizedFileSymbol] = true;
 | 
			
		||||
 | 
			
		||||
  return file;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
 | 
			
		||||
 | 
			
		||||
import { AbortError } from "../errors";
 | 
			
		||||
 | 
			
		||||
import { normalizeFile } from "./blob";
 | 
			
		||||
 | 
			
		||||
import type { FileSystemHandle } from "browser-fs-access";
 | 
			
		||||
 | 
			
		||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
 | 
			
		||||
 | 
			
		||||
const INPUT_CHANGE_INTERVAL_MS = 500;
 | 
			
		||||
 | 
			
		||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
 | 
			
		||||
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
 | 
			
		||||
  extensions?: FILE_EXTENSION[];
 | 
			
		||||
  description: string;
 | 
			
		||||
  multiple?: M;
 | 
			
		||||
@@ -35,7 +37,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
 | 
			
		||||
    return acc.concat(`.${ext}`);
 | 
			
		||||
  }, [] as string[]);
 | 
			
		||||
 | 
			
		||||
  return _fileOpen({
 | 
			
		||||
  const files = await _fileOpen({
 | 
			
		||||
    description: opts.description,
 | 
			
		||||
    extensions,
 | 
			
		||||
    mimeTypes,
 | 
			
		||||
@@ -74,7 +76,14 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
  }) as Promise<RetType>;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (Array.isArray(files)) {
 | 
			
		||||
    return (await Promise.all(
 | 
			
		||||
      files.map((file) => normalizeFile(file)),
 | 
			
		||||
    )) as RetType;
 | 
			
		||||
  }
 | 
			
		||||
  return (await normalizeFile(files)) as RetType;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const fileSave = (
 | 
			
		||||
 
 | 
			
		||||
@@ -10,7 +10,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
 | 
			
		||||
 | 
			
		||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
 | 
			
		||||
 | 
			
		||||
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
 | 
			
		||||
import { isImageFileHandle, loadFromBlob } from "./blob";
 | 
			
		||||
import { fileOpen, fileSave } from "./filesystem";
 | 
			
		||||
 | 
			
		||||
import type { AppState, BinaryFiles, LibraryItems } from "../types";
 | 
			
		||||
@@ -100,12 +100,7 @@ export const loadFromJSON = async (
 | 
			
		||||
    // gets resolved. Else, iOS users cannot open `.excalidraw` files.
 | 
			
		||||
    // extensions: ["json", "excalidraw", "png", "svg"],
 | 
			
		||||
  });
 | 
			
		||||
  return loadFromBlob(
 | 
			
		||||
    await normalizeFile(file),
 | 
			
		||||
    localAppState,
 | 
			
		||||
    localElements,
 | 
			
		||||
    file.handle,
 | 
			
		||||
  );
 | 
			
		||||
  return loadFromBlob(file, localAppState, localElements, file.handle);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isValidExcalidrawData = (data?: {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user