feat: better file normalization (#10024)

* feat: better file normalization

* fix lint

* fix png detection

* optimize

* fix type
This commit is contained in:
David Luzar
2025-09-25 22:26:58 +02:00
committed by GitHub
parent a89a03c66c
commit a8acc8212d
4 changed files with 57 additions and 54 deletions

View File

@@ -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,
}; };
} }

View File

@@ -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) => { const getActualMimeTypeFromImage = async (file: Blob | File) => {
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null = let mimeType: ValueOf<
null; 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) { for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) {
mimeType = MIME_TYPES.png; if (leadingBytes.match(bytes[type])) {
} else if (first8Bytes.startsWith(headerBytes.jpg)) { mimeType = MIME_TYPES[type];
mimeType = MIME_TYPES.jpg; break;
} else if (first8Bytes.startsWith(headerBytes.gif)) { }
mimeType = MIME_TYPES.gif;
} }
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) { // to prevent double normalization (perf optim)
if (file?.name?.endsWith(".excalidrawlib")) { if ((file as any)[normalizedFileSymbol]) {
file = createFile( return file;
await blobToArrayBuffer(file), }
MIME_TYPES.excalidrawlib,
file.name, if (file?.name?.endsWith(".excalidrawlib")) {
); file = createFile(file, MIME_TYPES.excalidrawlib, file.name);
} else if (file?.name?.endsWith(".excalidraw")) { } else if (file?.name?.endsWith(".excalidraw")) {
file = createFile( file = createFile(file, MIME_TYPES.excalidraw, file.name);
await blobToArrayBuffer(file), } else if (!file.type || file.type?.startsWith("image/")) {
MIME_TYPES.excalidraw,
file.name,
);
} else {
const buffer = await blobToArrayBuffer(file);
const mimeType = getActualMimeTypeFromImage(buffer);
if (mimeType) {
file = createFile(buffer, mimeType, file.name);
}
}
// 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) // actual mimeType (this is an edge case, but happens - especially
} else if (isSupportedImageFile(file)) { // with AI generated images)
const buffer = await blobToArrayBuffer(file); const mimeType = await getActualMimeTypeFromImage(file);
const mimeType = getActualMimeTypeFromImage(buffer);
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;
}; };

View File

@@ -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 = (

View File

@@ -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( return loadFromBlob(file, localAppState, localElements, file.handle);
await normalizeFile(file),
localAppState,
localElements,
file.handle,
);
}; };
export const isValidExcalidrawData = (data?: { export const isValidExcalidrawData = (data?: {