From 6729e42245c3a18af5fb91535210d32931d4e2e4 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 17 Jan 2025 14:14:22 +0100 Subject: [PATCH 1/8] Implement a JS WebP encoder/decoder --- ts/WoltLabSuite/Core/Image/ExifUtil.ts | 38 +- ts/WoltLabSuite/Core/Image/Resizer.ts | 10 +- ts/WoltLabSuite/Core/Image/WebP.ts | 439 ++++++++++++++++++ .../js/WoltLabSuite/Core/Image/ExifUtil.js | 34 +- .../js/WoltLabSuite/Core/Image/Resizer.js | 12 +- .../files/js/WoltLabSuite/Core/Image/WebP.js | 307 ++++++++++++ 6 files changed, 833 insertions(+), 7 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Image/WebP.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js diff --git a/ts/WoltLabSuite/Core/Image/ExifUtil.ts b/ts/WoltLabSuite/Core/Image/ExifUtil.ts index 4a6df6e762..b2c2fecfa6 100644 --- a/ts/WoltLabSuite/Core/Image/ExifUtil.ts +++ b/ts/WoltLabSuite/Core/Image/ExifUtil.ts @@ -7,6 +7,8 @@ * @woltlabExcludeBundle tiny */ +import { parseWebPFromBuffer } from "./WebP"; + const Tag = { SOI: 0xd8, // Start of image APP0: 0xe0, // JFIF tag @@ -70,6 +72,20 @@ async function blobToUint8(blob: Blob | File): Promise { }); } +export async function getExifBytesFromWebp(blob: Blob | File): Promise { + if (!((blob as any) instanceof Blob) && !(blob instanceof File)) { + throw new TypeError("The argument must be a Blob or a File"); + } + + const webp = parseWebPFromBuffer(await blob.arrayBuffer()); + const exif = webp?.getExifData(); + if (exif === undefined) { + return new Uint8Array(0); + } + + return exif; +} + /** * Extracts the EXIF / XMP sections of a JPEG blob. */ @@ -157,8 +173,28 @@ export async function removeExifData(blob: Blob | File): Promise { return new Blob([result], { type: blob.type }); } +export async function setWebpExifData(blob: Blob, exif: Exif): Promise { + const webp = parseWebPFromBuffer(await blob.arrayBuffer()); + if (webp === undefined) { + return blob; + } + + let webpWithExif: Uint8Array; + try { + webpWithExif = webp.exportWithExif(exif); + } catch (e) { + if (window.ENABLE_DEBUG_MODE) { + console.error(e); + } + + throw e; + } + + return new Blob([webpWithExif], { type: blob.type }); +} + /** - * Overrides the APP1 (EXIF / XMP) sections of a JPEG blob with the given data. + * Overrides the APP1 (EXIF / XMP) sections of a JPEG or WebP blob with the given data. */ export async function setExifData(blob: Blob, exif: Exif): Promise { blob = await removeExifData(blob); diff --git a/ts/WoltLabSuite/Core/Image/Resizer.ts b/ts/WoltLabSuite/Core/Image/Resizer.ts index 2eb659a770..de28845abe 100644 --- a/ts/WoltLabSuite/Core/Image/Resizer.ts +++ b/ts/WoltLabSuite/Core/Image/Resizer.ts @@ -85,8 +85,12 @@ class ImageResizer { let blob = await pica.toBlob(data.image, fileType, quality); - if (fileType === "image/jpeg" && typeof data.exif !== "undefined") { - blob = await ExifUtil.setExifData(blob, data.exif); + if (typeof data.exif !== "undefined") { + if (fileType === "image/jpeg") { + blob = await ExifUtil.setExifData(blob, data.exif); + } else if (fileType === "image/webp") { + blob = await ExifUtil.setWebpExifData(blob, data.exif); + } } return FileUtil.blobToFile(blob, basename![1]); @@ -105,6 +109,8 @@ class ImageResizer { // Strip EXIF data fileData = await ExifUtil.removeExifData(fileData); + } else if (file.type === "image/webp") { + exifBytes = ExifUtil.getExifBytesFromWebp(file); } const imageLoader: Promise = new Promise((resolve, reject) => { diff --git a/ts/WoltLabSuite/Core/Image/WebP.ts b/ts/WoltLabSuite/Core/Image/WebP.ts new file mode 100644 index 0000000000..46ede94a4c --- /dev/null +++ b/ts/WoltLabSuite/Core/Image/WebP.ts @@ -0,0 +1,439 @@ +/** + * Provides helper functions to decode and encode WebP images. The exported + * image will always be VP8X for simplicity. + * + * @author Alexander Ebert + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.1 + * @woltlabExcludeBundle tiny + */ + +import type { Exif } from "./ExifUtil"; + +const enum ChunkHeader { + ALPH = "ALPH", + ANIM = "ANIM", + ANMF = "ANMF", + EXIF = "EXIF", + ICCP = "ICCP", + RIFF = "RIFF", + VP8 = "VP8 ", + VP8L = "VP8L", + VP8X = "VP8X", + WEBP = "WEBP", + XMP = "XMP ", +} + +function decodeHeader(uint32BE: number): ChunkHeader | number { + switch (uint32BE) { + case 0x414c5048: + return ChunkHeader.ALPH; + + case 0x414e494d: + return ChunkHeader.ANIM; + + case 0x414e4d46: + return ChunkHeader.ANMF; + + case 0x45584946: + return ChunkHeader.EXIF; + + case 0x49434350: + return ChunkHeader.ICCP; + + case 0x52494646: + return ChunkHeader.RIFF; + + case 0x56503820: + return ChunkHeader.VP8; + + case 0x5650384c: + return ChunkHeader.VP8L; + + case 0x56503858: + return ChunkHeader.VP8X; + + case 0x57454250: + return ChunkHeader.WEBP; + + case 0x584d5020: + return ChunkHeader.XMP; + + default: + return uint32BE; + } +} + +type Offset = number; +type ChunkSize = number; +type Chunk = [ChunkHeader | number, Offset, ChunkSize]; + +class WebP { + readonly #buffer: ArrayBuffer; + readonly #chunks: Chunk[]; + readonly #height: number; + readonly #width: number; + + constructor(buffer: ArrayBuffer, width: number, height: number, chunks: Chunk[]) { + this.#buffer = buffer; + this.#chunks = chunks; + this.#height = height; + this.#width = width; + } + + getExifData(): Exif | undefined { + for (const [chunkHeader, offset, chunkSize] of this.#chunks) { + if (chunkHeader === ChunkHeader.EXIF) { + return new Uint8Array(this.#buffer.slice(offset, offset + chunkSize)); + } + } + + return undefined; + } + + exportWithExif(exif: Exif): Uint8Array { + const iccp = this.#getChunk(ChunkHeader.ICCP); + const anim = this.#getChunk(ChunkHeader.ANIM); + + const imageData: Chunk[] = []; + let hasAlpha = false; + if (anim === undefined) { + const alpha = this.#getChunk(ChunkHeader.ALPH); + if (alpha !== undefined) { + imageData.push(alpha); + hasAlpha = true; + } + + const bitstream = this.#getChunk(ChunkHeader.VP8) || this.#getChunk(ChunkHeader.VP8L); + if (bitstream === undefined) { + throw new Error("Still image does not contain any bitstream subchunks."); + } + + imageData.push(bitstream); + } else { + imageData.push(anim); + const frames = this.#chunks.filter((chunk) => chunk[0] === ChunkHeader.ANMF); + if (frames.length === 0) { + throw new Error("Animated image contains no frames."); + } + + for (const chunk of frames) { + imageData.push(chunk); + } + } + + const xmp = this.#getChunk(ChunkHeader.XMP); + const unknownChunks = this.#chunks.filter((chunk) => typeof chunk[0] === "number"); + + // Calculate the size of the total image by summing up the chunks and the + // size of the exif data. + // The `RIFF` header as well as the length itself is not part of the length + // which is why the header is only counted as 22 bytes, igoring the 8 bytes + // at the start. + const riffHeaderLength = 22; + const chunkHeaderLength = 8; + + let length = riffHeaderLength; + if (iccp !== undefined) { + const paddingByte = iccp[2] % 2; + length += chunkHeaderLength + iccp[2] + paddingByte; + } + + length += imageData.reduce((acc, chunk) => { + const paddingByte = chunk[2] % 2; + return acc + chunkHeaderLength + chunk[2] + paddingByte; + }, 0); + + length += unknownChunks.reduce((acc, chunk) => { + const paddingByte = chunk[2] % 2; + return acc + chunkHeaderLength + chunk[2] + paddingByte; + }, 0); + + if (exif.byteLength !== 0) { + const paddingByte = exif.byteLength % 2; + length += chunkHeaderLength + exif.byteLength + paddingByte; + } + + if (xmp !== undefined) { + const paddingByte = xmp[2] % 2; + length += chunkHeaderLength + xmp[2] + paddingByte; + } + + // The 8 bytes for the `RIFF` header plus the chunk length are not part of + // `length.`. + const totalFileSize = length + 8; + const result = new Uint8Array(totalFileSize); + const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength); + dataView.setUint32(0, 0x52494646); // RIFF + dataView.setUint32(4, length, true); + dataView.setUint32(8, 0x57454250); // WEBP + dataView.setUint32(12, 0x56503858); // VP8X + dataView.setUint32(16, 10, true); + dataView.setUint8( + 20, + this.#encodeFlags(iccp !== undefined, hasAlpha, exif.byteLength > 0, false, anim !== undefined), + ); + // 3 reserved bytes (offset is now 24). + + // width - 1 as uint24LE. + this.#writeDimension(result, 24, this.width); + // height - 1 as uint24LE. + this.#writeDimension(result, 27, this.height); + + let offset = 30; + + if (iccp !== undefined) { + offset = this.#writeChunk( + result, + dataView, + offset, + iccp[0], + new Uint8Array(this.#buffer.slice(iccp[1], iccp[1] + iccp[2])), + ); + } + + for (const chunk of imageData) { + offset = this.#writeChunk( + result, + dataView, + offset, + chunk[0], + new Uint8Array(this.#buffer.slice(chunk[1], chunk[1] + chunk[2])), + ); + } + + if (exif.byteLength > 0) { + offset = this.#writeChunk(result, dataView, offset, ChunkHeader.EXIF, exif); + } + + if (xmp !== undefined) { + offset = this.#writeChunk( + result, + dataView, + offset, + xmp[0], + new Uint8Array(this.#buffer.slice(xmp[1], xmp[1] + xmp[2])), + ); + } + + for (const chunk of unknownChunks) { + offset = this.#writeChunk( + result, + dataView, + offset, + chunk[0], + new Uint8Array(this.#buffer.slice(chunk[1], chunk[1] + chunk[2])), + ); + } + + if (offset !== totalFileSize) { + throw new Error(`Encoding failed, only ${offset} of ${totalFileSize} bytes have been written.`); + } + + return result; + } + + get height(): number { + return this.#height; + } + + get width(): number { + return this.#width; + } + + #writeDimension(result: Uint8Array, offset: number, value: number): void { + const bytes = new Uint8Array(4); + const dw = new DataView(bytes.buffer, 0, 4); + + // Encode the dimension - 1 as uint32LE + dw.setUint32(0, value - 1, true); + + // Discards the 4th bit. + dw.setUint32(0, dw.getUint16(0, true) << (8 + dw.getUint16(2, true)), true); + + result.set(bytes.slice(1, 4), offset); + } + + #encodeFlags(iccProfile: boolean, alpha: boolean, exif: boolean, xmp: boolean, animation: boolean): number { + let result = 0; + + // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format + if (iccProfile) { + result = result | (1 << (8 - 3)); + } + + if (alpha) { + result = result | (1 << (8 - 4)); + } + + if (exif) { + result = result | (1 << (8 - 5)); + } + + if (xmp) { + result = result | (1 << (8 - 6)); + } + + if (animation) { + result = result | (1 << (8 - 7)); + } + + return result; + } + + #writeChunk( + result: Uint8Array, + dataView: DataView, + offset: number, + header: ChunkHeader | number, + data: Uint8Array, + ): number { + header = this.#toFourCC(header); + dataView.setUint32(offset, header); + dataView.setUint32(offset + 4, data.byteLength, true); + + result.set(data, offset + 8); + + offset = offset + 8 + data.byteLength; + + if (data.byteLength % 2 === 1) { + // "If Chunk Size is odd, a single padding byte -- which MUST be 0 to + // conform with RIFF -- is added." + dataView.setUint8(offset, 0); + offset += 1; + } + + return offset; + } + + #toFourCC(value: string | number): number { + if (typeof value === "number") { + return value; + } + + if (value.length !== 4) { + throw new Error(`Cannot decode "${value}" as FourCC`); + } + + const buffer = new Uint8Array(4); + const dataView = new DataView(buffer.buffer, 0, 4); + + for (let i = 0; i < 4; i++) { + dataView.setUint8(i, value.charCodeAt(i)); + } + + return dataView.getUint32(0); + } + + #getChunk(requestedChunk: ChunkHeader): Chunk | undefined { + for (const chunk of this.#chunks) { + if (chunk[0] === requestedChunk) { + return chunk; + } + } + + return undefined; + } +} + +function parseVp8x(buffer: ArrayBuffer, dataView: DataView): WebP { + if (dataView.byteLength <= 30) { + throw new Error("A VP8X encoded WebP must be larger than 30 bytes."); + } + + //const flags = dataView.getUint8(20); + // The first two bits are reserved. + /* + const iccProfile = ((1 << (8 - 3)) & flags) > 0; + const alpha = ((1 << (8 - 4)) & flags) > 0; + const exif = ((1 << (8 - 5)) & flags) > 0; + const xmp = ((1 << (8 - 6)) & flags) > 0; + const animation = ((1 << (8 - 7)) & flags) > 0; + */ + // The last bit is reserved. + + // The next 24 bits are reserved. (offset + 3 = 24) + + // The next 48 bits contain the width and height, represented as uint24LE, but + // using the value - 1, thus we need to add 1 to each calculated value. + const width = ((dataView.getUint8(26) << 16) | (dataView.getUint8(25) << 8) | dataView.getUint8(24)) + 1; + const height = ((dataView.getUint8(29) << 16) | (dataView.getUint8(28) << 8) | dataView.getUint8(27)) + 1; + + const chunks: Chunk[] = []; + let offset = 30; + while (offset < dataView.byteLength) { + const chunkHeader = decodeHeader(dataView.getUint32(offset)); + const chunkSize = dataView.getUint32(offset + 4, true); + offset += 8; + + chunks.push([chunkHeader, offset, chunkSize]); + + offset += chunkSize; + + if (chunkSize % 2 === 1) { + // "If Chunk Size is odd, a single padding byte -- which MUST be 0 to + // conform with RIFF -- is added." + offset += 1; + } + + if (offset > dataView.byteLength) { + const header = typeof chunkHeader === "number" ? `0x${chunkHeader.toString(16)}` : chunkHeader; + throw new Error(`Corrupted image detected, offset ${offset} > ${dataView.byteLength} for chunk ${header}.`); + } + } + + return new WebP(buffer, width, height, chunks); +} + +function getDimensions(buffer: ArrayBuffer): [number, number] { + // This is the lazy version that avoids having to implement an RFC 6386 parser + // to extract the dimensions from the VP8/VP8L frames. + const blob = new Blob([new Uint8Array(buffer)], { type: "image/webp" }); + const img = new Image(); + img.src = window.URL.createObjectURL(blob); + + return [img.naturalWidth, img.naturalHeight]; +} + +export function parseWebPFromBuffer(buffer: ArrayBuffer): WebP | undefined { + const dataView = new DataView(buffer, 0, buffer.byteLength); + if (dataView.byteLength < 20) { + // Anything below 20 bytes cannot be an WebP image. The first 12 bytes are + // the RIFF header followed by at least 8 bytes for a chunk plus its size. + return undefined; + } + + if (decodeHeader(dataView.getUint32(0)) !== ChunkHeader.RIFF) { + return undefined; + } + + // The next 4 bytes represent the total size of the file. + + if (decodeHeader(dataView.getUint32(8)) !== ChunkHeader.WEBP) { + return undefined; + } + + const firstChunk = decodeHeader(dataView.getUint32(12)); + if (typeof firstChunk === "number") { + // The first chunk must be a known value. + throw new Error(`Unrecognized chunk 0x${firstChunk.toString(16)} at the first position`); + } + + const chunkSize = dataView.getUint32(16, true); + + switch (firstChunk) { + case ChunkHeader.VP8: + case ChunkHeader.VP8L: { + const [width, height] = getDimensions(buffer); + + return new WebP(buffer, width, height, [[firstChunk, 20, chunkSize]]); + } + + case ChunkHeader.VP8X: + return parseVp8x(buffer, dataView); + + default: + throw new Error(`Unexpected chunk "${firstChunk}" at the first position`); + } +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js index e09ff1091d..5633254d8d 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js @@ -6,11 +6,13 @@ * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny */ -define(["require", "exports"], function (require, exports) { +define(["require", "exports", "./WebP"], function (require, exports, WebP_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); + exports.getExifBytesFromWebp = getExifBytesFromWebp; exports.getExifBytesFromJpeg = getExifBytesFromJpeg; exports.removeExifData = removeExifData; + exports.setWebpExifData = setWebpExifData; exports.setExifData = setExifData; const Tag = { SOI: 0xd8, // Start of image @@ -66,6 +68,17 @@ define(["require", "exports"], function (require, exports) { reader.readAsArrayBuffer(blob); }); } + async function getExifBytesFromWebp(blob) { + if (!(blob instanceof Blob) && !(blob instanceof File)) { + throw new TypeError("The argument must be a Blob or a File"); + } + const webp = (0, WebP_1.parseWebPFromBuffer)(await blob.arrayBuffer()); + const exif = webp?.getExifData(); + if (exif === undefined) { + return new Uint8Array(0); + } + return exif; + } /** * Extracts the EXIF / XMP sections of a JPEG blob. */ @@ -139,8 +152,25 @@ define(["require", "exports"], function (require, exports) { } return new Blob([result], { type: blob.type }); } + async function setWebpExifData(blob, exif) { + const webp = (0, WebP_1.parseWebPFromBuffer)(await blob.arrayBuffer()); + if (webp === undefined) { + return blob; + } + let webpWithExif; + try { + webpWithExif = webp.exportWithExif(exif); + } + catch (e) { + if (window.ENABLE_DEBUG_MODE) { + console.error(e); + } + throw e; + } + return new Blob([webpWithExif], { type: blob.type }); + } /** - * Overrides the APP1 (EXIF / XMP) sections of a JPEG blob with the given data. + * Overrides the APP1 (EXIF / XMP) sections of a JPEG or WebP blob with the given data. */ async function setExifData(blob, exif) { blob = await removeExifData(blob); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/Resizer.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/Resizer.js index f36920c1e5..71961d0a32 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/Resizer.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/Resizer.js @@ -67,8 +67,13 @@ define(["require", "exports", "tslib", "../FileUtil", "./ExifUtil", "pica"], fun async saveFile(data, fileName, fileType = this.fileType, quality = this.quality) { const basename = /(.+)(\..+?)$/.exec(fileName); let blob = await pica.toBlob(data.image, fileType, quality); - if (fileType === "image/jpeg" && typeof data.exif !== "undefined") { - blob = await ExifUtil.setExifData(blob, data.exif); + if (typeof data.exif !== "undefined") { + if (fileType === "image/jpeg") { + blob = await ExifUtil.setExifData(blob, data.exif); + } + else if (fileType === "image/webp") { + blob = await ExifUtil.setWebpExifData(blob, data.exif); + } } return FileUtil.blobToFile(blob, basename[1]); } @@ -84,6 +89,9 @@ define(["require", "exports", "tslib", "../FileUtil", "./ExifUtil", "pica"], fun // Strip EXIF data fileData = await ExifUtil.removeExifData(fileData); } + else if (file.type === "image/webp") { + exifBytes = ExifUtil.getExifBytesFromWebp(file); + } const imageLoader = new Promise((resolve, reject) => { const reader = new FileReader(); const image = new Image(); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js new file mode 100644 index 0000000000..90af55147d --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js @@ -0,0 +1,307 @@ +/** + * Provides helper functions to decode and encode WebP images. The exported + * image will always be VP8X for simplicity. + * + * @author Alexander Ebert + * @copyright 2001-2025 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.1 + * @woltlabExcludeBundle tiny + */ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.parseWebPFromBuffer = parseWebPFromBuffer; + function decodeHeader(uint32BE) { + switch (uint32BE) { + case 0x414c5048: + return "ALPH" /* ChunkHeader.ALPH */; + case 0x414e494d: + return "ANIM" /* ChunkHeader.ANIM */; + case 0x414e4d46: + return "ANMF" /* ChunkHeader.ANMF */; + case 0x45584946: + return "EXIF" /* ChunkHeader.EXIF */; + case 0x49434350: + return "ICCP" /* ChunkHeader.ICCP */; + case 0x52494646: + return "RIFF" /* ChunkHeader.RIFF */; + case 0x56503820: + return "VP8 " /* ChunkHeader.VP8 */; + case 0x5650384c: + return "VP8L" /* ChunkHeader.VP8L */; + case 0x56503858: + return "VP8X" /* ChunkHeader.VP8X */; + case 0x57454250: + return "WEBP" /* ChunkHeader.WEBP */; + case 0x584d5020: + return "XMP " /* ChunkHeader.XMP */; + default: + return uint32BE; + } + } + class WebP { + #buffer; + #chunks; + #height; + #width; + constructor(buffer, width, height, chunks) { + this.#buffer = buffer; + this.#chunks = chunks; + this.#height = height; + this.#width = width; + } + getExifData() { + for (const [chunkHeader, offset, chunkSize] of this.#chunks) { + if (chunkHeader === "EXIF" /* ChunkHeader.EXIF */) { + return new Uint8Array(this.#buffer.slice(offset, offset + chunkSize)); + } + } + return undefined; + } + exportWithExif(exif) { + const iccp = this.#getChunk("ICCP" /* ChunkHeader.ICCP */); + const anim = this.#getChunk("ANIM" /* ChunkHeader.ANIM */); + const imageData = []; + let hasAlpha = false; + if (anim === undefined) { + const alpha = this.#getChunk("ALPH" /* ChunkHeader.ALPH */); + if (alpha !== undefined) { + imageData.push(alpha); + hasAlpha = true; + } + const bitstream = this.#getChunk("VP8 " /* ChunkHeader.VP8 */) || this.#getChunk("VP8L" /* ChunkHeader.VP8L */); + if (bitstream === undefined) { + throw new Error("Still image does not contain any bitstream subchunks."); + } + imageData.push(bitstream); + } + else { + imageData.push(anim); + const frames = this.#chunks.filter((chunk) => chunk[0] === "ANMF" /* ChunkHeader.ANMF */); + if (frames.length === 0) { + throw new Error("Animated image contains no frames."); + } + for (const chunk of frames) { + imageData.push(chunk); + } + } + const xmp = this.#getChunk("XMP " /* ChunkHeader.XMP */); + const unknownChunks = this.#chunks.filter((chunk) => typeof chunk[0] === "number"); + // Calculate the size of the total image by summing up the chunks and the + // size of the exif data. + // The `RIFF` header as well as the length itself is not part of the length + // which is why the header is only counted as 22 bytes, igoring the 8 bytes + // at the start. + const riffHeaderLength = 22; + const chunkHeaderLength = 8; + let length = riffHeaderLength; + if (iccp !== undefined) { + const paddingByte = iccp[2] % 2; + length += chunkHeaderLength + iccp[2] + paddingByte; + } + length += imageData.reduce((acc, chunk) => { + const paddingByte = chunk[2] % 2; + return acc + chunkHeaderLength + chunk[2] + paddingByte; + }, 0); + length += unknownChunks.reduce((acc, chunk) => { + const paddingByte = chunk[2] % 2; + return acc + chunkHeaderLength + chunk[2] + paddingByte; + }, 0); + if (exif.byteLength !== 0) { + const paddingByte = exif.byteLength % 2; + length += chunkHeaderLength + exif.byteLength + paddingByte; + } + if (xmp !== undefined) { + const paddingByte = xmp[2] % 2; + length += chunkHeaderLength + xmp[2] + paddingByte; + } + // The 8 bytes for the `RIFF` header plus the chunk length are not part of + // `length.`. + const totalFileSize = length + 8; + const result = new Uint8Array(totalFileSize); + const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength); + dataView.setUint32(0, 0x52494646); // RIFF + dataView.setUint32(4, length, true); + dataView.setUint32(8, 0x57454250); // WEBP + dataView.setUint32(12, 0x56503858); // VP8X + dataView.setUint32(16, 10, true); + dataView.setUint8(20, this.#encodeFlags(iccp !== undefined, hasAlpha, exif.byteLength > 0, false, anim !== undefined)); + // 3 reserved bytes (offset is now 24). + // width - 1 as uint24LE. + this.#writeDimension(result, 24, this.width); + // height - 1 as uint24LE. + this.#writeDimension(result, 27, this.height); + let offset = 30; + if (iccp !== undefined) { + offset = this.#writeChunk(result, dataView, offset, iccp[0], new Uint8Array(this.#buffer.slice(iccp[1], iccp[1] + iccp[2]))); + } + for (const chunk of imageData) { + offset = this.#writeChunk(result, dataView, offset, chunk[0], new Uint8Array(this.#buffer.slice(chunk[1], chunk[1] + chunk[2]))); + } + if (exif.byteLength > 0) { + offset = this.#writeChunk(result, dataView, offset, "EXIF" /* ChunkHeader.EXIF */, exif); + } + if (xmp !== undefined) { + offset = this.#writeChunk(result, dataView, offset, xmp[0], new Uint8Array(this.#buffer.slice(xmp[1], xmp[1] + xmp[2]))); + } + for (const chunk of unknownChunks) { + offset = this.#writeChunk(result, dataView, offset, chunk[0], new Uint8Array(this.#buffer.slice(chunk[1], chunk[1] + chunk[2]))); + } + if (offset !== totalFileSize) { + throw new Error(`Encoding failed, only ${offset} of ${totalFileSize} bytes have been written.`); + } + return result; + } + get height() { + return this.#height; + } + get width() { + return this.#width; + } + #writeDimension(result, offset, value) { + const bytes = new Uint8Array(4); + const dw = new DataView(bytes.buffer, 0, 4); + // Encode the dimension - 1 as uint32LE + dw.setUint32(0, value - 1, true); + // Discards the 4th bit. + dw.setUint32(0, dw.getUint16(0, true) << (8 + dw.getUint16(2, true)), true); + result.set(bytes.slice(1, 4), offset); + } + #encodeFlags(iccProfile, alpha, exif, xmp, animation) { + let result = 0; + // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format + if (iccProfile) { + result = result | (1 << (8 - 3)); + } + if (alpha) { + result = result | (1 << (8 - 4)); + } + if (exif) { + result = result | (1 << (8 - 5)); + } + if (xmp) { + result = result | (1 << (8 - 6)); + } + if (animation) { + result = result | (1 << (8 - 7)); + } + return result; + } + #writeChunk(result, dataView, offset, header, data) { + header = this.#toFourCC(header); + dataView.setUint32(offset, header); + dataView.setUint32(offset + 4, data.byteLength, true); + result.set(data, offset + 8); + offset = offset + 8 + data.byteLength; + if (data.byteLength % 2 === 1) { + // "If Chunk Size is odd, a single padding byte -- which MUST be 0 to + // conform with RIFF -- is added." + dataView.setUint8(offset, 0); + offset += 1; + } + return offset; + } + #toFourCC(value) { + if (typeof value === "number") { + return value; + } + if (value.length !== 4) { + throw new Error(`Cannot decode "${value}" as FourCC`); + } + const buffer = new Uint8Array(4); + const dataView = new DataView(buffer.buffer, 0, 4); + for (let i = 0; i < 4; i++) { + dataView.setUint8(i, value.charCodeAt(i)); + } + return dataView.getUint32(0); + } + #getChunk(requestedChunk) { + for (const chunk of this.#chunks) { + if (chunk[0] === requestedChunk) { + return chunk; + } + } + return undefined; + } + } + function parseVp8x(buffer, dataView) { + if (dataView.byteLength <= 30) { + throw new Error("A VP8X encoded WebP must be larger than 30 bytes."); + } + //const flags = dataView.getUint8(20); + // The first two bits are reserved. + /* + const iccProfile = ((1 << (8 - 3)) & flags) > 0; + const alpha = ((1 << (8 - 4)) & flags) > 0; + const exif = ((1 << (8 - 5)) & flags) > 0; + const xmp = ((1 << (8 - 6)) & flags) > 0; + const animation = ((1 << (8 - 7)) & flags) > 0; + */ + // The last bit is reserved. + // The next 24 bits are reserved. (offset + 3 = 24) + // The next 48 bits contain the width and height, represented as uint24LE, but + // using the value - 1, thus we need to add 1 to each calculated value. + const width = ((dataView.getUint8(26) << 16) | (dataView.getUint8(25) << 8) | dataView.getUint8(24)) + 1; + const height = ((dataView.getUint8(29) << 16) | (dataView.getUint8(28) << 8) | dataView.getUint8(27)) + 1; + const chunks = []; + let offset = 30; + while (offset < dataView.byteLength) { + const chunkHeader = decodeHeader(dataView.getUint32(offset)); + const chunkSize = dataView.getUint32(offset + 4, true); + offset += 8; + chunks.push([chunkHeader, offset, chunkSize]); + offset += chunkSize; + if (chunkSize % 2 === 1) { + // "If Chunk Size is odd, a single padding byte -- which MUST be 0 to + // conform with RIFF -- is added." + offset += 1; + } + if (offset > dataView.byteLength) { + const header = typeof chunkHeader === "number" ? `0x${chunkHeader.toString(16)}` : chunkHeader; + throw new Error(`Corrupted image detected, offset ${offset} > ${dataView.byteLength} for chunk ${header}.`); + } + } + return new WebP(buffer, width, height, chunks); + } + function getDimensions(buffer) { + // This is the lazy version that avoids having to implement an RFC 6386 parser + // to extract the dimensions from the VP8/VP8L frames. + const blob = new Blob([new Uint8Array(buffer)], { type: "image/webp" }); + const img = new Image(); + img.src = window.URL.createObjectURL(blob); + return [img.naturalWidth, img.naturalHeight]; + } + function parseWebPFromBuffer(buffer) { + const dataView = new DataView(buffer, 0, buffer.byteLength); + if (dataView.byteLength < 20) { + // Anything below 20 bytes cannot be an WebP image. The first 12 bytes are + // the RIFF header followed by at least 8 bytes for a chunk plus its size. + return undefined; + } + if (decodeHeader(dataView.getUint32(0)) !== "RIFF" /* ChunkHeader.RIFF */) { + return undefined; + } + // The next 4 bytes represent the total size of the file. + if (decodeHeader(dataView.getUint32(8)) !== "WEBP" /* ChunkHeader.WEBP */) { + return undefined; + } + const firstChunk = decodeHeader(dataView.getUint32(12)); + if (typeof firstChunk === "number") { + // The first chunk must be a known value. + throw new Error(`Unrecognized chunk 0x${firstChunk.toString(16)} at the first position`); + } + const chunkSize = dataView.getUint32(16, true); + switch (firstChunk) { + case "VP8 " /* ChunkHeader.VP8 */: + case "VP8L" /* ChunkHeader.VP8L */: { + const [width, height] = getDimensions(buffer); + return new WebP(buffer, width, height, [[firstChunk, 20, chunkSize]]); + } + case "VP8X" /* ChunkHeader.VP8X */: + return parseVp8x(buffer, dataView); + default: + throw new Error(`Unexpected chunk "${firstChunk}" at the first position`); + } + } +}); From 6b9ac7187c3c6ccb8b5e2ca9f1964d066e7340c3 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 17 Jan 2025 17:25:19 +0100 Subject: [PATCH 2/8] Add a basic WebP decoder to extract EXIF --- .../lib/system/image/WebPDecoder.class.php | 102 ++++++++++++++++++ .../install/files/lib/util/ExifUtil.class.php | 20 ++-- 2 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/image/WebPDecoder.class.php diff --git a/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php b/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php new file mode 100644 index 0000000000..936a97fcdc --- /dev/null +++ b/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php @@ -0,0 +1,102 @@ + + * @since 6.1 + */ +final class WebPDecoder +{ + /** + * Decodes the EXIF data contained in a WebP VP8X container. + */ + public static function extractExifData(string $filename): array + { + // We're offloading the EXIF decoding task for `exif_read_data()` which + // cannot process WebP. + if (!\function_exists('exif_read_data')) { + return []; + } + + $data = \file_get_contents($filename); + if (\strlen($data) <= 30) { + // The RIFF header for VP8X is at least 30 bytes. + return []; + } + + // A WebP image must start with "RIFF" in ascii, followed by four bytes + // for the chunk length and then read "WEBP" at offset 8. + if (!(\substr($data, 0, 4) === "RIFF" && \substr($data, 8, 4) === "WEBP")) { + return []; + } + + // Only VP8X contains EXIF data. + if (!(\substr($data, 12, 4) === "VP8X")) { + return []; + } + + // Check if the EXIF bit is set. + $flags = \ord(\substr($data, 20, 1)); + $hasExif = ((1 << (8 - 5)) & $flags) > 0; + if (!$hasExif) { + return []; + } + + // Find the EXIF chunk. + $exifData = null; + $offset = 30; + while ($offset < \strlen($data)) { + $chunkHeader = \substr($data, $offset, 4); + // 'V' = uint32LE + $chunkSize = \unpack('V', \substr($data, $offset + 4, 4))[1]; + $offset += 8; + + if ($chunkHeader !== 'EXIF') { + // "If Chunk Size is odd, a single padding byte -- which MUST be + // 0 to conform with RIFF -- is added." + $paddingByte = $chunkSize % 2; + $offset += $chunkSize + $paddingByte; + + continue; + } + + $exifData = \substr($data, $offset, $chunkSize); + } + + if ($exifData === null) { + return []; + } + + // A tiny JPEG used as the host for the EXIF data. + // See https://github.com/mathiasbynens/small/blob/267b39f682598eebb0dafe7590b1504be79b5cad/jpeg.jpg + $jpg1x1px = \hex2bin("ffd8ffdb004300030202020202030202020303030304060404040404080606050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b080001000101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9"); + + $offset = 2; + // Check if the second tag is the JFIF tag. + if ($jpg1x1px[2] === 0xff && $jpg1x1px[3] === 0xe0) { + $offset += 2 + (($jpg1x1px[4] << 8) | $jpg1x1px[5]); + } + + // Add the markers for the EXIF sequence in JPEGs. + $exifData = "\xFF\xE1\xC3\xEF\x45\x78\x69\x66\x00\x00" . $exifData; + + $exif = \exif_read_data( + \sprintf( + "data://image/jpeg;base64,%s", + \base64_encode(\substr($jpg1x1px, 0, $offset) . $exifData . \substr($jpg1x1px, $offset)), + ), + ); + + if ($exif === false) { + return []; + } + + return $exif; + } +} diff --git a/wcfsetup/install/files/lib/util/ExifUtil.class.php b/wcfsetup/install/files/lib/util/ExifUtil.class.php index bc2d515807..b2497048a6 100644 --- a/wcfsetup/install/files/lib/util/ExifUtil.class.php +++ b/wcfsetup/install/files/lib/util/ExifUtil.class.php @@ -2,6 +2,8 @@ namespace wcf\util; +use wcf\system\image\WebPDecoder; + /** * Provides exif-related functions. * @@ -84,14 +86,20 @@ private function __construct() */ public static function getExifData($filename) { - if (\function_exists('exif_read_data')) { - $exifData = @\exif_read_data($filename, '', true); - if ($exifData !== false) { - return $exifData; - } + if (!\function_exists('exif_read_data')) { + return []; + } + + if (FileUtil::getMimeType($filename) === 'image/webp') { + return WebPDecoder::extractExifData($filename); } - return []; + $exifData = @\exif_read_data($filename, '', true); + if ($exifData === false) { + return []; + } + + return $exifData; } /** From 1021b7cc8a8a1fae15a3824a1026acdf7466de8f Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 17 Jan 2025 17:37:27 +0100 Subject: [PATCH 3/8] Fix copying of JPEG EXIF into WebP --- ts/WoltLabSuite/Core/Image/WebP.ts | 5 +++++ wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/ts/WoltLabSuite/Core/Image/WebP.ts b/ts/WoltLabSuite/Core/Image/WebP.ts index 46ede94a4c..6f23f8476b 100644 --- a/ts/WoltLabSuite/Core/Image/WebP.ts +++ b/ts/WoltLabSuite/Core/Image/WebP.ts @@ -93,6 +93,11 @@ class WebP { } exportWithExif(exif: Exif): Uint8Array { + // The EXIF might originate from a JPEG thus we need to strip the header. + if (exif[0] === 0xff && exif[1] === 0xe1 && exif[2] === 0xc3 && exif[3] === 0xef) { + exif = exif.slice(10); + } + const iccp = this.#getChunk(ChunkHeader.ICCP); const anim = this.#getChunk(ChunkHeader.ANIM); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js index 90af55147d..7d389c95f5 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js @@ -60,6 +60,10 @@ define(["require", "exports"], function (require, exports) { return undefined; } exportWithExif(exif) { + // The EXIF might originate from a JPEG thus we need to strip the header. + if (exif[0] === 0xff && exif[1] === 0xe1 && exif[2] === 0xc3 && exif[3] === 0xef) { + exif = exif.slice(10); + } const iccp = this.#getChunk("ICCP" /* ChunkHeader.ICCP */); const anim = this.#getChunk("ANIM" /* ChunkHeader.ANIM */); const imageData = []; From 8dafdb1ce5812ac3922c707acedc3c3221f85499 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Fri, 17 Jan 2025 17:43:45 +0100 Subject: [PATCH 4/8] Add support for the separate 'Orientation' field in EXIF --- wcfsetup/install/files/lib/util/ExifUtil.class.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/wcfsetup/install/files/lib/util/ExifUtil.class.php b/wcfsetup/install/files/lib/util/ExifUtil.class.php index b2497048a6..e26caf16bb 100644 --- a/wcfsetup/install/files/lib/util/ExifUtil.class.php +++ b/wcfsetup/install/files/lib/util/ExifUtil.class.php @@ -252,9 +252,12 @@ public static function getOrientation(array $exifData) $orientation = self::ORIENTATION_ORIGINAL; if (isset($exifData['IFD0']['Orientation'])) { $orientation = \intval($exifData['IFD0']['Orientation']); - if ($orientation < self::ORIENTATION_ORIGINAL || $orientation > self::ORIENTATION_270_ROTATE) { - $orientation = self::ORIENTATION_ORIGINAL; - } + } else if (isset($exifData['Orientation'])) { + $orientation = \intval($exifData['Orientation']); + } + + if ($orientation < self::ORIENTATION_ORIGINAL || $orientation > self::ORIENTATION_270_ROTATE) { + $orientation = self::ORIENTATION_ORIGINAL; } return $orientation; From 3eb3c948c249d941849b2dbd1ca76671f3901809 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 18 Jan 2025 17:47:27 +0100 Subject: [PATCH 5/8] Simplify the bit check and improve the JPG validation The previous check was ported from the JS ExifUtil and is meant as a safety check. An earlier version used a slightly different JPEG (that included a JFIF tag) but was replaced to some 30 bytes with this version. This also means that any changes to the encoded image could break the following logic thus we convert it into `assert()` to fail hard if adjusted improperly. --- .../files/lib/system/image/WebPDecoder.class.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php b/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php index 936a97fcdc..794cebfa12 100644 --- a/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php +++ b/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php @@ -43,7 +43,7 @@ public static function extractExifData(string $filename): array // Check if the EXIF bit is set. $flags = \ord(\substr($data, 20, 1)); - $hasExif = ((1 << (8 - 5)) & $flags) > 0; + $hasExif = $flags & 0b00001000; if (!$hasExif) { return []; } @@ -77,11 +77,16 @@ public static function extractExifData(string $filename): array // See https://github.com/mathiasbynens/small/blob/267b39f682598eebb0dafe7590b1504be79b5cad/jpeg.jpg $jpg1x1px = \hex2bin("ffd8ffdb004300030202020202030202020303030304060404040404080606050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b080001000101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9"); + // The image does not have a JFIF tag and instead directly starts with + // the quantization table (DQT, 0xFF xDB) after the start of image (SOI, + // 0xFF 0xD8). + // + // This means our offset is always 2. We could further optimize this by + // ommitting the SOI from the hex string but the \substr() is already + // quite cheap that it doesn't make sense to obscure the image further. + \assert($jpg1x1px[0] === "\xFF" && $jpg1x1px[1] === "\xD8"); + \assert($jpg1x1px[2] === "\xFF" && $jpg1x1px[3] === "\xDB"); $offset = 2; - // Check if the second tag is the JFIF tag. - if ($jpg1x1px[2] === 0xff && $jpg1x1px[3] === 0xe0) { - $offset += 2 + (($jpg1x1px[4] << 8) | $jpg1x1px[5]); - } // Add the markers for the EXIF sequence in JPEGs. $exifData = "\xFF\xE1\xC3\xEF\x45\x78\x69\x66\x00\x00" . $exifData; From 6f26278fed28a440aaeb7b9f775b3bc0b393364e Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 18 Jan 2025 17:55:37 +0100 Subject: [PATCH 6/8] Simplify the logic to construct the JPEG --- .../lib/system/image/WebPDecoder.class.php | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php b/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php index 794cebfa12..ed4a7ec42c 100644 --- a/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php +++ b/wcfsetup/install/files/lib/system/image/WebPDecoder.class.php @@ -75,26 +75,21 @@ public static function extractExifData(string $filename): array // A tiny JPEG used as the host for the EXIF data. // See https://github.com/mathiasbynens/small/blob/267b39f682598eebb0dafe7590b1504be79b5cad/jpeg.jpg - $jpg1x1px = \hex2bin("ffd8ffdb004300030202020202030202020303030304060404040404080606050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b080001000101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9"); + // This is a modified version without the leading 0xFF 0xD8 (SOI)! + $jpegBody = \hex2bin("ffdb004300030202020202030202020303030304060404040404080606050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b080001000101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9"); // The image does not have a JFIF tag and instead directly starts with - // the quantization table (DQT, 0xFF xDB) after the start of image (SOI, - // 0xFF 0xD8). - // - // This means our offset is always 2. We could further optimize this by - // ommitting the SOI from the hex string but the \substr() is already - // quite cheap that it doesn't make sense to obscure the image further. - \assert($jpg1x1px[0] === "\xFF" && $jpg1x1px[1] === "\xD8"); - \assert($jpg1x1px[2] === "\xFF" && $jpg1x1px[3] === "\xDB"); - $offset = 2; - - // Add the markers for the EXIF sequence in JPEGs. - $exifData = "\xFF\xE1\xC3\xEF\x45\x78\x69\x66\x00\x00" . $exifData; + // the quantization table (DQT, 0xFF xDB). The SOI (start of image, 0xFF + // 0xD8) is prepended below to simpify the construction of the image. + \assert($jpegBody[0] === "\xFF" && $jpegBody[1] === "\xDB"); + + $soiTag = "\xFF\xD8"; + $exifTag = "\xFF\xE1\xC3\xEF\x45\x78\x69\x66\x00\x00"; $exif = \exif_read_data( \sprintf( "data://image/jpeg;base64,%s", - \base64_encode(\substr($jpg1x1px, 0, $offset) . $exifData . \substr($jpg1x1px, $offset)), + \base64_encode($soiTag . $exifTag . $exifData . $jpegBody), ), ); From 14394ddb5a5c6963e7ea348cdc8f1d00d6b42ad9 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 18 Jan 2025 18:47:02 +0100 Subject: [PATCH 7/8] Use `Array.find()` instead of a helper method --- ts/WoltLabSuite/Core/Image/WebP.ts | 24 ++++++------------- .../files/js/WoltLabSuite/Core/Image/WebP.js | 22 ++++++----------- 2 files changed, 14 insertions(+), 32 deletions(-) diff --git a/ts/WoltLabSuite/Core/Image/WebP.ts b/ts/WoltLabSuite/Core/Image/WebP.ts index 6f23f8476b..3f162f6bb6 100644 --- a/ts/WoltLabSuite/Core/Image/WebP.ts +++ b/ts/WoltLabSuite/Core/Image/WebP.ts @@ -98,19 +98,19 @@ class WebP { exif = exif.slice(10); } - const iccp = this.#getChunk(ChunkHeader.ICCP); - const anim = this.#getChunk(ChunkHeader.ANIM); + const iccp = this.#chunks.find(([header]) => header === ChunkHeader.ICCP); + const anim = this.#chunks.find(([header]) => header === ChunkHeader.ANIM); const imageData: Chunk[] = []; let hasAlpha = false; if (anim === undefined) { - const alpha = this.#getChunk(ChunkHeader.ALPH); + const alpha = this.#chunks.find(([header]) => header === ChunkHeader.ALPH); if (alpha !== undefined) { imageData.push(alpha); hasAlpha = true; } - const bitstream = this.#getChunk(ChunkHeader.VP8) || this.#getChunk(ChunkHeader.VP8L); + const bitstream = this.#chunks.find(([header]) => header === ChunkHeader.VP8 || header === ChunkHeader.VP8L); if (bitstream === undefined) { throw new Error("Still image does not contain any bitstream subchunks."); } @@ -118,7 +118,7 @@ class WebP { imageData.push(bitstream); } else { imageData.push(anim); - const frames = this.#chunks.filter((chunk) => chunk[0] === ChunkHeader.ANMF); + const frames = this.#chunks.filter(([header]) => header === ChunkHeader.ANMF); if (frames.length === 0) { throw new Error("Animated image contains no frames."); } @@ -128,8 +128,8 @@ class WebP { } } - const xmp = this.#getChunk(ChunkHeader.XMP); - const unknownChunks = this.#chunks.filter((chunk) => typeof chunk[0] === "number"); + const xmp = this.#chunks.find(([header]) => header === ChunkHeader.XMP); + const unknownChunks = this.#chunks.filter(([header]) => typeof header === "number"); // Calculate the size of the total image by summing up the chunks and the // size of the exif data. @@ -330,16 +330,6 @@ class WebP { return dataView.getUint32(0); } - - #getChunk(requestedChunk: ChunkHeader): Chunk | undefined { - for (const chunk of this.#chunks) { - if (chunk[0] === requestedChunk) { - return chunk; - } - } - - return undefined; - } } function parseVp8x(buffer: ArrayBuffer, dataView: DataView): WebP { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js index 7d389c95f5..06ab9c7b2a 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js @@ -64,17 +64,17 @@ define(["require", "exports"], function (require, exports) { if (exif[0] === 0xff && exif[1] === 0xe1 && exif[2] === 0xc3 && exif[3] === 0xef) { exif = exif.slice(10); } - const iccp = this.#getChunk("ICCP" /* ChunkHeader.ICCP */); - const anim = this.#getChunk("ANIM" /* ChunkHeader.ANIM */); + const iccp = this.#chunks.find(([header]) => header === "ICCP" /* ChunkHeader.ICCP */); + const anim = this.#chunks.find(([header]) => header === "ANIM" /* ChunkHeader.ANIM */); const imageData = []; let hasAlpha = false; if (anim === undefined) { - const alpha = this.#getChunk("ALPH" /* ChunkHeader.ALPH */); + const alpha = this.#chunks.find(([header]) => header === "ALPH" /* ChunkHeader.ALPH */); if (alpha !== undefined) { imageData.push(alpha); hasAlpha = true; } - const bitstream = this.#getChunk("VP8 " /* ChunkHeader.VP8 */) || this.#getChunk("VP8L" /* ChunkHeader.VP8L */); + const bitstream = this.#chunks.find(([header]) => header === "VP8 " /* ChunkHeader.VP8 */ || header === "VP8L" /* ChunkHeader.VP8L */); if (bitstream === undefined) { throw new Error("Still image does not contain any bitstream subchunks."); } @@ -82,7 +82,7 @@ define(["require", "exports"], function (require, exports) { } else { imageData.push(anim); - const frames = this.#chunks.filter((chunk) => chunk[0] === "ANMF" /* ChunkHeader.ANMF */); + const frames = this.#chunks.filter(([header]) => header === "ANMF" /* ChunkHeader.ANMF */); if (frames.length === 0) { throw new Error("Animated image contains no frames."); } @@ -90,8 +90,8 @@ define(["require", "exports"], function (require, exports) { imageData.push(chunk); } } - const xmp = this.#getChunk("XMP " /* ChunkHeader.XMP */); - const unknownChunks = this.#chunks.filter((chunk) => typeof chunk[0] === "number"); + const xmp = this.#chunks.find(([header]) => header === "XMP " /* ChunkHeader.XMP */); + const unknownChunks = this.#chunks.filter(([header]) => typeof header === "number"); // Calculate the size of the total image by summing up the chunks and the // size of the exif data. // The `RIFF` header as well as the length itself is not part of the length @@ -220,14 +220,6 @@ define(["require", "exports"], function (require, exports) { } return dataView.getUint32(0); } - #getChunk(requestedChunk) { - for (const chunk of this.#chunks) { - if (chunk[0] === requestedChunk) { - return chunk; - } - } - return undefined; - } } function parseVp8x(buffer, dataView) { if (dataView.byteLength <= 30) { From d626a326800edb8828ef016a5f909c6b5e6f7c3f Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 18 Jan 2025 18:55:12 +0100 Subject: [PATCH 8/8] Simplify the bitmask handling --- ts/WoltLabSuite/Core/Image/ExifUtil.ts | 7 +++--- ts/WoltLabSuite/Core/Image/WebP.ts | 24 +++++++------------ .../js/WoltLabSuite/Core/Image/ExifUtil.js | 5 ++-- .../files/js/WoltLabSuite/Core/Image/WebP.js | 23 +++++++----------- 4 files changed, 22 insertions(+), 37 deletions(-) diff --git a/ts/WoltLabSuite/Core/Image/ExifUtil.ts b/ts/WoltLabSuite/Core/Image/ExifUtil.ts index b2c2fecfa6..8f8cae1634 100644 --- a/ts/WoltLabSuite/Core/Image/ExifUtil.ts +++ b/ts/WoltLabSuite/Core/Image/ExifUtil.ts @@ -179,9 +179,10 @@ export async function setWebpExifData(blob: Blob, exif: Exif): Promise { return blob; } - let webpWithExif: Uint8Array; try { - webpWithExif = webp.exportWithExif(exif); + const webpWithExif = webp.exportWithExif(exif); + + return new Blob([webpWithExif], { type: blob.type }); } catch (e) { if (window.ENABLE_DEBUG_MODE) { console.error(e); @@ -189,8 +190,6 @@ export async function setWebpExifData(blob: Blob, exif: Exif): Promise { throw e; } - - return new Blob([webpWithExif], { type: blob.type }); } /** diff --git a/ts/WoltLabSuite/Core/Image/WebP.ts b/ts/WoltLabSuite/Core/Image/WebP.ts index 3f162f6bb6..cccfcf72b5 100644 --- a/ts/WoltLabSuite/Core/Image/WebP.ts +++ b/ts/WoltLabSuite/Core/Image/WebP.ts @@ -265,23 +265,23 @@ class WebP { // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format if (iccProfile) { - result = result | (1 << (8 - 3)); + result |= 0b00100000; } if (alpha) { - result = result | (1 << (8 - 4)); + result |= 0b00010000; } if (exif) { - result = result | (1 << (8 - 5)); + result |= 0b00001000; } if (xmp) { - result = result | (1 << (8 - 6)); + result |= 0b00000100; } if (animation) { - result = result | (1 << (8 - 7)); + result |= 0b00000010; } return result; @@ -337,16 +337,10 @@ function parseVp8x(buffer: ArrayBuffer, dataView: DataView): WebP { throw new Error("A VP8X encoded WebP must be larger than 30 bytes."); } - //const flags = dataView.getUint8(20); - // The first two bits are reserved. - /* - const iccProfile = ((1 << (8 - 3)) & flags) > 0; - const alpha = ((1 << (8 - 4)) & flags) > 0; - const exif = ((1 << (8 - 5)) & flags) > 0; - const xmp = ((1 << (8 - 6)) & flags) > 0; - const animation = ((1 << (8 - 7)) & flags) > 0; - */ - // The last bit is reserved. + // If we reach this point, then we have already consumed the first 20 bytes of + // the buffer. (offset = 20) + + // The next 8 bits contain the flags. (offset + 1 = 21) // The next 24 bits are reserved. (offset + 3 = 24) diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js index 5633254d8d..10a40fadea 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/ExifUtil.js @@ -157,9 +157,9 @@ define(["require", "exports", "./WebP"], function (require, exports, WebP_1) { if (webp === undefined) { return blob; } - let webpWithExif; try { - webpWithExif = webp.exportWithExif(exif); + const webpWithExif = webp.exportWithExif(exif); + return new Blob([webpWithExif], { type: blob.type }); } catch (e) { if (window.ENABLE_DEBUG_MODE) { @@ -167,7 +167,6 @@ define(["require", "exports", "./WebP"], function (require, exports, WebP_1) { } throw e; } - return new Blob([webpWithExif], { type: blob.type }); } /** * Overrides the APP1 (EXIF / XMP) sections of a JPEG or WebP blob with the given data. diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js index 06ab9c7b2a..e4dc629d93 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Image/WebP.js @@ -176,19 +176,19 @@ define(["require", "exports"], function (require, exports) { let result = 0; // https://developers.google.com/speed/webp/docs/riff_container#extended_file_format if (iccProfile) { - result = result | (1 << (8 - 3)); + result |= 0b00100000; } if (alpha) { - result = result | (1 << (8 - 4)); + result |= 0b00010000; } if (exif) { - result = result | (1 << (8 - 5)); + result |= 0b00001000; } if (xmp) { - result = result | (1 << (8 - 6)); + result |= 0b00000100; } if (animation) { - result = result | (1 << (8 - 7)); + result |= 0b00000010; } return result; } @@ -225,16 +225,9 @@ define(["require", "exports"], function (require, exports) { if (dataView.byteLength <= 30) { throw new Error("A VP8X encoded WebP must be larger than 30 bytes."); } - //const flags = dataView.getUint8(20); - // The first two bits are reserved. - /* - const iccProfile = ((1 << (8 - 3)) & flags) > 0; - const alpha = ((1 << (8 - 4)) & flags) > 0; - const exif = ((1 << (8 - 5)) & flags) > 0; - const xmp = ((1 << (8 - 6)) & flags) > 0; - const animation = ((1 << (8 - 7)) & flags) > 0; - */ - // The last bit is reserved. + // If we reach this point, then we have already consumed the first 20 bytes of + // the buffer. (offset = 20) + // The next 8 bits contain the flags. (offset + 1 = 21) // The next 24 bits are reserved. (offset + 3 = 24) // The next 48 bits contain the width and height, represented as uint24LE, but // using the value - 1, thus we need to add 1 to each calculated value.