浏览代码

feat: better file normalization (#10024)

* feat: better file normalization

* fix lint

* fix png detection

* optimize

* fix type
David Luzar 4 周之前
父节点
当前提交
a8acc8212d

+ 3 - 2
packages/excalidraw/clipboard.ts

@@ -470,13 +470,14 @@ export const parseDataTransferEvent = async (
       Array.from(items || []).map(
       Array.from(items || []).map(
         async (item): Promise<ParsedDataTransferItem | null> => {
         async (item): Promise<ParsedDataTransferItem | null> => {
           if (item.kind === "file") {
           if (item.kind === "file") {
-            const file = item.getAsFile();
+            let file = item.getAsFile();
             if (file) {
             if (file) {
               const fileHandle = await getFileHandle(item);
               const fileHandle = await getFileHandle(item);
+              file = await normalizeFile(file);
               return {
               return {
                 type: file.type,
                 type: file.type,
                 kind: "file",
                 kind: "file",
-                file: await normalizeFile(file),
+                file,
                 fileHandle,
                 fileHandle,
               };
               };
             }
             }

+ 40 - 42
packages/excalidraw/data/blob.ts

@@ -25,7 +25,7 @@ import { restore, restoreLibraryItems } from "./restore";
 
 
 import type { AppState, DataURL, LibraryItem } from "../types";
 import type { AppState, DataURL, LibraryItem } from "../types";
 
 
-import type { FileSystemHandle } from "./filesystem";
+import type { FileSystemHandle } from "browser-fs-access";
 import type { ImportedLibraryData } from "./types";
 import type { ImportedLibraryData } from "./types";
 
 
 const parseFileContents = async (blob: Blob | File): Promise<string> => {
 const parseFileContents = async (blob: Blob | File): Promise<string> => {
@@ -416,37 +416,42 @@ export const getFileHandle = async (
 /**
 /**
  * attempts to detect if a buffer is a valid image by checking its leading bytes
  * 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
   // uint8 leading bytes
-  const headerBytes = {
+  const bytes = {
     // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
     // 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
     // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
     // jpg is a bit wonky. Checking the first three bytes should be enough,
     // jpg is a bit wonky. Checking the first three bytes should be enough,
     // but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
     // 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
     // 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 = (
 export const createFile = (
   blob: File | Blob | ArrayBuffer,
   blob: File | Blob | ArrayBuffer,
-  mimeType: ValueOf<typeof MIME_TYPES>,
+  mimeType: string,
   name: string | undefined,
   name: string | undefined,
 ) => {
 ) => {
   return new File([blob], name || "", {
   return new File([blob], name || "", {
@@ -454,40 +459,33 @@ export const createFile = (
   });
   });
 };
 };
 
 
+const normalizedFileSymbol = Symbol("fileNormalized");
+
 /** attempts to detect correct mimeType if none is set, or if an image
 /** attempts to detect correct mimeType if none is set, or if an image
  * has an incorrect extension.
  * has an incorrect extension.
  * Note: doesn't handle missing .excalidraw/.excalidrawlib extension  */
  * Note: doesn't handle missing .excalidraw/.excalidrawlib extension  */
 export const normalizeFile = async (file: File) => {
 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
     // 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) {
     if (mimeType && mimeType !== file.type) {
-      file = createFile(buffer, mimeType, file.name);
+      file = createFile(file, mimeType, file.name);
     }
     }
   }
   }
 
 
+  (file as any)[normalizedFileSymbol] = true;
+
   return file;
   return file;
 };
 };
 
 

+ 12 - 3
packages/excalidraw/data/filesystem.ts

@@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
 
 
 import { AbortError } from "../errors";
 import { AbortError } from "../errors";
 
 
+import { normalizeFile } from "./blob";
+
 import type { FileSystemHandle } from "browser-fs-access";
 import type { FileSystemHandle } from "browser-fs-access";
 
 
 type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
 type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
 
 
 const INPUT_CHANGE_INTERVAL_MS = 500;
 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[];
   extensions?: FILE_EXTENSION[];
   description: string;
   description: string;
   multiple?: M;
   multiple?: M;
@@ -35,7 +37,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
     return acc.concat(`.${ext}`);
     return acc.concat(`.${ext}`);
   }, [] as string[]);
   }, [] as string[]);
 
 
-  return _fileOpen({
+  const files = await _fileOpen({
     description: opts.description,
     description: opts.description,
     extensions,
     extensions,
     mimeTypes,
     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 = (
 export const fileSave = (

+ 2 - 7
packages/excalidraw/data/json.ts

@@ -15,7 +15,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 
 import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
 import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
 
 
-import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
+import { isImageFileHandle, loadFromBlob } from "./blob";
 import { fileOpen, fileSave } from "./filesystem";
 import { fileOpen, fileSave } from "./filesystem";
 
 
 import type { AppState, BinaryFiles, LibraryItems } from "../types";
 import type { AppState, BinaryFiles, LibraryItems } from "../types";
@@ -108,12 +108,7 @@ export const loadFromJSON = async (
     // gets resolved. Else, iOS users cannot open `.excalidraw` files.
     // gets resolved. Else, iOS users cannot open `.excalidraw` files.
     // extensions: ["json", "excalidraw", "png", "svg"],
     // 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?: {
 export const isValidExcalidrawData = (data?: {