From 25a0c34cb9707619c41fd42926eaed16a22c248b Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Fri, 28 Mar 2025 00:01:41 +0000 Subject: [PATCH 01/10] added package to project --- packages/excalidraw/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 29239b486..3780565ac 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -87,13 +87,14 @@ "image-blob-reduce": "3.0.1", "jotai": "2.11.0", "jotai-scope": "0.7.2", - "lodash.throttle": "4.1.1", "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", "pako": "2.0.3", "perfect-freehand": "1.2.0", "pica": "7.1.1", + "png-chunk-itxt": "1.0.0", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", "png-chunks-extract": "1.0.0", From 04d0b00b957d71d75387fefafd0162058af283d4 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Fri, 28 Mar 2025 01:43:05 +0000 Subject: [PATCH 02/10] Changed global file for iTXt --- packages/excalidraw/global.d.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index e9b6c3f96..f3c2e2f26 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -33,6 +33,7 @@ interface Clipboard extends EventTarget { // PNG encoding/decoding // ----------------------------------------------------------------------------- type TEXtChunk = { name: "tEXt"; data: Uint8Array }; +type ITXtChunk = { name: "iTXt"; data: Uint8Array }; declare module "png-chunk-text" { function encode( @@ -41,12 +42,29 @@ declare module "png-chunk-text" { ): { name: "tEXt"; data: Uint8Array }; function decode(data: Uint8Array): { keyword: string; text: string }; } + +declare module "png-chunk-itxt" { + function encode( + keyword: string, + text: string, + options?: { compressed?: boolean; language?: string; translated?: string }, + ): { name: "iTXt"; data: Uint8Array }; + function decode(data: Uint8Array): { + keyword: string; + text: string; + language?: string; + translated?: string; + }; + export { encode, decode }; +} + declare module "png-chunks-encode" { - function encode(chunks: TEXtChunk[]): Uint8Array; + function encode(chunks: (TEXtChunk | ITXtChunk)[]): Uint8Array; export = encode; } + declare module "png-chunks-extract" { - function extract(buffer: Uint8Array): TEXtChunk[]; + function extract(buffer: Uint8Array): (TEXtChunk | ITXtChunk)[]; export = extract; } // ----------------------------------------------------------------------------- From 22869ee58cd1f8458ff908650fa516052ab1311b Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Sat, 29 Mar 2025 20:23:12 +0000 Subject: [PATCH 03/10] first implementation of itxt on image.ts --- packages/excalidraw/data/image.ts | 78 ++++++++++++++++++++++++------- packages/utils/src/export.ts | 4 +- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index c9c84c95b..d1425b804 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -1,4 +1,5 @@ import tEXt from "png-chunk-text"; +import { encode as encodeITXt, decode as decodeITXt } from "png-chunk-itxt"; import encodePng from "png-chunks-encode"; import decodePng from "png-chunks-extract"; @@ -11,48 +12,89 @@ import { encode, decode } from "./encode"; // PNG // ----------------------------------------------------------------------------- -export const getTEXtChunk = async ( +export const getMetadataChunk = 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); + + const iTXtChunk = chunks.find((chunk) => chunk.name === "iTXt"); + if (iTXtChunk) { + try { + const decoded = decodeITXt(iTXtChunk.data); + return { keyword: decoded.keyword, text: decoded.text }; + } catch (error) { + console.error("Failed to decode iTXt chunk:", error); + } } + + const tEXtChunk = chunks.find((chunk) => chunk.name === "tEXt"); + if (tEXtChunk) { + return tEXt.decode(tEXtChunk.data); + } + return null; }; export const encodePngMetadata = async ({ blob, metadata, + useITXt = true, }: { blob: Blob; metadata: string; + useITXt?: boolean; }) => { const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - - const metadataChunk = tEXt.encode( - MIME_TYPES.excalidraw, - JSON.stringify( - encode({ - text: metadata, - compress: true, - }), - ), + + const filteredChunks = chunks.filter( + (chunk) => + !(chunk.name === "tEXt" && + tEXt.decode(chunk.data).keyword === MIME_TYPES.excalidraw) && + !(chunk.name === "iTXt" && + decodeITXt(chunk.data).keyword === MIME_TYPES.excalidraw) + ); + + const encodedData = JSON.stringify( + encode({ + text: metadata, + compress: true, + }), ); - // insert metadata before last chunk (iEND) - chunks.splice(-1, 0, metadataChunk); - return new Blob([encodePng(chunks)], { type: MIME_TYPES.png }); + let metadataChunk; + try { + if (useITXt) { + metadataChunk = encodeITXt( + MIME_TYPES.excalidraw, + encodedData, + { + compressed: false, //Already compressed in encode + language: "en", + translated: "" + } + ); + } else { + throw new Error("Fallback to tEXt"); + } + } catch (error) { + console.warn("iTXt encoding failed, falling back to tEXt:", error); + metadataChunk = tEXt.encode( + MIME_TYPES.excalidraw, + encodedData, + ); + } + + filteredChunks.splice(-1, 0, metadataChunk); + + return new Blob([encodePng(filteredChunks)], { type: MIME_TYPES.png }); }; export const decodePngMetadata = async (blob: Blob) => { - const metadata = await getTEXtChunk(blob); + const metadata = await getMetadataChunk(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 === EXPORT_DATA_TYPES.excalidraw diff --git a/packages/utils/src/export.ts b/packages/utils/src/export.ts index 4559fe1af..347bf971d 100644 --- a/packages/utils/src/export.ts +++ b/packages/utils/src/export.ts @@ -101,9 +101,10 @@ export const exportToBlob = async ( mimeType?: string; quality?: number; exportPadding?: number; + useITXt?: boolean; }, ): Promise => { - let { mimeType = MIME_TYPES.png, quality } = opts; + let { mimeType = MIME_TYPES.png, quality, useITXt = true } = opts; if (mimeType === MIME_TYPES.png && typeof quality === "number") { console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`); @@ -150,6 +151,7 @@ export const exportToBlob = async ( opts.files || {}, "local", ), + useITXt, }); } resolve(blob); From 0a6c0b5950d3bbd638854a3cd3784e5633a99a68 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Sat, 29 Mar 2025 21:18:20 +0000 Subject: [PATCH 04/10] some minor improvements --- packages/excalidraw/data/image.ts | 17 +++++++++-------- packages/excalidraw/global.d.ts | 15 ++++++++------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index d1425b804..399ba87d3 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -17,7 +17,7 @@ export const getMetadataChunk = async ( ): Promise<{ keyword: string; text: string } | null> => { const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - const iTXtChunk = chunks.find((chunk) => chunk.name === "iTXt"); + const iTXtChunk = chunks.find((chunk) => chunk.name === PNGChunkType.iTXt); if (iTXtChunk) { try { const decoded = decodeITXt(iTXtChunk.data); @@ -27,7 +27,7 @@ export const getMetadataChunk = async ( } } - const tEXtChunk = chunks.find((chunk) => chunk.name === "tEXt"); + const tEXtChunk = chunks.find((chunk) => chunk.name === PNGChunkType.tEXt); if (tEXtChunk) { return tEXt.decode(tEXtChunk.data); } @@ -48,9 +48,9 @@ export const encodePngMetadata = async ({ const filteredChunks = chunks.filter( (chunk) => - !(chunk.name === "tEXt" && + !(chunk.name === PNGChunkType.tEXt && tEXt.decode(chunk.data).keyword === MIME_TYPES.excalidraw) && - !(chunk.name === "iTXt" && + !(chunk.name === PNGChunkType.iTXt && decodeITXt(chunk.data).keyword === MIME_TYPES.excalidraw) ); @@ -68,7 +68,8 @@ export const encodePngMetadata = async ({ MIME_TYPES.excalidraw, encodedData, { - compressed: false, //Already compressed in encode + compressed: true, + compressedMethod: 0, language: "en", translated: "" } @@ -101,13 +102,13 @@ export const decodePngMetadata = async (blob: Blob) => { ) { return metadata.text; } - throw new Error("FAILED"); + throw new Error("Malformed or unexpected metadata format"); } return decode(encodedData); } catch (error: any) { console.error(error); - throw new Error("FAILED"); + throw new Error("Malformed or unexpected metadata format"); } } - throw new Error("INVALID"); + throw new Error("Invalid or unsupported PNG metadata format"); }; diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index f3c2e2f26..401039b13 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -32,23 +32,26 @@ interface Clipboard extends EventTarget { // PNG encoding/decoding // ----------------------------------------------------------------------------- +enum PNGChunkType { + tEXt = "tEXt", + iTXt = "iTXt", +} + type TEXtChunk = { name: "tEXt"; data: Uint8Array }; type ITXtChunk = { name: "iTXt"; data: Uint8Array }; - declare module "png-chunk-text" { function encode( name: string, value: string, - ): { name: "tEXt"; data: Uint8Array }; + ): { name: PNGChunkType.tEXt; data: Uint8Array }; function decode(data: Uint8Array): { keyword: string; text: string }; } - declare module "png-chunk-itxt" { function encode( keyword: string, text: string, - options?: { compressed?: boolean; language?: string; translated?: string }, - ): { name: "iTXt"; data: Uint8Array }; + options?: { compressed?: boolean; compressedMethod: number; language?: string; translated?: string }, + ): { name: PNGChunkType.iTXt; data: Uint8Array }; function decode(data: Uint8Array): { keyword: string; text: string; @@ -57,12 +60,10 @@ declare module "png-chunk-itxt" { }; export { encode, decode }; } - declare module "png-chunks-encode" { function encode(chunks: (TEXtChunk | ITXtChunk)[]): Uint8Array; export = encode; } - declare module "png-chunks-extract" { function extract(buffer: Uint8Array): (TEXtChunk | ITXtChunk)[]; export = extract; From c8ccdf371698002a7be87ca2f99287bca927828d Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Sat, 29 Mar 2025 21:26:51 +0000 Subject: [PATCH 05/10] removing enum --- packages/excalidraw/data/image.ts | 8 ++++---- packages/excalidraw/global.d.ts | 10 +++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 399ba87d3..941c5fcdd 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -17,7 +17,7 @@ export const getMetadataChunk = async ( ): Promise<{ keyword: string; text: string } | null> => { const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - const iTXtChunk = chunks.find((chunk) => chunk.name === PNGChunkType.iTXt); + const iTXtChunk = chunks.find((chunk) => chunk.name === "iTXt"); if (iTXtChunk) { try { const decoded = decodeITXt(iTXtChunk.data); @@ -27,7 +27,7 @@ export const getMetadataChunk = async ( } } - const tEXtChunk = chunks.find((chunk) => chunk.name === PNGChunkType.tEXt); + const tEXtChunk = chunks.find((chunk) => chunk.name === "tEXt"); if (tEXtChunk) { return tEXt.decode(tEXtChunk.data); } @@ -48,9 +48,9 @@ export const encodePngMetadata = async ({ const filteredChunks = chunks.filter( (chunk) => - !(chunk.name === PNGChunkType.tEXt && + !(chunk.name === "tEXt" && tEXt.decode(chunk.data).keyword === MIME_TYPES.excalidraw) && - !(chunk.name === PNGChunkType.iTXt && + !(chunk.name === "iTXt" && decodeITXt(chunk.data).keyword === MIME_TYPES.excalidraw) ); diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 401039b13..e6ed123dc 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -32,18 +32,14 @@ interface Clipboard extends EventTarget { // PNG encoding/decoding // ----------------------------------------------------------------------------- -enum PNGChunkType { - tEXt = "tEXt", - iTXt = "iTXt", -} - type TEXtChunk = { name: "tEXt"; data: Uint8Array }; type ITXtChunk = { name: "iTXt"; data: Uint8Array }; + declare module "png-chunk-text" { function encode( name: string, value: string, - ): { name: PNGChunkType.tEXt; data: Uint8Array }; + ): { name: "tEXt"; data: Uint8Array }; function decode(data: Uint8Array): { keyword: string; text: string }; } declare module "png-chunk-itxt" { @@ -51,7 +47,7 @@ declare module "png-chunk-itxt" { keyword: string, text: string, options?: { compressed?: boolean; compressedMethod: number; language?: string; translated?: string }, - ): { name: PNGChunkType.iTXt; data: Uint8Array }; + ): { name: "iTXt"; data: Uint8Array }; function decode(data: Uint8Array): { keyword: string; text: string; From 770f44019a12f66159bb6bd536e9efdf26250cc4 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Sat, 29 Mar 2025 22:02:22 +0000 Subject: [PATCH 06/10] changed metadata output --- packages/excalidraw/data/image.ts | 22 +++++++++++++++++++--- packages/excalidraw/global.d.ts | 2 ++ yarn.lock | 12 ++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 941c5fcdd..070889fdf 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -14,14 +14,30 @@ import { encode, decode } from "./encode"; export const getMetadataChunk = async ( blob: Blob, -): Promise<{ keyword: string; text: string } | null> => { +): Promise<{ + keyword: string; + text: string; + compressionFlag?: boolean; + compressionMethod?: number; + languageTag?: string; + translatedKeyword?: string; + } | null> => { const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); const iTXtChunk = chunks.find((chunk) => chunk.name === "iTXt"); + if (iTXtChunk) { try { const decoded = decodeITXt(iTXtChunk.data); - return { keyword: decoded.keyword, text: decoded.text }; + console.log("Decoded iTXt chunk:", decoded); + return { + keyword: decoded.keyword, + text: decoded.text, + compressionFlag: decoded.compressed, + compressionMethod: decoded.compressedMethod, + languageTag: decoded.language || "", + translatedKeyword: decoded.translated || "" + }; } catch (error) { console.error("Failed to decode iTXt chunk:", error); } @@ -45,7 +61,7 @@ export const encodePngMetadata = async ({ useITXt?: boolean; }) => { const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - + debugger; const filteredChunks = chunks.filter( (chunk) => !(chunk.name === "tEXt" && diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index e6ed123dc..8688d354b 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -51,6 +51,8 @@ declare module "png-chunk-itxt" { function decode(data: Uint8Array): { keyword: string; text: string; + compressed?: boolean; + compressedMethod?: number; language?: string; translated?: string; }; diff --git a/yarn.lock b/yarn.lock index ccd0827fb..30a203b2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3882,6 +3882,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== +binary-parser@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/binary-parser/-/binary-parser-2.2.1.tgz#4edc6da2dc56db73fa5ba450dfe6382ede8294ce" + integrity sha512-5ATpz/uPDgq5GgEDxTB4ouXCde7q2lqAQlSdBRQVl/AJnxmQmhIfyxJx+0MGu//D5rHQifkfGbWWlaysG0o9NA== + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -7919,6 +7924,13 @@ pkg-dir@4.2.0: dependencies: find-up "^4.0.0" +png-chunk-itxt@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/png-chunk-itxt/-/png-chunk-itxt-1.0.0.tgz#4652547b7c88d512337599e422b7431f2c234355" + integrity sha512-/1gTTBlIBUL47FS1wXI5oW5zidHge1Lwn+w4WNsnTc6wu1i82l63hwz0mgw1x2eYFH4iYkHkmKH0FHoHYMmjig== + dependencies: + binary-parser "^2.2.1" + png-chunk-text@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz#1c6006d8e34ba471d38e1c9c54b3f53e1085e18f" From 6ee38ad8263647cf2f5d8d035a865ac8c198fc53 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Sun, 30 Mar 2025 20:11:50 +0100 Subject: [PATCH 07/10] renamed itxt function names --- packages/excalidraw/data/image.ts | 8 ++++---- packages/excalidraw/global.d.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 070889fdf..5796f42fc 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -1,5 +1,5 @@ import tEXt from "png-chunk-text"; -import { encode as encodeITXt, decode as decodeITXt } from "png-chunk-itxt"; +import { encodeSync, decodeSync} from "png-chunk-itxt"; import encodePng from "png-chunks-encode"; import decodePng from "png-chunks-extract"; @@ -28,7 +28,7 @@ export const getMetadataChunk = async ( if (iTXtChunk) { try { - const decoded = decodeITXt(iTXtChunk.data); + const decoded = decodeSync(iTXtChunk.data); console.log("Decoded iTXt chunk:", decoded); return { keyword: decoded.keyword, @@ -67,7 +67,7 @@ export const encodePngMetadata = async ({ !(chunk.name === "tEXt" && tEXt.decode(chunk.data).keyword === MIME_TYPES.excalidraw) && !(chunk.name === "iTXt" && - decodeITXt(chunk.data).keyword === MIME_TYPES.excalidraw) + decodeSync(chunk.data).keyword === MIME_TYPES.excalidraw) ); const encodedData = JSON.stringify( @@ -80,7 +80,7 @@ export const encodePngMetadata = async ({ let metadataChunk; try { if (useITXt) { - metadataChunk = encodeITXt( + metadataChunk = encodeSync( MIME_TYPES.excalidraw, encodedData, { diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 8688d354b..efbaa9292 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -43,12 +43,12 @@ declare module "png-chunk-text" { function decode(data: Uint8Array): { keyword: string; text: string }; } declare module "png-chunk-itxt" { - function encode( + function encodeSync( keyword: string, text: string, options?: { compressed?: boolean; compressedMethod: number; language?: string; translated?: string }, ): { name: "iTXt"; data: Uint8Array }; - function decode(data: Uint8Array): { + function decodeSync (data: Uint8Array): { keyword: string; text: string; compressed?: boolean; @@ -56,7 +56,7 @@ declare module "png-chunk-itxt" { language?: string; translated?: string; }; - export { encode, decode }; + export { encodeSync, decodeSync }; } declare module "png-chunks-encode" { function encode(chunks: (TEXtChunk | ITXtChunk)[]): Uint8Array; From a89f353f5b668b25349a7072d4ce1fc4f7236d23 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Mon, 31 Mar 2025 00:48:48 +0100 Subject: [PATCH 08/10] minor changes and import buffer --- packages/excalidraw/data/image.ts | 10 +++++----- packages/excalidraw/global.d.ts | 1 - packages/utils/src/export.ts | 5 +++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 5796f42fc..4d72a26a4 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -1,5 +1,5 @@ import tEXt from "png-chunk-text"; -import { encodeSync, decodeSync} from "png-chunk-itxt"; +import * as iTXt from "png-chunk-itxt"; import encodePng from "png-chunks-encode"; import decodePng from "png-chunks-extract"; @@ -28,7 +28,7 @@ export const getMetadataChunk = async ( if (iTXtChunk) { try { - const decoded = decodeSync(iTXtChunk.data); + const decoded = iTXt.decodeSync(iTXtChunk.data); console.log("Decoded iTXt chunk:", decoded); return { keyword: decoded.keyword, @@ -61,13 +61,13 @@ export const encodePngMetadata = async ({ useITXt?: boolean; }) => { const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - debugger; + const filteredChunks = chunks.filter( (chunk) => !(chunk.name === "tEXt" && tEXt.decode(chunk.data).keyword === MIME_TYPES.excalidraw) && !(chunk.name === "iTXt" && - decodeSync(chunk.data).keyword === MIME_TYPES.excalidraw) + iTXt.decodeSync(chunk.data).keyword === MIME_TYPES.excalidraw) ); const encodedData = JSON.stringify( @@ -80,7 +80,7 @@ export const encodePngMetadata = async ({ let metadataChunk; try { if (useITXt) { - metadataChunk = encodeSync( + metadataChunk = iTXt.encodeSync( MIME_TYPES.excalidraw, encodedData, { diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index efbaa9292..6a142748e 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -56,7 +56,6 @@ declare module "png-chunk-itxt" { language?: string; translated?: string; }; - export { encodeSync, decodeSync }; } declare module "png-chunks-encode" { function encode(chunks: (TEXtChunk | ITXtChunk)[]): Uint8Array; diff --git a/packages/utils/src/export.ts b/packages/utils/src/export.ts index 347bf971d..6e122eff9 100644 --- a/packages/utils/src/export.ts +++ b/packages/utils/src/export.ts @@ -19,6 +19,11 @@ import type { NonDeleted, } from "@excalidraw/element/types"; import type { AppState, BinaryFiles } from "@excalidraw/excalidraw/types"; +import { Buffer } from "buffer"; + +window.onload = () => { + window.Buffer = Buffer; +} export { MIME_TYPES }; From a8b92950673c70822f83a56fd71c0ccf3e72a065 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Mon, 31 Mar 2025 01:22:09 +0100 Subject: [PATCH 09/10] changes withou consider zlib --- packages/excalidraw/data/image.ts | 23 ++++++++++++----------- packages/excalidraw/global.d.ts | 18 +++++++++++------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 4d72a26a4..750e52141 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -77,19 +77,20 @@ export const encodePngMetadata = async ({ }), ); - let metadataChunk; + let metadataChunk: TEXtChunk | ITXtChunk; try { if (useITXt) { - metadataChunk = iTXt.encodeSync( - MIME_TYPES.excalidraw, - encodedData, - { - compressed: true, - compressedMethod: 0, - language: "en", - translated: "" - } - ); + metadataChunk = { + name: "iTXt", + data: iTXt.encodeSync({ + keyword: MIME_TYPES.excalidraw, + text: encodedData, + compressionFlag: true, + compressionMethod: 0, + languageTag: "en", + translatedKeyword: "" + }) + }; } else { throw new Error("Fallback to tEXt"); } diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 6a142748e..52ad3b52c 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -43,13 +43,17 @@ declare module "png-chunk-text" { function decode(data: Uint8Array): { keyword: string; text: string }; } declare module "png-chunk-itxt" { - function encodeSync( - keyword: string, - text: string, - options?: { compressed?: boolean; compressedMethod: number; language?: string; translated?: string }, - ): { name: "iTXt"; data: Uint8Array }; - function decodeSync (data: Uint8Array): { - keyword: string; + function encodeSync(options: { + keyword: string; + text: string; + compressionFlag?: boolean; + compressionMethod?: number; + languageTag?: string; + translatedKeyword?: string; + }): Uint8Array; + + function decodeSync(data: Uint8Array): { + keyword: string; text: string; compressed?: boolean; compressedMethod?: number; From f585cfb72063d5e7b97179cf67ee9a0ed0afaf74 Mon Sep 17 00:00:00 2001 From: Gabriel Gomes Date: Fri, 11 Apr 2025 20:20:58 +0100 Subject: [PATCH 10/10] itxt implementation finished and working --- packages/excalidraw/data/encode.ts | 109 +++++++++++++++- packages/excalidraw/data/image.ts | 193 ++++++++++++++++++----------- 2 files changed, 226 insertions(+), 76 deletions(-) diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts index 31d7a5bc1..7851c53da 100644 --- a/packages/excalidraw/data/encode.ts +++ b/packages/excalidraw/data/encode.ts @@ -81,7 +81,7 @@ export const base64urlToString = (str: string) => { }; // ----------------------------------------------------------------------------- -// text encoding +// tEXT encoding/decoding // ----------------------------------------------------------------------------- type EncodedData = { @@ -143,6 +143,113 @@ export const decode = (data: EncodedData): string => { return decoded; }; +// ----------------------------------------------------------------------------- +// iTXt encoding/decoding +// ----------------------------------------------------------------------------- + +// Based on PNG spec: http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html +// and iTXt chunk structure: https://www.w3.org/TR/PNG/#11iTXt + +export const encodeITXtChunk = ({ + keyword, + text, + compressionFlag = true, + compressionMethod = 0, + languageTag = "", + translatedKeyword = "", +}: { + keyword: string; + text: string; + compressionFlag?: boolean; + compressionMethod?: number; + languageTag?: string; + translatedKeyword?: string; +}): Uint8Array => { + const keywordBytes = new TextEncoder().encode(keyword); + const languageTagBytes = new TextEncoder().encode(languageTag); + const translatedKeywordBytes = new TextEncoder().encode(translatedKeyword); + const textBytes = new TextEncoder().encode(text); + + const totalSize = + keywordBytes.length + + 1 + // null separator after keyword + 1 + // compression flag + 1 + // compression method + languageTagBytes.length + + 1 + // null separator after language tag + translatedKeywordBytes.length + + 1 + // null separator after translated keyword + (compressionFlag ? deflate(textBytes).length : textBytes.length); + + const output = new Uint8Array(totalSize); + let offset = 0; + + output.set(keywordBytes, offset); + offset += keywordBytes.length; + output[offset++] = 0; // null separator + + output[offset++] = compressionFlag ? 1 : 0; + + output[offset++] = compressionMethod; + + output.set(languageTagBytes, offset); + offset += languageTagBytes.length; + output[offset++] = 0; // null separator + + output.set(translatedKeywordBytes, offset); + offset += translatedKeywordBytes.length; + output[offset++] = 0; // null separator + + const finalTextBytes = compressionFlag ? deflate(textBytes) : textBytes; + output.set(finalTextBytes, offset); + + return output; +}; + +export const decodeITXtChunk = (data: Uint8Array): { + keyword: string; + text: string; + compressed: boolean; + compressedMethod: number; + language: string; + translated: string; +} => { + let offset = 0; + + const keywordEnd = data.indexOf(0, offset); + if (keywordEnd === -1) throw new Error("Invalid iTXt chunk: missing keyword"); + const keyword = new TextDecoder().decode(data.slice(offset, keywordEnd)); + offset = keywordEnd + 1; + + const compressionFlag = data[offset++] === 1; + + const compressionMethod = data[offset++]; + + const languageEnd = data.indexOf(0, offset); + if (languageEnd === -1) throw new Error("Invalid iTXt chunk: missing language tag"); + const language = new TextDecoder().decode(data.slice(offset, languageEnd)); + offset = languageEnd + 1; + + const translatedEnd = data.indexOf(0, offset); + if (translatedEnd === -1) throw new Error("Invalid iTXt chunk: missing translated keyword"); + const translated = new TextDecoder().decode(data.slice(offset, translatedEnd)); + offset = translatedEnd + 1; + + const textBytes = data.slice(offset); + const text = compressionFlag + ? new TextDecoder().decode(inflate(textBytes)) + : new TextDecoder().decode(textBytes); + + return { + keyword, + text, + compressed: compressionFlag, + compressedMethod: compressionMethod, + language, + translated, + }; +}; + // ----------------------------------------------------------------------------- // binary encoding // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/data/image.ts b/packages/excalidraw/data/image.ts index 750e52141..87c6b47d3 100644 --- a/packages/excalidraw/data/image.ts +++ b/packages/excalidraw/data/image.ts @@ -1,12 +1,15 @@ import tEXt from "png-chunk-text"; -import * as iTXt from "png-chunk-itxt"; import encodePng from "png-chunks-encode"; import decodePng from "png-chunks-extract"; import { EXPORT_DATA_TYPES, MIME_TYPES } from "@excalidraw/common"; import { blobToArrayBuffer } from "./blob"; -import { encode, decode } from "./encode"; +import { encode, decode, encodeITXtChunk, decodeITXtChunk } from "./encode"; + +type TEXtChunk = { name: "tEXt"; data: Uint8Array }; +type ITXtChunk = { name: "iTXt"; data: Uint8Array }; +type PngChunk = TEXtChunk | ITXtChunk; // ----------------------------------------------------------------------------- // PNG @@ -22,33 +25,43 @@ export const getMetadataChunk = async ( languageTag?: string; translatedKeyword?: string; } | null> => { - const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - - const iTXtChunk = chunks.find((chunk) => chunk.name === "iTXt"); - - if (iTXtChunk) { - try { - const decoded = iTXt.decodeSync(iTXtChunk.data); - console.log("Decoded iTXt chunk:", decoded); - return { - keyword: decoded.keyword, - text: decoded.text, - compressionFlag: decoded.compressed, - compressionMethod: decoded.compressedMethod, - languageTag: decoded.language || "", - translatedKeyword: decoded.translated || "" - }; - } catch (error) { - console.error("Failed to decode iTXt chunk:", error); + try { + const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))) as PngChunk[]; + + // Try iTXt chunk first (preferred format) + const iTXtChunk = chunks.find((chunk) => chunk.name === "iTXt"); + if (iTXtChunk) { + try { + const decoded = decodeITXtChunk(iTXtChunk.data); + console.debug("Decoded iTXt chunk:", decoded); + return { + keyword: decoded.keyword, + text: decoded.text, + compressionFlag: decoded.compressed, + compressionMethod: decoded.compressedMethod, + languageTag: decoded.language, + translatedKeyword: decoded.translated + }; + } catch (error) { + console.warn("Failed to decode iTXt chunk:", error); + } } + + // Fallback to tEXt chunk + const tEXtChunk = chunks.find((chunk) => chunk.name === "tEXt"); + if (tEXtChunk) { + try { + return tEXt.decode(tEXtChunk.data); + } catch (error) { + console.warn("Failed to decode tEXt chunk:", error); + } + } + + return null; + } catch (error) { + console.error("Failed to get metadata chunk:", error); + return null; } - - const tEXtChunk = chunks.find((chunk) => chunk.name === "tEXt"); - if (tEXtChunk) { - return tEXt.decode(tEXtChunk.data); - } - - return null; }; export const encodePngMetadata = async ({ @@ -60,58 +73,85 @@ export const encodePngMetadata = async ({ metadata: string; useITXt?: boolean; }) => { - const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))); - - const filteredChunks = chunks.filter( - (chunk) => - !(chunk.name === "tEXt" && - tEXt.decode(chunk.data).keyword === MIME_TYPES.excalidraw) && - !(chunk.name === "iTXt" && - iTXt.decodeSync(chunk.data).keyword === MIME_TYPES.excalidraw) - ); - - const encodedData = JSON.stringify( - encode({ - text: metadata, - compress: true, - }), - ); - - let metadataChunk: TEXtChunk | ITXtChunk; try { - if (useITXt) { - metadataChunk = { - name: "iTXt", - data: iTXt.encodeSync({ - keyword: MIME_TYPES.excalidraw, - text: encodedData, - compressionFlag: true, - compressionMethod: 0, - languageTag: "en", - translatedKeyword: "" - }) - }; - } else { - throw new Error("Fallback to tEXt"); - } - } catch (error) { - console.warn("iTXt encoding failed, falling back to tEXt:", error); - metadataChunk = tEXt.encode( - MIME_TYPES.excalidraw, - encodedData, - ); - } - - filteredChunks.splice(-1, 0, metadataChunk); + const chunks = decodePng(new Uint8Array(await blobToArrayBuffer(blob))) as PngChunk[]; - return new Blob([encodePng(filteredChunks)], { type: MIME_TYPES.png }); + const filteredChunks = chunks.filter((chunk) => { + try { + if (chunk.name === "tEXt") { + return tEXt.decode(chunk.data).keyword !== MIME_TYPES.excalidraw; + } + if (chunk.name === "iTXt") { + return decodeITXtChunk(chunk.data).keyword !== MIME_TYPES.excalidraw; + } + return true; + } catch (error) { + console.warn("Failed to decode chunk during filtering:", error); + return true; + } + }); + + const encodedData = JSON.stringify( + encode({ + text: metadata, + compress: true, + }), + ); + + let metadataChunk: PngChunk; + try { + if (useITXt) { + metadataChunk = { + name: "iTXt", + data: encodeITXtChunk({ + keyword: MIME_TYPES.excalidraw, + text: encodedData, + compressionFlag: true, + compressionMethod: 0, + languageTag: "en", + translatedKeyword: "" + }) + }; + } else { + throw new Error("Fallback to tEXt"); + } + } catch (error) { + console.warn("iTXt encoding failed, falling back to tEXt:", error); + const tEXtData = tEXt.encode( + MIME_TYPES.excalidraw, + encodedData, + ) as unknown as Uint8Array; + metadataChunk = { + name: "tEXt", + data: tEXtData + }; + } + + // Insert metadata chunk before the IEND chunk (last chunk) + filteredChunks.splice(-1, 0, metadataChunk); + + return new Blob( + [(encodePng as (chunks: PngChunk[]) => Uint8Array)(filteredChunks)], + { type: MIME_TYPES.png } + ); + } catch (error) { + console.error("Failed to encode PNG metadata:", error); + throw new Error("Failed to encode PNG metadata"); + } }; export const decodePngMetadata = async (blob: Blob) => { - const metadata = await getMetadataChunk(blob); - if (metadata?.keyword === MIME_TYPES.excalidraw) { + try { + const metadata = await getMetadataChunk(blob); + + if (!metadata?.keyword || metadata.keyword !== MIME_TYPES.excalidraw) { + throw new Error("Invalid or unsupported PNG metadata format"); + } + try { const encodedData = JSON.parse(metadata.text); + + // Handle legacy format if (!("encoded" in encodedData)) { if ( "type" in encodedData && @@ -121,11 +161,14 @@ export const decodePngMetadata = async (blob: Blob) => { } throw new Error("Malformed or unexpected metadata format"); } + return decode(encodedData); - } catch (error: any) { - console.error(error); + } catch (error) { + console.error("Failed to decode metadata:", error); throw new Error("Malformed or unexpected metadata format"); } + } catch (error) { + console.error("Failed to decode PNG metadata:", error); + throw new Error("Failed to decode PNG metadata"); } - throw new Error("Invalid or unsupported PNG metadata format"); };