From dd3b433467429e8161fdbdf40966943baf43b377 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Thu, 26 Feb 2026 23:33:20 +0530 Subject: [PATCH 01/12] feat: support TIFF images in DOCX rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TIFF images in DOCX files rendered as broken icons because browsers cannot natively display image/tiff. Convert TIFF to PNG at import time using utif2, following the existing EMF/WMF → SVG conversion pattern. Closes #2064 --- packages/super-editor/package.json | 1 + packages/super-editor/src/core/DocxZipper.js | 4 +- .../wp/helpers/encode-image-node-helpers.js | 21 ++- .../helpers/encode-image-node-helpers.test.js | 2 +- .../v3/handlers/wp/helpers/tiff-converter.js | 121 ++++++++++++++++++ .../wp/helpers/tiff-converter.test.js | 73 +++++++++++ pnpm-lock.yaml | 13 ++ pnpm-workspace.yaml | 1 + 8 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 62aa5a461c..17494b9d4d 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -101,6 +101,7 @@ "remark-parse": "catalog:", "remark-stringify": "catalog:", "unified": "catalog:", + "utif2": "catalog:", "uuid": "catalog:", "vue": "catalog:", "xml-js": "catalog:" diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 5c43ae12e3..e248b23ea8 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -63,7 +63,7 @@ class DocxZipper { const fileBase64 = await zipEntry.async('base64'); let extension = this.getFileExtension(name)?.toLowerCase(); // Only build data URIs for images; keep raw base64 for other binaries (e.g., xlsx) - const imageTypes = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'emf', 'wmf', 'svg', 'webp']); + const imageTypes = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); // For unknown extensions (like .tmp), try to detect the image type from content let detectedType = null; @@ -105,7 +105,7 @@ class DocxZipper { */ async updateContentTypes(docx, media, fromJson, updatedDocs = {}) { const additionalPartNames = Object.keys(updatedDocs || {}); - const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'emf', 'wmf', 'svg', 'webp']); + const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); const newMediaTypes = Object.keys(media) .map((name) => this.getFileExtension(name)) .filter((ext) => ext && imageExts.has(ext)); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 3c03d1f974..4e95d32f78 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -8,6 +8,7 @@ import { extractCustomGeometry, } from './vector-shape-helpers'; import { convertMetafileToSvg, isMetafileExtension, setMetafileDomEnvironment } from './metafile-converter.js'; +import { convertTiffToPng, isTiffExtension, setTiffDomEnvironment } from './tiff-converter.js'; import { collectTextBoxParagraphs, preProcessTextBoxContent, @@ -405,6 +406,22 @@ export function handleImageNode(node, params, isAnchor) { } } + // Convert TIFF images to PNG for display (browsers cannot render TIFF natively) + if (!wasConverted && isTiffExtension(extension)) { + const mediaData = converter?.media?.[path]; + if (mediaData) { + if (converter?.domEnvironment) { + setTiffDomEnvironment(converter.domEnvironment); + } + const conversionResult = convertTiffToPng(mediaData, size); + if (conversionResult?.dataUri) { + finalSrc = conversionResult.dataUri; + finalExtension = conversionResult.format || 'png'; + wasConverted = true; + } + } + } + // For converted metafile images (EMF+/WMF+ placeholders), we want them to render // as block-level images, not inline. We use the original wrap type if available, // otherwise default to the original wrap settings. @@ -416,8 +433,8 @@ export function handleImageNode(node, params, isAnchor) { // originalXml: carbonCopy(node), src: finalSrc, alt: - isMetafileExtension(extension) && !wasConverted - ? 'Unable to render EMF/WMF image' + (isMetafileExtension(extension) || isTiffExtension(extension)) && !wasConverted + ? 'Unable to render image' : docPr?.attributes?.name || 'Image', extension: finalExtension, // Store original path and extension for potential round-tripping diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 8abe320d3d..03d0a50a00 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -292,7 +292,7 @@ describe('handleImageNode', () => { const node = makeNode(); const params = makeParams('media/pic.emf'); const result = handleImageNode(node, params, false); - expect(result.attrs.alt).toBe('Unable to render EMF/WMF image'); + expect(result.attrs.alt).toBe('Unable to render image'); expect(result.attrs.extension).toBe('emf'); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js new file mode 100644 index 0000000000..58d79b60cf --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -0,0 +1,121 @@ +/** + * TIFF to PNG Converter + * + * Converts TIFF images to PNG format using utif2 for decoding and Canvas for + * encoding. Browsers cannot natively render TIFF images, so this converts them + * at import time to a browser-friendly format. + * + * @module tiff-converter + */ + +import * as UTIF from 'utif2'; +import { base64ToUint8Array } from '../../../../helpers.js'; + +// Optional DOM environment provided by callers (e.g., JSDOM in Node) +let domEnvironment = null; + +/** + * Configure a DOM environment that can be used when running in Node. + * + * @param {{ mockWindow?: Window|null, window?: Window|null, mockDocument?: Document|null, document?: Document|null }|null} env + */ +export const setTiffDomEnvironment = (env) => { + domEnvironment = env || null; +}; + +/** + * Checks if a file extension is a TIFF format. + * + * @param {string} extension - File extension to check + * @returns {boolean} True if the extension is 'tiff' or 'tif' + */ +export function isTiffExtension(extension) { + const ext = extension?.toLowerCase(); + return ext === 'tiff' || ext === 'tif'; +} + +/** + * Get a canvas element, trying the global document first, then the domEnvironment. + * + * @returns {HTMLCanvasElement|null} + */ +function createCanvas() { + if (typeof document !== 'undefined') { + return document.createElement('canvas'); + } + + const env = domEnvironment || {}; + const doc = env.document || env.mockDocument || env.window?.document || env.mockWindow?.document || null; + if (doc) { + return doc.createElement('canvas'); + } + + return null; +} + +/** + * Converts a TIFF image to a PNG data URI. + * + * @param {string} data - Base64 encoded data or data URI of the TIFF file + * @returns {{ dataUri: string, format: string }|null} Data URI plus format, or null if conversion fails + */ +export function convertTiffToPng(data) { + try { + // Parse input — accept data URI or raw base64 + let bytes; + if (typeof data === 'string') { + let base64 = data; + if (data.startsWith('data:')) { + const commaIndex = data.indexOf(','); + if (commaIndex === -1) return null; + base64 = data.substring(commaIndex + 1); + } + bytes = base64ToUint8Array(base64); + } else if (data instanceof Uint8Array) { + bytes = data; + } else if (ArrayBuffer.isView(data)) { + bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } else { + return null; + } + + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + + // Decode TIFF — get Image File Directories (pages) + const ifds = UTIF.decode(buffer); + if (!ifds || ifds.length === 0) return null; + + // Decode pixel data for the first page + UTIF.decodeImage(buffer, ifds[0]); + const rgba = UTIF.toRGBA8(ifds[0]); + if (!rgba || rgba.length === 0) return null; + + const { width, height } = ifds[0]; + if (!width || !height) return null; + + // Render to canvas and export as PNG + const canvas = createCanvas(); + if (!canvas) { + console.warn('TIFF conversion requires a DOM environment with canvas support'); + return null; + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const imageData = ctx.createImageData(width, height); + imageData.data.set(new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength)); + ctx.putImageData(imageData, 0, 0); + + const dataUri = canvas.toDataURL('image/png'); + if (!dataUri || dataUri === 'data:,') return null; + + return { dataUri, format: 'png' }; + } catch (error) { + console.warn('Failed to convert TIFF to PNG:', error.message); + return null; + } +} diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js new file mode 100644 index 0000000000..7ccb7b80b0 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { isTiffExtension, convertTiffToPng, setTiffDomEnvironment } from './tiff-converter.js'; + +describe('tiff-converter', () => { + describe('isTiffExtension', () => { + it('returns true for tiff extension', () => { + expect(isTiffExtension('tiff')).toBe(true); + expect(isTiffExtension('TIFF')).toBe(true); + expect(isTiffExtension('Tiff')).toBe(true); + }); + + it('returns true for tif extension', () => { + expect(isTiffExtension('tif')).toBe(true); + expect(isTiffExtension('TIF')).toBe(true); + expect(isTiffExtension('Tif')).toBe(true); + }); + + it('returns false for other extensions', () => { + expect(isTiffExtension('png')).toBe(false); + expect(isTiffExtension('jpg')).toBe(false); + expect(isTiffExtension('jpeg')).toBe(false); + expect(isTiffExtension('gif')).toBe(false); + expect(isTiffExtension('svg')).toBe(false); + expect(isTiffExtension('emf')).toBe(false); + expect(isTiffExtension('wmf')).toBe(false); + expect(isTiffExtension('')).toBe(false); + expect(isTiffExtension(null)).toBe(false); + expect(isTiffExtension(undefined)).toBe(false); + }); + }); + + describe('convertTiffToPng', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null for invalid data', () => { + const result = convertTiffToPng('not-valid-base64!!!'); + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = convertTiffToPng(''); + expect(result).toBeNull(); + }); + + it('returns null for null input', () => { + const result = convertTiffToPng(null); + expect(result).toBeNull(); + }); + + it('returns null for undefined input', () => { + const result = convertTiffToPng(undefined); + expect(result).toBeNull(); + }); + + it('returns null for non-TIFF base64 data', () => { + // A valid base64 string that isn't TIFF data + const result = convertTiffToPng('data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='); + expect(result).toBeNull(); + }); + }); + + describe('setTiffDomEnvironment', () => { + it('accepts an environment object without error', () => { + expect(() => setTiffDomEnvironment({ window: {}, document: {} })).not.toThrow(); + }); + + it('accepts null to clear environment', () => { + expect(() => setTiffDomEnvironment(null)).not.toThrow(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2981d4390..4568f7fcf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ catalogs: unified: specifier: 11.0.5 version: 11.0.5 + utif2: + specifier: ^4.1.0 + version: 4.1.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -1123,6 +1126,9 @@ importers: unified: specifier: 'catalog:' version: 11.0.5 + utif2: + specifier: 'catalog:' + version: 4.1.0 uuid: specifier: 'catalog:' version: 9.0.1 @@ -10848,6 +10854,9 @@ packages: '@types/react': optional: true + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -23420,6 +23429,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.11 + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} util@0.12.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0381260adc..f552aa2169 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -105,6 +105,7 @@ catalog: typescript: ^5.9.2 typescript-eslint: ^8.49.0 unified: 11.0.5 + utif2: ^4.1.0 uuid: ^9.0.1 verdaccio: ^6.1.6 vite: ^7.2.7 From ce6cf192e53a54a295c070753c24561f6829403f Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 00:20:41 +0530 Subject: [PATCH 02/12] fix: enforce pixel limit before TIFF decode to prevent DoS Reject TIFF images exceeding 100M pixels before allocating RGBA buffers or canvas, preventing a malicious TIFF with extreme dimensions from freezing or crashing the tab during import. --- .../v3/handlers/wp/helpers/tiff-converter.js | 7 ++++++- .../handlers/wp/helpers/tiff-converter.test.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 58d79b60cf..a8561ba1d4 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -14,6 +14,11 @@ import { base64ToUint8Array } from '../../../../helpers.js'; // Optional DOM environment provided by callers (e.g., JSDOM in Node) let domEnvironment = null; +// Safety limit: reject TIFF images whose decoded RGBA buffer would exceed this +// pixel count. 100 million pixels ≈ 400 MB of RGBA data — well above any +// realistic document image while still preventing DoS from malicious dimensions. +const MAX_PIXEL_COUNT = 100_000_000; + /** * Configure a DOM environment that can be used when running in Node. * @@ -91,7 +96,7 @@ export function convertTiffToPng(data) { if (!rgba || rgba.length === 0) return null; const { width, height } = ifds[0]; - if (!width || !height) return null; + if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; // Render to canvas and export as PNG const canvas = createCanvas(); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index 7ccb7b80b0..67ad70239e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -59,6 +59,22 @@ describe('tiff-converter', () => { const result = convertTiffToPng('data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='); expect(result).toBeNull(); }); + + it('returns null for TIFF with dimensions exceeding pixel limit', () => { + // Mock utif2 to return oversized dimensions (100k × 10k = 1 billion pixels) + vi.doMock('utif2', () => ({ + decode: () => [{ width: 100_000, height: 10_000 }], + decodeImage: () => undefined, + toRGBA8: () => new Uint8Array(4), + })); + + // Re-import to pick up the mock + return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { + const result = fn(new Uint8Array([0x49, 0x49, 0x2a, 0x00])); + expect(result).toBeNull(); + vi.doUnmock('utif2'); + }); + }); }); describe('setTiffDomEnvironment', () => { From d8e7c3b69f6453ba69a990025531d03c0262b9a1 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 00:31:50 +0530 Subject: [PATCH 03/12] fix: validate TIFF dimensions before decoding and correct .tif MIME type Move MAX_PIXEL_COUNT check before UTIF.decodeImage/toRGBA8 so oversized TIFFs are rejected before allocating the RGBA buffer. Map .tif extension to image/tiff in Content_Types.xml generation to avoid emitting the invalid MIME type image/tif. --- packages/super-editor/src/core/DocxZipper.js | 4 +++- .../v3/handlers/wp/helpers/tiff-converter.js | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index e248b23ea8..5f737e7e71 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -106,6 +106,7 @@ class DocxZipper { async updateContentTypes(docx, media, fromJson, updatedDocs = {}) { const additionalPartNames = Object.keys(updatedDocs || {}); const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); + const mimeTypeForExt = { tif: 'tiff' }; const newMediaTypes = Object.keys(media) .map((name) => this.getFileExtension(name)) .filter((ext) => ext && imageExts.has(ext)); @@ -131,7 +132,8 @@ class DocxZipper { if (defaultMediaTypes.includes(type)) continue; if (seenTypes.has(type)) continue; - const newContentType = ``; + const mime = mimeTypeForExt[type] || type; + const newContentType = ``; typesString += newContentType; seenTypes.add(type); } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index a8561ba1d4..2de237139b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -90,14 +90,15 @@ export function convertTiffToPng(data) { const ifds = UTIF.decode(buffer); if (!ifds || ifds.length === 0) return null; + // Validate dimensions from IFD metadata before decoding pixel data + const { width, height } = ifds[0]; + if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; + // Decode pixel data for the first page UTIF.decodeImage(buffer, ifds[0]); const rgba = UTIF.toRGBA8(ifds[0]); if (!rgba || rgba.length === 0) return null; - const { width, height } = ifds[0]; - if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; - // Render to canvas and export as PNG const canvas = createCanvas(); if (!canvas) { From 3c9e6bc8177b82a81b5da8e2d197943202425297 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 00:49:54 +0530 Subject: [PATCH 04/12] fix: read TIFF dimensions from raw IFD tags for pre-decode validation UTIF.decode populates raw tag entries (t256/t257) but .width/.height are only set after decodeImage. Read from raw tags so the pixel limit guard works before the expensive decode step without rejecting valid files. --- .../v3/handlers/wp/helpers/tiff-converter.js | 11 ++++++++--- .../v3/handlers/wp/helpers/tiff-converter.test.js | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 2de237139b..f7d80aafe4 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -90,15 +90,20 @@ export function convertTiffToPng(data) { const ifds = UTIF.decode(buffer); if (!ifds || ifds.length === 0) return null; - // Validate dimensions from IFD metadata before decoding pixel data - const { width, height } = ifds[0]; - if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; + // Validate dimensions from raw IFD tags before decoding pixel data. + // UTIF.decode populates tag entries (t256=ImageWidth, t257=ImageLength) + // but .width/.height are only set after decodeImage. + const ifdWidth = ifds[0].t256?.[0]; + const ifdHeight = ifds[0].t257?.[0]; + if (!ifdWidth || !ifdHeight || ifdWidth * ifdHeight > MAX_PIXEL_COUNT) return null; // Decode pixel data for the first page UTIF.decodeImage(buffer, ifds[0]); const rgba = UTIF.toRGBA8(ifds[0]); if (!rgba || rgba.length === 0) return null; + const { width, height } = ifds[0]; + // Render to canvas and export as PNG const canvas = createCanvas(); if (!canvas) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index 67ad70239e..fc38a59cfe 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -61,9 +61,10 @@ describe('tiff-converter', () => { }); it('returns null for TIFF with dimensions exceeding pixel limit', () => { - // Mock utif2 to return oversized dimensions (100k × 10k = 1 billion pixels) + // Mock utif2 to return oversized dimensions via raw IFD tags + // (t256=ImageWidth, t257=ImageLength — 100k × 10k = 1 billion pixels) vi.doMock('utif2', () => ({ - decode: () => [{ width: 100_000, height: 10_000 }], + decode: () => [{ t256: [100_000], t257: [10_000] }], decodeImage: () => undefined, toRGBA8: () => new Uint8Array(4), })); From 7e2bec6b795a46ae66424697d73294b68ef6fdf1 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 19:16:55 +0530 Subject: [PATCH 05/12] fix: address PR review feedback for TIFF support - Use mimeTypeForExt mapping for .tif data URIs (image/tiff not image/tif) - Remove unused size arg from convertTiffToPng call - Add happy-path test asserting valid TIFF produces PNG data URI --- packages/super-editor/src/core/DocxZipper.js | 4 ++- .../wp/helpers/encode-image-node-helpers.js | 2 +- .../wp/helpers/tiff-converter.test.js | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 5f737e7e71..e99262917d 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -64,6 +64,7 @@ class DocxZipper { let extension = this.getFileExtension(name)?.toLowerCase(); // Only build data URIs for images; keep raw base64 for other binaries (e.g., xlsx) const imageTypes = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); + const mimeTypeForExt = { tif: 'tiff' }; // For unknown extensions (like .tmp), try to detect the image type from content let detectedType = null; @@ -75,7 +76,8 @@ class DocxZipper { } if (imageTypes.has(extension)) { - this.mediaFiles[name] = `data:image/${extension};base64,${fileBase64}`; + const mimeSubtype = mimeTypeForExt[extension] || extension; + this.mediaFiles[name] = `data:image/${mimeSubtype};base64,${fileBase64}`; const blob = await zipEntry.async('blob'); const fileObj = new File([blob], name, { type: blob.type }); const imageUrl = URL.createObjectURL(fileObj); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 4e95d32f78..de4803de08 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -413,7 +413,7 @@ export function handleImageNode(node, params, isAnchor) { if (converter?.domEnvironment) { setTiffDomEnvironment(converter.domEnvironment); } - const conversionResult = convertTiffToPng(mediaData, size); + const conversionResult = convertTiffToPng(mediaData); if (conversionResult?.dataUri) { finalSrc = conversionResult.dataUri; finalExtension = conversionResult.format || 'png'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index fc38a59cfe..4a08e6f654 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -60,6 +60,37 @@ describe('tiff-converter', () => { expect(result).toBeNull(); }); + it('returns a PNG data URI for valid TIFF input', () => { + const fakeRgba = new Uint8Array(2 * 2 * 4); // 2x2 image, RGBA + vi.doMock('utif2', () => ({ + decode: () => [{ t256: [2], t257: [2] }], + decodeImage: (_buf, ifd) => { + ifd.width = 2; + ifd.height = 2; + }, + toRGBA8: () => fakeRgba, + })); + + const mockCanvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), + putImageData: () => {}, + }), + toDataURL: () => 'data:image/png;base64,iVBORw0KGgo=', + }; + const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); + + return import('./tiff-converter.js?happy').then(({ convertTiffToPng: fn }) => { + const result = fn(new Uint8Array([0x49, 0x49, 0x2a, 0x00])); + expect(result).toEqual({ dataUri: 'data:image/png;base64,iVBORw0KGgo=', format: 'png' }); + + spy.mockRestore(); + vi.doUnmock('utif2'); + }); + }); + it('returns null for TIFF with dimensions exceeding pixel limit', () => { // Mock utif2 to return oversized dimensions via raw IFD tags // (t256=ImageWidth, t257=ImageLength — 100k × 10k = 1 billion pixels) From 28b3a8582a0bc508f2dbd8aa829b7fe199c7d6e6 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Fri, 27 Feb 2026 23:53:59 +0530 Subject: [PATCH 06/12] fix: clean up convertTiffToPng signature and add wiring test - Remove unused Uint8Array/ArrayBufferView branches (only strings are passed) - Add handleImageNode test verifying convertTiffToPng is called for .tif files --- .../helpers/encode-image-node-helpers.test.js | 23 +++++++++++++++++++ .../v3/handlers/wp/helpers/tiff-converter.js | 23 +++++++------------ .../wp/helpers/tiff-converter.test.js | 5 ++-- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 03d0a50a00..2e60a6b0bc 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { handleImageNode, getVectorShape } from './encode-image-node-helpers.js'; import { emuToPixels, polygonToObj, rotToDegrees } from '@converter/helpers.js'; import { extractFillColor, extractStrokeColor, extractStrokeWidth, extractLineEnds } from './vector-shape-helpers.js'; +import { convertTiffToPng } from './tiff-converter.js'; vi.mock('@converter/helpers.js', async (importOriginal) => { const actual = await importOriginal(); @@ -21,6 +22,14 @@ vi.mock('./vector-shape-helpers.js', () => ({ extractCustomGeometry: vi.fn(), })); +vi.mock('./tiff-converter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + convertTiffToPng: vi.fn(actual.convertTiffToPng), + }; +}); + describe('handleImageNode', () => { beforeEach(() => { vi.clearAllMocks(); @@ -221,6 +230,20 @@ describe('handleImageNode', () => { expect(result.attrs.size).toEqual({ width: 5, height: 6 }); // emuToPixels mocked }); + it('calls convertTiffToPng for .tif images', () => { + convertTiffToPng.mockReturnValue({ dataUri: 'data:image/png;base64,fake', format: 'png' }); + const node = makeNode(); + const params = { + ...makeParams('media/photo.tif'), + converter: { media: { 'word/media/photo.tif': 'data:image/tiff;base64,AAAA' } }, + }; + const result = handleImageNode(node, params, false); + + expect(convertTiffToPng).toHaveBeenCalledWith('data:image/tiff;base64,AAAA'); + expect(result.attrs.src).toBe('data:image/png;base64,fake'); + expect(result.attrs.extension).toBe('png'); + }); + it('captures unhandled drawing children for passthrough preservation', () => { const node = makeNode(); node.elements.push({ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index f7d80aafe4..2fc148bbb3 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -66,23 +66,16 @@ function createCanvas() { */ export function convertTiffToPng(data) { try { + if (typeof data !== 'string') return null; + // Parse input — accept data URI or raw base64 - let bytes; - if (typeof data === 'string') { - let base64 = data; - if (data.startsWith('data:')) { - const commaIndex = data.indexOf(','); - if (commaIndex === -1) return null; - base64 = data.substring(commaIndex + 1); - } - bytes = base64ToUint8Array(base64); - } else if (data instanceof Uint8Array) { - bytes = data; - } else if (ArrayBuffer.isView(data)) { - bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } else { - return null; + let base64 = data; + if (data.startsWith('data:')) { + const commaIndex = data.indexOf(','); + if (commaIndex === -1) return null; + base64 = data.substring(commaIndex + 1); } + const bytes = base64ToUint8Array(base64); const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index 4a08e6f654..bdbbebb925 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -83,7 +83,8 @@ describe('tiff-converter', () => { const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); return import('./tiff-converter.js?happy').then(({ convertTiffToPng: fn }) => { - const result = fn(new Uint8Array([0x49, 0x49, 0x2a, 0x00])); + // Pass base64-encoded string (the only input type the call site uses) + const result = fn('SU8qAA=='); expect(result).toEqual({ dataUri: 'data:image/png;base64,iVBORw0KGgo=', format: 'png' }); spy.mockRestore(); @@ -102,7 +103,7 @@ describe('tiff-converter', () => { // Re-import to pick up the mock return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { - const result = fn(new Uint8Array([0x49, 0x49, 0x2a, 0x00])); + const result = fn('SU8qAA=='); expect(result).toBeNull(); vi.doUnmock('utif2'); }); From 910f68d1160fea4cd6d009b6e2d49bf867930de1 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 28 Feb 2026 22:03:42 +0530 Subject: [PATCH 07/12] test: add integration test for TIFF image loading pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Playwright test that loads a minimal DOCX containing a TIFF image and verifies the full pipeline: DocxZipper → convertTiffToPng → rendered PNG. --- .../tests/importing/fixtures/tiff-image.docx | Bin 0 -> 3913 bytes .../importing/load-doc-with-tiff.spec.ts | 28 ++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/behavior/tests/importing/fixtures/tiff-image.docx create mode 100644 tests/behavior/tests/importing/load-doc-with-tiff.spec.ts diff --git a/tests/behavior/tests/importing/fixtures/tiff-image.docx b/tests/behavior/tests/importing/fixtures/tiff-image.docx new file mode 100644 index 0000000000000000000000000000000000000000..9e0b78ceff4fdae2f6935628119a3a9e604b0137 GIT binary patch literal 3913 zcmd@XO>Y}Tbe%L+8bG3I3lbnPTJ=;EuTxd1UD>YGnp9S*VnykRqH3}`wnunBG&8Z2 zGincrJBS1P0)7JG-*DgnKOwv~v$Nh=)=m_Tj5ND%XWsXF^XBd7VDs9JhvUt+uU?G~ z?*9B&%kyyFg0-)nW~pMSdVO@3Gx>G5`vqnWvnGtd|V^t37QD&<3iw<;8?+9Mn+Vq7c>DAf<-3c zAkLzb1R{YUs4=;~#%w(15i9ADTQ0JQNeK~4X6VuWohQZPI=o+_ z#{(CKeEjy$Hlz>EcS;=eu)K2W99F|~#T0TvY*MJomJH*bFNX1+PfX@qoT0|CgCZ~{ zQ5`9PivgUem@w7z{~b|Cw{B3?N3L=G7PwWja6MlHM!(SW8l@>j&;I`I_qz}toS(Re zWimjT{Xb)-kK{5`35z)m^x|F{5AcPNx~G1)|0|phoHwm^tA4%mF<69_jX4A1;xdJ_ ze&9`_;$iopjxGm8Je8ri3`;k!8#2F%^CG_t>sZi*u1kiitNg%hHgTs{V7|%+IghTu z4XD(KV5YPz!s9GH!#f17szsO^NW;Ry`b+hXhWqJzi zTxJszfITBYI22Z>s;R!rw6R0H^~{A=A)w(z(0s}xS2D;hcLr2Zaw7PDjLuT1b`1nB zgjp9`?xph)2Sb|nK{=$3Ud-!;x)sBfXue8HWM1=Lfe|3{HR3gk;~CHQ`E0gAKN=ze z5g|*CnZ4s0#c5AVC76n)eQ^31m>#l71^sS7L%ZSBjH;gN^w5gRe5Dgu2avQrF%hbNYhK^s3k-5R?7#Lxsd7^%MyYBv5ri_&C6 zSI4p0e6!b(eyCJ&ATaG+zLm}D1{4T;%$LhpHEI>1sixqXsacw!=xWwaNe<(VErx~4)1x}unGSktxXS_5pM%#s{(HTya{V>VQv=dhjat{crX{BV7@@VU@>plIYXO0nYjT^WuIcqeUCJH0 Wgv)|HI@r=dyy4x@@w@#V68!^-&;jxQ literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/importing/load-doc-with-tiff.spec.ts b/tests/behavior/tests/importing/load-doc-with-tiff.spec.ts new file mode 100644 index 0000000000..105152368a --- /dev/null +++ b/tests/behavior/tests/importing/load-doc-with-tiff.spec.ts @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady, getDocumentText } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/tiff-image.docx'); + +test.use({ config: { toolbar: 'full', comments: 'off' } }); + +test('loads DOCX with TIFF image and renders it as PNG', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + await assertDocumentApiReady(superdoc.page); + + // Document text is present + const text = await getDocumentText(superdoc.page); + expect(text).toContain('TIFF test document'); + + // Editor is functional — pages and lines rendered + await expect(superdoc.page.locator('.superdoc-page').first()).toBeVisible(); + await expect(superdoc.page.locator('.superdoc-line').first()).toBeVisible(); + + // The TIFF was converted to PNG — the rendered should have a PNG data URI + const imgSrc = await superdoc.page.locator('img').first().getAttribute('src'); + expect(imgSrc).toBeTruthy(); + expect(imgSrc).toContain('data:image/png'); +}); From 7daae45b1fb8b1144f01bef7da4fab4fa7e830ca Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sun, 1 Mar 2026 18:23:30 +0530 Subject: [PATCH 08/12] refactor: replace utif2 with image-js/tiff and deduplicate DocxZipper constants Replace unmaintained utif2 with actively maintained image-js/tiff for TIFF decoding. Extract duplicated IMAGE_EXTS and MIME_TYPE_FOR_EXT mappings in DocxZipper.js to module-level constants. --- packages/super-editor/package.json | 2 +- packages/super-editor/src/core/DocxZipper.js | 21 ++-- .../v3/handlers/wp/helpers/tiff-converter.js | 79 +++++++++++--- .../wp/helpers/tiff-converter.test.js | 24 ++--- pnpm-lock.yaml | 102 +++++------------- pnpm-workspace.yaml | 2 +- 6 files changed, 107 insertions(+), 123 deletions(-) diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 17494b9d4d..96f2f4274a 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -101,7 +101,7 @@ "remark-parse": "catalog:", "remark-stringify": "catalog:", "unified": "catalog:", - "utif2": "catalog:", + "tiff": "catalog:", "uuid": "catalog:", "vue": "catalog:", "xml-js": "catalog:" diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index e99262917d..9f22e133dd 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -5,6 +5,12 @@ import { ensureXmlString, isXmlLike } from './encoding-helpers.js'; import { DOCX } from '@superdoc/common'; import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js'; +/** Image file extensions recognized during import and export. */ +const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); + +/** Map file extensions to correct MIME sub-types where they differ. */ +const MIME_TYPE_FOR_EXT = { tif: 'tiff' }; + /** * Class to handle unzipping and zipping of docx files */ @@ -63,20 +69,17 @@ class DocxZipper { const fileBase64 = await zipEntry.async('base64'); let extension = this.getFileExtension(name)?.toLowerCase(); // Only build data URIs for images; keep raw base64 for other binaries (e.g., xlsx) - const imageTypes = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); - const mimeTypeForExt = { tif: 'tiff' }; - // For unknown extensions (like .tmp), try to detect the image type from content let detectedType = null; - if (!imageTypes.has(extension) || extension === 'tmp') { + if (!IMAGE_EXTS.has(extension) || extension === 'tmp') { detectedType = detectImageType(fileBase64); if (detectedType) { extension = detectedType; } } - if (imageTypes.has(extension)) { - const mimeSubtype = mimeTypeForExt[extension] || extension; + if (IMAGE_EXTS.has(extension)) { + const mimeSubtype = MIME_TYPE_FOR_EXT[extension] || extension; this.mediaFiles[name] = `data:image/${mimeSubtype};base64,${fileBase64}`; const blob = await zipEntry.async('blob'); const fileObj = new File([blob], name, { type: blob.type }); @@ -107,11 +110,9 @@ class DocxZipper { */ async updateContentTypes(docx, media, fromJson, updatedDocs = {}) { const additionalPartNames = Object.keys(updatedDocs || {}); - const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); - const mimeTypeForExt = { tif: 'tiff' }; const newMediaTypes = Object.keys(media) .map((name) => this.getFileExtension(name)) - .filter((ext) => ext && imageExts.has(ext)); + .filter((ext) => ext && IMAGE_EXTS.has(ext)); const contentTypesPath = '[Content_Types].xml'; let contentTypesXml; @@ -134,7 +135,7 @@ class DocxZipper { if (defaultMediaTypes.includes(type)) continue; if (seenTypes.has(type)) continue; - const mime = mimeTypeForExt[type] || type; + const mime = MIME_TYPE_FOR_EXT[type] || type; const newContentType = ``; typesString += newContentType; seenTypes.add(type); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 2fc148bbb3..19d43990f3 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -1,14 +1,14 @@ /** * TIFF to PNG Converter * - * Converts TIFF images to PNG format using utif2 for decoding and Canvas for - * encoding. Browsers cannot natively render TIFF images, so this converts them - * at import time to a browser-friendly format. + * Converts TIFF images to PNG format using the `tiff` package (image-js/tiff) + * for decoding and Canvas for encoding. Browsers cannot natively render TIFF + * images, so this converts them at import time to a browser-friendly format. * * @module tiff-converter */ -import * as UTIF from 'utif2'; +import { decode } from 'tiff'; import { base64ToUint8Array } from '../../../../helpers.js'; // Optional DOM environment provided by callers (e.g., JSDOM in Node) @@ -58,6 +58,54 @@ function createCanvas() { return null; } +/** + * Convert decoded pixel data to RGBA format. + * The `tiff` package returns pixel data whose channel count depends on the + * image (greyscale=1, grey+alpha=2, RGB=3, RGBA=4). Canvas requires RGBA. + * + * @param {Uint8Array} data - Decoded pixel data + * @param {number} samplesPerPixel - Number of channels per pixel + * @param {boolean} hasAlpha - Whether the image has an alpha channel + * @param {number} pixelCount - Total number of pixels (width × height) + * @returns {Uint8Array} RGBA pixel data + */ +function toRGBA(data, samplesPerPixel, hasAlpha, pixelCount) { + if (samplesPerPixel === 4 && hasAlpha) return data; + + const rgba = new Uint8Array(pixelCount * 4); + + if (samplesPerPixel === 3) { + // RGB → RGBA + for (let i = 0; i < pixelCount; i++) { + rgba[i * 4] = data[i * 3]; + rgba[i * 4 + 1] = data[i * 3 + 1]; + rgba[i * 4 + 2] = data[i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + } else if (samplesPerPixel === 2 && hasAlpha) { + // Grey + Alpha → RGBA + for (let i = 0; i < pixelCount; i++) { + const g = data[i * 2]; + rgba[i * 4] = g; + rgba[i * 4 + 1] = g; + rgba[i * 4 + 2] = g; + rgba[i * 4 + 3] = data[i * 2 + 1]; + } + } else if (samplesPerPixel === 1) { + // Greyscale → RGBA + for (let i = 0; i < pixelCount; i++) { + rgba[i * 4] = data[i]; + rgba[i * 4 + 1] = data[i]; + rgba[i * 4 + 2] = data[i]; + rgba[i * 4 + 3] = 255; + } + } else { + return null; + } + + return rgba; +} + /** * Converts a TIFF image to a PNG data URI. * @@ -80,22 +128,19 @@ export function convertTiffToPng(data) { const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); // Decode TIFF — get Image File Directories (pages) - const ifds = UTIF.decode(buffer); + const ifds = decode(buffer); if (!ifds || ifds.length === 0) return null; - // Validate dimensions from raw IFD tags before decoding pixel data. - // UTIF.decode populates tag entries (t256=ImageWidth, t257=ImageLength) - // but .width/.height are only set after decodeImage. - const ifdWidth = ifds[0].t256?.[0]; - const ifdHeight = ifds[0].t257?.[0]; - if (!ifdWidth || !ifdHeight || ifdWidth * ifdHeight > MAX_PIXEL_COUNT) return null; + const ifd = ifds[0]; + const { width, height } = ifd; + if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; - // Decode pixel data for the first page - UTIF.decodeImage(buffer, ifds[0]); - const rgba = UTIF.toRGBA8(ifds[0]); - if (!rgba || rgba.length === 0) return null; + const pixelData = ifd.data; + if (!pixelData || pixelData.length === 0) return null; - const { width, height } = ifds[0]; + const samplesPerPixel = ifd.samplesPerPixel ?? (ifd.alpha ? 2 : 1); + const rgba = toRGBA(pixelData, samplesPerPixel, ifd.alpha, width * height); + if (!rgba) return null; // Render to canvas and export as PNG const canvas = createCanvas(); @@ -111,7 +156,7 @@ export function convertTiffToPng(data) { if (!ctx) return null; const imageData = ctx.createImageData(width, height); - imageData.data.set(new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength)); + imageData.data.set(rgba); ctx.putImageData(imageData, 0, 0); const dataUri = canvas.toDataURL('image/png'); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index bdbbebb925..50a39027c8 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -61,14 +61,9 @@ describe('tiff-converter', () => { }); it('returns a PNG data URI for valid TIFF input', () => { - const fakeRgba = new Uint8Array(2 * 2 * 4); // 2x2 image, RGBA - vi.doMock('utif2', () => ({ - decode: () => [{ t256: [2], t257: [2] }], - decodeImage: (_buf, ifd) => { - ifd.width = 2; - ifd.height = 2; - }, - toRGBA8: () => fakeRgba, + const fakePixelData = new Uint8Array(2 * 2 * 3); // 2x2 RGB image + vi.doMock('tiff', () => ({ + decode: () => [{ width: 2, height: 2, data: fakePixelData, samplesPerPixel: 3, alpha: false }], })); const mockCanvas = { @@ -88,24 +83,21 @@ describe('tiff-converter', () => { expect(result).toEqual({ dataUri: 'data:image/png;base64,iVBORw0KGgo=', format: 'png' }); spy.mockRestore(); - vi.doUnmock('utif2'); + vi.doUnmock('tiff'); }); }); it('returns null for TIFF with dimensions exceeding pixel limit', () => { - // Mock utif2 to return oversized dimensions via raw IFD tags - // (t256=ImageWidth, t257=ImageLength — 100k × 10k = 1 billion pixels) - vi.doMock('utif2', () => ({ - decode: () => [{ t256: [100_000], t257: [10_000] }], - decodeImage: () => undefined, - toRGBA8: () => new Uint8Array(4), + // Mock tiff to return oversized dimensions (100k × 10k = 1 billion pixels) + vi.doMock('tiff', () => ({ + decode: () => [{ width: 100_000, height: 10_000, data: new Uint8Array(4), samplesPerPixel: 1, alpha: false }], })); // Re-import to pick up the mock return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { const result = fn('SU8qAA=='); expect(result).toBeNull(); - vi.doUnmock('utif2'); + vi.doUnmock('tiff'); }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4568f7fcf9..7988cb4f0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,9 +237,9 @@ catalogs: semantic-release-linear-app: specifier: ^0.7.1 version: 0.7.1 - sirv: - specifier: ^3.0.2 - version: 3.0.2 + tiff: + specifier: ^6.1.1 + version: 6.2.0 tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -258,9 +258,6 @@ catalogs: unified: specifier: 11.0.5 version: 11.0.5 - utif2: - specifier: ^4.1.0 - version: 4.1.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -315,9 +312,6 @@ importers: .: devDependencies: - '@clack/prompts': - specifier: ^1.0.1 - version: 1.0.1 '@commitlint/cli': specifier: 'catalog:' version: 19.8.1(@types/node@22.19.2)(typescript@5.9.3) @@ -348,9 +342,6 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.2)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.1))(tsx@4.21.0)(yaml@2.8.2)) - concurrently: - specifier: 'catalog:' - version: 9.2.1 eslint: specifier: 'catalog:' version: 9.39.2(jiti@2.6.1) @@ -424,9 +415,6 @@ importers: fast-glob: specifier: 'catalog:' version: 3.3.3 - happy-dom: - specifier: 20.4.0 - version: 20.4.0 y-websocket: specifier: 'catalog:' version: 3.0.0(yjs@13.6.19) @@ -1123,12 +1111,12 @@ importers: remark-stringify: specifier: 'catalog:' version: 11.0.0 + tiff: + specifier: 'catalog:' + version: 6.2.0 unified: specifier: 'catalog:' version: 11.0.5 - utif2: - specifier: 'catalog:' - version: 4.1.0 uuid: specifier: 'catalog:' version: 9.0.1 @@ -1269,9 +1257,6 @@ importers: '@hocuspocus/server': specifier: 'catalog:' version: 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) - '@superdoc-dev/superdoc-yjs-collaboration': - specifier: workspace:* - version: link:../collaboration-yjs '@superdoc/common': specifier: workspace:* version: link:../../shared/common @@ -1311,9 +1296,6 @@ importers: rollup-plugin-visualizer: specifier: 'catalog:' version: 5.14.0(rollup@4.57.1) - sirv: - specifier: 'catalog:' - version: 3.0.2 typescript: specifier: 'catalog:' version: 5.9.3 @@ -1329,9 +1311,6 @@ importers: vitest: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.8)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.1))(tsx@4.21.0)(yaml@2.8.2) - ws: - specifier: ^8.18.3 - version: 8.19.0 xml-js: specifier: 'catalog:' version: 1.6.11 @@ -1862,15 +1841,9 @@ packages: '@clack/core@1.0.0': resolution: {integrity: sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==} - '@clack/core@1.0.1': - resolution: {integrity: sha512-WKeyK3NOBwDOzagPR5H08rFk9D/WuN705yEbuZvKqlkmoLM2woKtXb10OO2k1NoSU4SFG947i2/SCYh+2u5e4g==} - '@clack/prompts@1.0.0': resolution: {integrity: sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==} - '@clack/prompts@1.0.1': - resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} - '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -3110,9 +3083,6 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -7031,6 +7001,9 @@ packages: resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} engines: {node: '>=12'} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -8375,10 +8348,6 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -8902,6 +8871,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -10059,10 +10031,6 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -10409,6 +10377,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiff@6.2.0: + resolution: {integrity: sha512-2oqrUZd1SUmCl1DGoBCWtQb0gVq6A81SoIpuQX4Os/VRA2Waa7ovF1hWzFhVFZkjFSC2B7w24aq6gjqIl7TAVg==} + time-span@5.1.0: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} @@ -10479,10 +10450,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - touch@3.1.1: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true @@ -10854,9 +10821,6 @@ packages: '@types/react': optional: true - utif2@4.1.0: - resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -12142,23 +12106,12 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/core@1.0.1': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.0.0': dependencies: '@clack/core': 1.0.0 picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/prompts@1.0.1': - dependencies: - '@clack/core': 1.0.1 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@colors/colors@1.5.0': optional: true @@ -13674,8 +13627,6 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@polka/url@1.0.0-next.29': {} - '@popperjs/core@2.11.8': {} '@puppeteer/browsers@2.3.0': @@ -18698,6 +18649,8 @@ snapshots: from2: 2.3.0 p-is-promise: 3.0.0 + iobuffer@5.4.0: {} + ip-address@10.0.1: {} ip-address@10.1.0: {} @@ -20442,8 +20395,6 @@ snapshots: mri@1.2.0: {} - mrmime@2.0.1: {} - ms@2.0.0: {} ms@2.1.3: {} @@ -20861,6 +20812,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -22473,12 +22426,6 @@ snapshots: dependencies: semver: 7.7.3 - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - sisteransi@1.0.5: {} skin-tone@2.0.0: @@ -22953,6 +22900,11 @@ snapshots: through@2.3.8: {} + tiff@6.2.0: + dependencies: + iobuffer: 5.4.0 + pako: 2.1.0 + time-span@5.1.0: dependencies: convert-hrtime: 5.0.0 @@ -23010,8 +22962,6 @@ snapshots: toidentifier@1.0.1: {} - totalist@3.0.1: {} - touch@3.1.1: {} tough-cookie@5.1.2: @@ -23429,10 +23379,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.11 - utif2@4.1.0: - dependencies: - pako: 1.0.11 - util-deprecate@1.0.2: {} util@0.12.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f552aa2169..3ecf08de77 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -105,7 +105,7 @@ catalog: typescript: ^5.9.2 typescript-eslint: ^8.49.0 unified: 11.0.5 - utif2: ^4.1.0 + tiff: ^6.1.1 uuid: ^9.0.1 verdaccio: ^6.1.6 vite: ^7.2.7 From 1a189a5ba3e7a676627e29aff7b871be092026bf Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Mon, 2 Mar 2026 10:51:21 +0530 Subject: [PATCH 09/12] fix: pre-decode size guard and 16-bit TIFF normalization Use decode(buffer, { ignoreImageData: true }) to check dimensions before allocating pixel data, preventing DoS from small compressed TIFFs with huge dimensions. Normalize Uint16Array and Float32Array pixel data to 8-bit for canvas compatibility. --- .../v3/handlers/wp/helpers/tiff-converter.js | 23 ++++++++++++++----- .../wp/helpers/tiff-converter.test.js | 19 +++++++++++---- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 19d43990f3..379e7536be 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -127,17 +127,28 @@ export function convertTiffToPng(data) { const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - // Decode TIFF — get Image File Directories (pages) - const ifds = decode(buffer); - if (!ifds || ifds.length === 0) return null; + // Read metadata first without decompressing pixel data so the size + // guard fires before a huge RGBA buffer is allocated. + const meta = decode(buffer, { ignoreImageData: true }); + if (!meta || meta.length === 0) return null; - const ifd = ifds[0]; - const { width, height } = ifd; + const { width, height } = meta[0]; if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; - const pixelData = ifd.data; + // Dimensions are safe — decode pixel data + const ifds = decode(buffer); + const ifd = ifds[0]; + + let pixelData = ifd.data; if (!pixelData || pixelData.length === 0) return null; + // Normalize higher bit-depth data to 8-bit for canvas + if (pixelData instanceof Uint16Array) { + pixelData = Uint8Array.from(pixelData, (v) => ((v + 128) / 257) | 0); + } else if (pixelData instanceof Float32Array) { + pixelData = Uint8Array.from(pixelData, (v) => (Math.min(Math.max(v, 0), 1) * 255 + 0.5) | 0); + } + const samplesPerPixel = ifd.samplesPerPixel ?? (ifd.alpha ? 2 : 1); const rgba = toRGBA(pixelData, samplesPerPixel, ifd.alpha, width * height); if (!rgba) return null; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index 50a39027c8..8736ffea80 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -63,7 +63,11 @@ describe('tiff-converter', () => { it('returns a PNG data URI for valid TIFF input', () => { const fakePixelData = new Uint8Array(2 * 2 * 3); // 2x2 RGB image vi.doMock('tiff', () => ({ - decode: () => [{ width: 2, height: 2, data: fakePixelData, samplesPerPixel: 3, alpha: false }], + decode: (_buf, opts) => { + // First call with ignoreImageData returns metadata only + if (opts?.ignoreImageData) return [{ width: 2, height: 2 }]; + return [{ width: 2, height: 2, data: fakePixelData, samplesPerPixel: 3, alpha: false }]; + }, })); const mockCanvas = { @@ -78,7 +82,6 @@ describe('tiff-converter', () => { const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); return import('./tiff-converter.js?happy').then(({ convertTiffToPng: fn }) => { - // Pass base64-encoded string (the only input type the call site uses) const result = fn('SU8qAA=='); expect(result).toEqual({ dataUri: 'data:image/png;base64,iVBORw0KGgo=', format: 'png' }); @@ -88,15 +91,21 @@ describe('tiff-converter', () => { }); it('returns null for TIFF with dimensions exceeding pixel limit', () => { - // Mock tiff to return oversized dimensions (100k × 10k = 1 billion pixels) + // Mock tiff metadata-only decode to return oversized dimensions + // (100k × 10k = 1 billion pixels). The full decode should never be called. + const fullDecode = vi.fn(); vi.doMock('tiff', () => ({ - decode: () => [{ width: 100_000, height: 10_000, data: new Uint8Array(4), samplesPerPixel: 1, alpha: false }], + decode: (_buf, opts) => { + if (opts?.ignoreImageData) return [{ width: 100_000, height: 10_000 }]; + fullDecode(); + return [{ width: 100_000, height: 10_000, data: new Uint8Array(4), samplesPerPixel: 1, alpha: false }]; + }, })); - // Re-import to pick up the mock return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { const result = fn('SU8qAA=='); expect(result).toBeNull(); + expect(fullDecode).not.toHaveBeenCalled(); vi.doUnmock('tiff'); }); }); From 2cf4b1d4b2e0bde321893438dd8fcd9b1735683b Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Mon, 2 Mar 2026 17:05:38 +0530 Subject: [PATCH 10/12] test: add TIFF MIME, fallback, and branch coverage tests; extract shared dataUriToArrayBuffer helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address remaining PR review feedback: add tests for .tif → image/tiff MIME mapping (import data URI and export Content_Types), TIFF conversion failure fallback alt text, greyscale/grey+alpha/Uint16/Float32 toRGBA branches, and extract duplicate data-URI-stripping logic from metafile-converter and tiff-converter into shared dataUriToArrayBuffer in helpers.js. --- .../super-editor/src/core/DocxZipper.test.js | 58 ++++++++ .../src/core/super-converter/helpers.js | 28 ++++ .../legacy-handle-paragraph-node.test.js | 16 ++- .../helpers/decode-image-node-helpers.test.js | 28 ++-- .../helpers/encode-image-node-helpers.test.js | 14 ++ .../handlers/wp/helpers/metafile-converter.js | 37 +---- .../v3/handlers/wp/helpers/tiff-converter.js | 13 +- .../wp/helpers/tiff-converter.test.js | 128 ++++++++++++++++++ 8 files changed, 259 insertions(+), 63 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index 05af9b750b..b7a13db72b 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -338,6 +338,64 @@ describe('DocxZipper - exportFromCollaborativeDocx media handling', () => { }); }); +describe('DocxZipper - .tif MIME type mapping', () => { + it('produces image/tiff data URI for .tif files on import', async () => { + const zipper = new DocxZipper(); + const zip = new JSZip(); + + const contentTypes = ` + + + + + `; + zip.file('[Content_Types].xml', contentTypes); + zip.file('word/document.xml', ''); + + // Arbitrary binary data stored as a .tif file + const tifData = new Uint8Array([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]); + zip.file('word/media/image1.tif', tifData); + + const buf = await zip.generateAsync({ type: 'arraybuffer' }); + await zipper.getDocxData(buf, false); + + // Must use image/tiff, not image/tif + expect(zipper.mediaFiles['word/media/image1.tif']).toMatch(/^data:image\/tiff;base64,/); + }); + + it('writes image/tiff content type in [Content_Types].xml on export', async () => { + const zipper = new DocxZipper(); + + const contentTypes = ` + + + + + `; + + const docx = [ + { name: '[Content_Types].xml', content: contentTypes }, + { name: 'word/document.xml', content: '' }, + ]; + + const result = await zipper.updateZip({ + docx, + updatedDocs: {}, + media: { 'word/media/image1.tif': 'AAAA' }, + fonts: {}, + isHeadless: true, + }); + + const readBack = await new JSZip().loadAsync(result); + const updatedContentTypes = await readBack.file('[Content_Types].xml').async('string'); + + // Should contain Extension="tif" with ContentType="image/tiff" + expect(updatedContentTypes).toContain('Extension="tif"'); + expect(updatedContentTypes).toContain('ContentType="image/tiff"'); + expect(updatedContentTypes).not.toContain('ContentType="image/tif"'); + }); +}); + describe('DocxZipper - .tmp image file detection', () => { it('detects and processes .tmp files with PNG signatures as PNG images', async () => { const zipper = new DocxZipper(); diff --git a/packages/super-editor/src/core/super-converter/helpers.js b/packages/super-editor/src/core/super-converter/helpers.js index 0990e0bbf2..fa1dab5902 100644 --- a/packages/super-editor/src/core/super-converter/helpers.js +++ b/packages/super-editor/src/core/super-converter/helpers.js @@ -33,6 +33,33 @@ function base64ToUint8Array(base64) { return bytes; } +/** + * Convert a base64 string or data URI to an ArrayBuffer. + * Accepts ArrayBuffer, TypedArray, data URI, or raw base64 string. + * + * @param {string|ArrayBuffer|Uint8Array} data + * @returns {ArrayBuffer} + */ +function dataUriToArrayBuffer(data) { + if (data instanceof ArrayBuffer) return data; + if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); + + if (typeof data !== 'string') { + throw new Error('Unsupported data type for conversion to ArrayBuffer'); + } + + let base64 = data; + if (data.startsWith('data:')) { + const commaIndex = data.indexOf(','); + if (commaIndex === -1) { + throw new Error('Invalid data URI: missing base64 content'); + } + base64 = data.substring(commaIndex + 1); + } + + return base64ToUint8Array(base64).buffer; +} + // CSS pixels per inch; used to convert between Word's inch-based measurements and DOM pixels. const PIXELS_PER_INCH = 96; @@ -720,5 +747,6 @@ export { resolveOpcTargetPath, computeCrc32Hex, base64ToUint8Array, + dataUriToArrayBuffer, detectImageType, }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index 4c18b25202..ad40117084 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -13,12 +13,16 @@ vi.mock('@converter/v2/importer/index.js', () => ({ })); // Simple and predictable conversion for positions -vi.mock('@converter/helpers.js', () => ({ - twipsToPixels: (twips) => (twips === undefined ? undefined : Number(twips) / 20), - twipsToInches: (twips) => (twips === undefined ? undefined : Number(twips) / 10), - twipsToLines: (twips) => (twips === undefined ? undefined : Number(twips) / 240), - pixelsToTwips: (pixels) => (pixels === undefined ? undefined : Math.round(Number(pixels) * 20)), -})); +vi.mock('@converter/helpers.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + twipsToPixels: (twips) => (twips === undefined ? undefined : Number(twips) / 20), + twipsToInches: (twips) => (twips === undefined ? undefined : Number(twips) / 10), + twipsToLines: (twips) => (twips === undefined ? undefined : Number(twips) / 240), + pixelsToTwips: (pixels) => (pixels === undefined ? undefined : Math.round(Number(pixels) * 20)), + }; +}); import { handleParagraphNode } from './legacy-handle-paragraph-node.js'; import { parseMarks, mergeTextNodes } from '@converter/v2/importer/index.js'; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 02fec3c60f..ef7ed61a68 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -5,18 +5,22 @@ import { import * as helpers from '@converter/helpers.js'; import * as annotationHelpers from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js'; -vi.mock('@converter/helpers.js', () => ({ - emuToPixels: vi.fn((v) => v / 9525), // 1 emu ≈ 1/9525 px - pixelsToEmu: vi.fn((v) => v * 9525), - getTextIndentExportValue: vi.fn((v) => v), - inchesToTwips: vi.fn((v) => v), - linesToTwips: vi.fn((v) => v), - pixelsToEightPoints: vi.fn((v) => v), - pixelsToTwips: vi.fn((v) => v), - ptToTwips: vi.fn((v) => v), - rgbToHex: vi.fn(() => '#000000'), - degreesToRot: vi.fn((v) => v), -})); +vi.mock('@converter/helpers.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + emuToPixels: vi.fn((v) => v / 9525), // 1 emu ≈ 1/9525 px + pixelsToEmu: vi.fn((v) => v * 9525), + getTextIndentExportValue: vi.fn((v) => v), + inchesToTwips: vi.fn((v) => v), + linesToTwips: vi.fn((v) => v), + pixelsToEightPoints: vi.fn((v) => v), + pixelsToTwips: vi.fn((v) => v), + ptToTwips: vi.fn((v) => v), + rgbToHex: vi.fn(() => '#000000'), + degreesToRot: vi.fn((v) => v), + }; +}); vi.mock('@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js', () => ({ prepareTextAnnotation: vi.fn(() => ({ type: 'text', text: 'annotation' })), diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 2e60a6b0bc..599598594a 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -244,6 +244,20 @@ describe('handleImageNode', () => { expect(result.attrs.extension).toBe('png'); }); + it('returns alt text when convertTiffToPng returns null', () => { + convertTiffToPng.mockReturnValue(null); + const node = makeNode(); + const params = { + ...makeParams('media/photo.tif'), + converter: { media: { 'word/media/photo.tif': 'data:image/tiff;base64,AAAA' } }, + }; + const result = handleImageNode(node, params, false); + + expect(convertTiffToPng).toHaveBeenCalledWith('data:image/tiff;base64,AAAA'); + expect(result.attrs.alt).toBe('Unable to render image'); + expect(result.attrs.extension).toBe('tif'); + }); + it('captures unhandled drawing children for passthrough preservation', () => { const node = makeNode(); node.elements.push({ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js index 4b25b7a727..bf9f1ff793 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js @@ -15,7 +15,7 @@ /* global btoa, XMLSerializer */ import { EMFJS, WMFJS } from './rtfjs'; -import { base64ToUint8Array } from '../../../../helpers.js'; +import { dataUriToArrayBuffer } from '../../../../helpers.js'; // Disable verbose logging from the renderers EMFJS.loggingEnabled(false); @@ -74,39 +74,8 @@ const MM_ANISOTROPIC = 8; const EMF_SIGNATURE = 0x464d4520; // ' EMF' const EMF_PLUS_SIGNATURE = 0x2b464d45; // 'EMF+' inside EMR_COMMENT -/** - * Converts input data to an ArrayBuffer. - * - * Accepts: - * - ArrayBuffer - * - TypedArray / Buffer - * - base64 string or data URI - * - * @param {string|ArrayBuffer|Uint8Array} data - * @returns {ArrayBuffer} - */ -function base64ToArrayBuffer(data) { - if (data instanceof ArrayBuffer) return data; - if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); - - if (typeof data !== 'string') { - throw new Error('Unsupported data type for conversion to ArrayBuffer'); - } - - // Handle both data URI format and raw base64 - let base64 = data; - - // Check if it's a data URI and extract the base64 portion - if (data.startsWith('data:')) { - const commaIndex = data.indexOf(','); - if (commaIndex === -1) { - throw new Error('Invalid data URI: missing base64 content'); - } - base64 = data.substring(commaIndex + 1); - } - - return base64ToUint8Array(base64).buffer; -} +// Re-export for local use — shared implementation lives in ../../../../helpers.js +const base64ToArrayBuffer = dataUriToArrayBuffer; /** * Encodes a Uint8Array into base64 using chunked processing to avoid call stack overflows. diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 379e7536be..51a1fbdad8 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -9,7 +9,7 @@ */ import { decode } from 'tiff'; -import { base64ToUint8Array } from '../../../../helpers.js'; +import { dataUriToArrayBuffer } from '../../../../helpers.js'; // Optional DOM environment provided by callers (e.g., JSDOM in Node) let domEnvironment = null; @@ -116,16 +116,7 @@ export function convertTiffToPng(data) { try { if (typeof data !== 'string') return null; - // Parse input — accept data URI or raw base64 - let base64 = data; - if (data.startsWith('data:')) { - const commaIndex = data.indexOf(','); - if (commaIndex === -1) return null; - base64 = data.substring(commaIndex + 1); - } - const bytes = base64ToUint8Array(base64); - - const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const buffer = dataUriToArrayBuffer(data); // Read metadata first without decompressing pixel data so the size // guard fires before a huge RGBA buffer is allocated. diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index 8736ffea80..16d90b0e1d 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -90,6 +90,134 @@ describe('tiff-converter', () => { }); }); + it('converts a greyscale (1 channel) TIFF to PNG', () => { + // 2x2 greyscale: pixel values [50, 100, 150, 200] + const greyData = new Uint8Array([50, 100, 150, 200]); + vi.doMock('tiff', () => ({ + decode: (_buf, opts) => { + if (opts?.ignoreImageData) return [{ width: 2, height: 2 }]; + return [{ width: 2, height: 2, data: greyData, samplesPerPixel: 1, alpha: false }]; + }, + })); + + const mockCanvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), + putImageData: () => {}, + }), + toDataURL: () => 'data:image/png;base64,grey', + }; + const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); + + return import('./tiff-converter.js?grey').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toEqual({ dataUri: 'data:image/png;base64,grey', format: 'png' }); + spy.mockRestore(); + vi.doUnmock('tiff'); + }); + }); + + it('converts a grey+alpha (2 channel) TIFF to PNG', () => { + // 2x1 grey+alpha: [grey, alpha, grey, alpha] + const greyAlphaData = new Uint8Array([128, 255, 64, 128]); + vi.doMock('tiff', () => ({ + decode: (_buf, opts) => { + if (opts?.ignoreImageData) return [{ width: 2, height: 1 }]; + return [{ width: 2, height: 1, data: greyAlphaData, samplesPerPixel: 2, alpha: true }]; + }, + })); + + const mockCanvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), + putImageData: () => {}, + }), + toDataURL: () => 'data:image/png;base64,greyAlpha', + }; + const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); + + return import('./tiff-converter.js?greyAlpha').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toEqual({ dataUri: 'data:image/png;base64,greyAlpha', format: 'png' }); + spy.mockRestore(); + vi.doUnmock('tiff'); + }); + }); + + it('normalizes Uint16Array pixel data to 8-bit', () => { + // 1x1 RGB with 16-bit values; 65535 → 255, 32768 → 128, 0 → 0 + const uint16Data = new Uint16Array([65535, 32768, 0]); + vi.doMock('tiff', () => ({ + decode: (_buf, opts) => { + if (opts?.ignoreImageData) return [{ width: 1, height: 1 }]; + return [{ width: 1, height: 1, data: uint16Data, samplesPerPixel: 3, alpha: false }]; + }, + })); + + const putCalls = []; + const mockCanvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), + putImageData: (imageData) => putCalls.push(Array.from(imageData.data)), + }), + toDataURL: () => 'data:image/png;base64,u16', + }; + const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); + + return import('./tiff-converter.js?uint16').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toEqual({ dataUri: 'data:image/png;base64,u16', format: 'png' }); + // Verify normalization: (65535+128)/257|0 = 255, (32768+128)/257|0 = 128, (0+128)/257|0 = 0 + expect(putCalls[0][0]).toBe(255); + expect(putCalls[0][1]).toBe(128); + expect(putCalls[0][2]).toBe(0); + expect(putCalls[0][3]).toBe(255); // alpha + spy.mockRestore(); + vi.doUnmock('tiff'); + }); + }); + + it('normalizes Float32Array pixel data to 8-bit', () => { + // 1x1 RGB with float values; 1.0 → 255, 0.5 → 128, 0.0 → 0 + const floatData = new Float32Array([1.0, 0.5, 0.0]); + vi.doMock('tiff', () => ({ + decode: (_buf, opts) => { + if (opts?.ignoreImageData) return [{ width: 1, height: 1 }]; + return [{ width: 1, height: 1, data: floatData, samplesPerPixel: 3, alpha: false }]; + }, + })); + + const putCalls = []; + const mockCanvas = { + width: 0, + height: 0, + getContext: () => ({ + createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), + putImageData: (imageData) => putCalls.push(Array.from(imageData.data)), + }), + toDataURL: () => 'data:image/png;base64,f32', + }; + const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); + + return import('./tiff-converter.js?float32').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toEqual({ dataUri: 'data:image/png;base64,f32', format: 'png' }); + // Verify normalization: 1.0 → 255, 0.5 → 128, 0.0 → 0 + expect(putCalls[0][0]).toBe(255); + expect(putCalls[0][1]).toBe(128); + expect(putCalls[0][2]).toBe(0); + expect(putCalls[0][3]).toBe(255); // alpha + spy.mockRestore(); + vi.doUnmock('tiff'); + }); + }); + it('returns null for TIFF with dimensions exceeding pixel limit', () => { // Mock tiff metadata-only decode to return oversized dimensions // (100k × 10k = 1 billion pixels). The full decode should never be called. From 5791d006ba451b817b1a846bfe6388e853ba79f4 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 4 Mar 2026 10:26:20 -0300 Subject: [PATCH 11/12] fix: revert to utif2 for TIFF decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image-js/tiff lacks support for PackBits, JPEG, and CCITT compression formats commonly found in Word documents. utif2 handles all TIFF compression types via its toRGBA8 pipeline. Updated tests to match utif2 API (decode → decodeImage → toRGBA8). --- packages/super-editor/package.json | 2 +- .../v3/handlers/wp/helpers/tiff-converter.js | 94 ++-------- .../wp/helpers/tiff-converter.test.js | 170 ++++-------------- pnpm-lock.yaml | 100 ++++++++--- pnpm-workspace.yaml | 2 +- 5 files changed, 130 insertions(+), 238 deletions(-) diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index 96f2f4274a..17494b9d4d 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -101,7 +101,7 @@ "remark-parse": "catalog:", "remark-stringify": "catalog:", "unified": "catalog:", - "tiff": "catalog:", + "utif2": "catalog:", "uuid": "catalog:", "vue": "catalog:", "xml-js": "catalog:" diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 51a1fbdad8..1c6074425d 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -1,14 +1,14 @@ /** * TIFF to PNG Converter * - * Converts TIFF images to PNG format using the `tiff` package (image-js/tiff) - * for decoding and Canvas for encoding. Browsers cannot natively render TIFF - * images, so this converts them at import time to a browser-friendly format. + * Converts TIFF images to PNG format using utif2 for decoding and Canvas for + * encoding. Browsers cannot natively render TIFF images, so this converts them + * at import time to a browser-friendly format. * * @module tiff-converter */ -import { decode } from 'tiff'; +import * as UTIF from 'utif2'; import { dataUriToArrayBuffer } from '../../../../helpers.js'; // Optional DOM environment provided by callers (e.g., JSDOM in Node) @@ -58,54 +58,6 @@ function createCanvas() { return null; } -/** - * Convert decoded pixel data to RGBA format. - * The `tiff` package returns pixel data whose channel count depends on the - * image (greyscale=1, grey+alpha=2, RGB=3, RGBA=4). Canvas requires RGBA. - * - * @param {Uint8Array} data - Decoded pixel data - * @param {number} samplesPerPixel - Number of channels per pixel - * @param {boolean} hasAlpha - Whether the image has an alpha channel - * @param {number} pixelCount - Total number of pixels (width × height) - * @returns {Uint8Array} RGBA pixel data - */ -function toRGBA(data, samplesPerPixel, hasAlpha, pixelCount) { - if (samplesPerPixel === 4 && hasAlpha) return data; - - const rgba = new Uint8Array(pixelCount * 4); - - if (samplesPerPixel === 3) { - // RGB → RGBA - for (let i = 0; i < pixelCount; i++) { - rgba[i * 4] = data[i * 3]; - rgba[i * 4 + 1] = data[i * 3 + 1]; - rgba[i * 4 + 2] = data[i * 3 + 2]; - rgba[i * 4 + 3] = 255; - } - } else if (samplesPerPixel === 2 && hasAlpha) { - // Grey + Alpha → RGBA - for (let i = 0; i < pixelCount; i++) { - const g = data[i * 2]; - rgba[i * 4] = g; - rgba[i * 4 + 1] = g; - rgba[i * 4 + 2] = g; - rgba[i * 4 + 3] = data[i * 2 + 1]; - } - } else if (samplesPerPixel === 1) { - // Greyscale → RGBA - for (let i = 0; i < pixelCount; i++) { - rgba[i * 4] = data[i]; - rgba[i * 4 + 1] = data[i]; - rgba[i * 4 + 2] = data[i]; - rgba[i * 4 + 3] = 255; - } - } else { - return null; - } - - return rgba; -} - /** * Converts a TIFF image to a PNG data URI. * @@ -118,31 +70,23 @@ export function convertTiffToPng(data) { const buffer = dataUriToArrayBuffer(data); - // Read metadata first without decompressing pixel data so the size - // guard fires before a huge RGBA buffer is allocated. - const meta = decode(buffer, { ignoreImageData: true }); - if (!meta || meta.length === 0) return null; + // Decode TIFF — get Image File Directories (pages) + const ifds = UTIF.decode(buffer); + if (!ifds || ifds.length === 0) return null; - const { width, height } = meta[0]; - if (!width || !height || width * height > MAX_PIXEL_COUNT) return null; + // Validate dimensions from raw IFD tags before decoding pixel data. + // UTIF.decode populates tag entries (t256=ImageWidth, t257=ImageLength) + // but .width/.height are only set after decodeImage. + const ifdWidth = ifds[0].t256?.[0]; + const ifdHeight = ifds[0].t257?.[0]; + if (!ifdWidth || !ifdHeight || ifdWidth * ifdHeight > MAX_PIXEL_COUNT) return null; - // Dimensions are safe — decode pixel data - const ifds = decode(buffer); - const ifd = ifds[0]; - - let pixelData = ifd.data; - if (!pixelData || pixelData.length === 0) return null; - - // Normalize higher bit-depth data to 8-bit for canvas - if (pixelData instanceof Uint16Array) { - pixelData = Uint8Array.from(pixelData, (v) => ((v + 128) / 257) | 0); - } else if (pixelData instanceof Float32Array) { - pixelData = Uint8Array.from(pixelData, (v) => (Math.min(Math.max(v, 0), 1) * 255 + 0.5) | 0); - } + // Decode pixel data for the first page only + UTIF.decodeImage(buffer, ifds[0]); + const rgba = UTIF.toRGBA8(ifds[0]); + if (!rgba || rgba.length === 0) return null; - const samplesPerPixel = ifd.samplesPerPixel ?? (ifd.alpha ? 2 : 1); - const rgba = toRGBA(pixelData, samplesPerPixel, ifd.alpha, width * height); - if (!rgba) return null; + const { width, height } = ifds[0]; // Render to canvas and export as PNG const canvas = createCanvas(); @@ -158,7 +102,7 @@ export function convertTiffToPng(data) { if (!ctx) return null; const imageData = ctx.createImageData(width, height); - imageData.data.set(rgba); + imageData.data.set(new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength)); ctx.putImageData(imageData, 0, 0); const dataUri = canvas.toDataURL('image/png'); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index 16d90b0e1d..cefaf7b4b5 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -61,13 +61,11 @@ describe('tiff-converter', () => { }); it('returns a PNG data URI for valid TIFF input', () => { - const fakePixelData = new Uint8Array(2 * 2 * 3); // 2x2 RGB image - vi.doMock('tiff', () => ({ - decode: (_buf, opts) => { - // First call with ignoreImageData returns metadata only - if (opts?.ignoreImageData) return [{ width: 2, height: 2 }]; - return [{ width: 2, height: 2, data: fakePixelData, samplesPerPixel: 3, alpha: false }]; - }, + const fakeRgba = new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255]); + vi.doMock('utif2', () => ({ + decode: () => [{ t256: [2], t257: [2] }], + decodeImage: (_buf, ifd) => { ifd.width = 2; ifd.height = 2; }, + toRGBA8: () => fakeRgba, })); const mockCanvas = { @@ -86,155 +84,53 @@ describe('tiff-converter', () => { expect(result).toEqual({ dataUri: 'data:image/png;base64,iVBORw0KGgo=', format: 'png' }); spy.mockRestore(); - vi.doUnmock('tiff'); + vi.doUnmock('utif2'); }); }); - it('converts a greyscale (1 channel) TIFF to PNG', () => { - // 2x2 greyscale: pixel values [50, 100, 150, 200] - const greyData = new Uint8Array([50, 100, 150, 200]); - vi.doMock('tiff', () => ({ - decode: (_buf, opts) => { - if (opts?.ignoreImageData) return [{ width: 2, height: 2 }]; - return [{ width: 2, height: 2, data: greyData, samplesPerPixel: 1, alpha: false }]; - }, - })); - - const mockCanvas = { - width: 0, - height: 0, - getContext: () => ({ - createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), - putImageData: () => {}, - }), - toDataURL: () => 'data:image/png;base64,grey', - }; - const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); - - return import('./tiff-converter.js?grey').then(({ convertTiffToPng: fn }) => { - const result = fn('SU8qAA=='); - expect(result).toEqual({ dataUri: 'data:image/png;base64,grey', format: 'png' }); - spy.mockRestore(); - vi.doUnmock('tiff'); - }); - }); - - it('converts a grey+alpha (2 channel) TIFF to PNG', () => { - // 2x1 grey+alpha: [grey, alpha, grey, alpha] - const greyAlphaData = new Uint8Array([128, 255, 64, 128]); - vi.doMock('tiff', () => ({ - decode: (_buf, opts) => { - if (opts?.ignoreImageData) return [{ width: 2, height: 1 }]; - return [{ width: 2, height: 1, data: greyAlphaData, samplesPerPixel: 2, alpha: true }]; - }, - })); - - const mockCanvas = { - width: 0, - height: 0, - getContext: () => ({ - createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), - putImageData: () => {}, - }), - toDataURL: () => 'data:image/png;base64,greyAlpha', - }; - const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); - - return import('./tiff-converter.js?greyAlpha').then(({ convertTiffToPng: fn }) => { - const result = fn('SU8qAA=='); - expect(result).toEqual({ dataUri: 'data:image/png;base64,greyAlpha', format: 'png' }); - spy.mockRestore(); - vi.doUnmock('tiff'); - }); - }); - - it('normalizes Uint16Array pixel data to 8-bit', () => { - // 1x1 RGB with 16-bit values; 65535 → 255, 32768 → 128, 0 → 0 - const uint16Data = new Uint16Array([65535, 32768, 0]); - vi.doMock('tiff', () => ({ - decode: (_buf, opts) => { - if (opts?.ignoreImageData) return [{ width: 1, height: 1 }]; - return [{ width: 1, height: 1, data: uint16Data, samplesPerPixel: 3, alpha: false }]; - }, + it('returns null for TIFF with dimensions exceeding pixel limit', () => { + // Mock utif2 to return oversized dimensions via IFD tags. + // decodeImage should never be called. + const decodeImageSpy = vi.fn(); + vi.doMock('utif2', () => ({ + decode: () => [{ t256: [100_000], t257: [10_000] }], + decodeImage: decodeImageSpy, + toRGBA8: () => new Uint8Array(0), })); - const putCalls = []; - const mockCanvas = { - width: 0, - height: 0, - getContext: () => ({ - createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), - putImageData: (imageData) => putCalls.push(Array.from(imageData.data)), - }), - toDataURL: () => 'data:image/png;base64,u16', - }; - const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); - - return import('./tiff-converter.js?uint16').then(({ convertTiffToPng: fn }) => { + return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { const result = fn('SU8qAA=='); - expect(result).toEqual({ dataUri: 'data:image/png;base64,u16', format: 'png' }); - // Verify normalization: (65535+128)/257|0 = 255, (32768+128)/257|0 = 128, (0+128)/257|0 = 0 - expect(putCalls[0][0]).toBe(255); - expect(putCalls[0][1]).toBe(128); - expect(putCalls[0][2]).toBe(0); - expect(putCalls[0][3]).toBe(255); // alpha - spy.mockRestore(); - vi.doUnmock('tiff'); + expect(result).toBeNull(); + expect(decodeImageSpy).not.toHaveBeenCalled(); + vi.doUnmock('utif2'); }); }); - it('normalizes Float32Array pixel data to 8-bit', () => { - // 1x1 RGB with float values; 1.0 → 255, 0.5 → 128, 0.0 → 0 - const floatData = new Float32Array([1.0, 0.5, 0.0]); - vi.doMock('tiff', () => ({ - decode: (_buf, opts) => { - if (opts?.ignoreImageData) return [{ width: 1, height: 1 }]; - return [{ width: 1, height: 1, data: floatData, samplesPerPixel: 3, alpha: false }]; - }, + it('returns null when decode returns empty IFDs', () => { + vi.doMock('utif2', () => ({ + decode: () => [], + decodeImage: () => {}, + toRGBA8: () => new Uint8Array(0), })); - const putCalls = []; - const mockCanvas = { - width: 0, - height: 0, - getContext: () => ({ - createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }), - putImageData: (imageData) => putCalls.push(Array.from(imageData.data)), - }), - toDataURL: () => 'data:image/png;base64,f32', - }; - const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas); - - return import('./tiff-converter.js?float32').then(({ convertTiffToPng: fn }) => { + return import('./tiff-converter.js?emptyIfds').then(({ convertTiffToPng: fn }) => { const result = fn('SU8qAA=='); - expect(result).toEqual({ dataUri: 'data:image/png;base64,f32', format: 'png' }); - // Verify normalization: 1.0 → 255, 0.5 → 128, 0.0 → 0 - expect(putCalls[0][0]).toBe(255); - expect(putCalls[0][1]).toBe(128); - expect(putCalls[0][2]).toBe(0); - expect(putCalls[0][3]).toBe(255); // alpha - spy.mockRestore(); - vi.doUnmock('tiff'); + expect(result).toBeNull(); + vi.doUnmock('utif2'); }); }); - it('returns null for TIFF with dimensions exceeding pixel limit', () => { - // Mock tiff metadata-only decode to return oversized dimensions - // (100k × 10k = 1 billion pixels). The full decode should never be called. - const fullDecode = vi.fn(); - vi.doMock('tiff', () => ({ - decode: (_buf, opts) => { - if (opts?.ignoreImageData) return [{ width: 100_000, height: 10_000 }]; - fullDecode(); - return [{ width: 100_000, height: 10_000, data: new Uint8Array(4), samplesPerPixel: 1, alpha: false }]; - }, + it('returns null when toRGBA8 returns empty data', () => { + vi.doMock('utif2', () => ({ + decode: () => [{ t256: [2], t257: [2] }], + decodeImage: () => {}, + toRGBA8: () => new Uint8Array(0), })); - return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { + return import('./tiff-converter.js?emptyRgba').then(({ convertTiffToPng: fn }) => { const result = fn('SU8qAA=='); expect(result).toBeNull(); - expect(fullDecode).not.toHaveBeenCalled(); - vi.doUnmock('tiff'); + vi.doUnmock('utif2'); }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7988cb4f0e..12b1a2f414 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,9 +237,9 @@ catalogs: semantic-release-linear-app: specifier: ^0.7.1 version: 0.7.1 - tiff: - specifier: ^6.1.1 - version: 6.2.0 + sirv: + specifier: ^3.0.2 + version: 3.0.2 tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -258,6 +258,9 @@ catalogs: unified: specifier: 11.0.5 version: 11.0.5 + utif2: + specifier: ^4.1.0 + version: 4.1.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -312,6 +315,9 @@ importers: .: devDependencies: + '@clack/prompts': + specifier: ^1.0.1 + version: 1.1.0 '@commitlint/cli': specifier: 'catalog:' version: 19.8.1(@types/node@22.19.2)(typescript@5.9.3) @@ -342,6 +348,9 @@ importers: '@vitest/coverage-v8': specifier: 'catalog:' version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.2)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.1))(tsx@4.21.0)(yaml@2.8.2)) + concurrently: + specifier: 'catalog:' + version: 9.2.1 eslint: specifier: 'catalog:' version: 9.39.2(jiti@2.6.1) @@ -415,6 +424,9 @@ importers: fast-glob: specifier: 'catalog:' version: 3.3.3 + happy-dom: + specifier: 20.4.0 + version: 20.4.0 y-websocket: specifier: 'catalog:' version: 3.0.0(yjs@13.6.19) @@ -1111,12 +1123,12 @@ importers: remark-stringify: specifier: 'catalog:' version: 11.0.0 - tiff: - specifier: 'catalog:' - version: 6.2.0 unified: specifier: 'catalog:' version: 11.0.5 + utif2: + specifier: 'catalog:' + version: 4.1.0 uuid: specifier: 'catalog:' version: 9.0.1 @@ -1257,6 +1269,9 @@ importers: '@hocuspocus/server': specifier: 'catalog:' version: 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) + '@superdoc-dev/superdoc-yjs-collaboration': + specifier: workspace:* + version: link:../collaboration-yjs '@superdoc/common': specifier: workspace:* version: link:../../shared/common @@ -1296,6 +1311,9 @@ importers: rollup-plugin-visualizer: specifier: 'catalog:' version: 5.14.0(rollup@4.57.1) + sirv: + specifier: 'catalog:' + version: 3.0.2 typescript: specifier: 'catalog:' version: 5.9.3 @@ -1311,6 +1329,9 @@ importers: vitest: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.8)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.1))(tsx@4.21.0)(yaml@2.8.2) + ws: + specifier: ^8.18.3 + version: 8.19.0 xml-js: specifier: 'catalog:' version: 1.6.11 @@ -1841,9 +1862,15 @@ packages: '@clack/core@1.0.0': resolution: {integrity: sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==} + '@clack/core@1.1.0': + resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + '@clack/prompts@1.0.0': resolution: {integrity: sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==} + '@clack/prompts@1.1.0': + resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -3083,6 +3110,9 @@ packages: resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} engines: {node: '>=12'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -7001,9 +7031,6 @@ packages: resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} engines: {node: '>=12'} - iobuffer@5.4.0: - resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} - ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -8348,6 +8375,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -8871,9 +8902,6 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -10031,6 +10059,10 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -10377,9 +10409,6 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - tiff@6.2.0: - resolution: {integrity: sha512-2oqrUZd1SUmCl1DGoBCWtQb0gVq6A81SoIpuQX4Os/VRA2Waa7ovF1hWzFhVFZkjFSC2B7w24aq6gjqIl7TAVg==} - time-span@5.1.0: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} @@ -10450,6 +10479,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + touch@3.1.1: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true @@ -10821,6 +10854,9 @@ packages: '@types/react': optional: true + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -12106,12 +12142,21 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/core@1.1.0': + dependencies: + sisteransi: 1.0.5 + '@clack/prompts@1.0.0': dependencies: '@clack/core': 1.0.0 picocolors: 1.1.1 sisteransi: 1.0.5 + '@clack/prompts@1.1.0': + dependencies: + '@clack/core': 1.1.0 + sisteransi: 1.0.5 + '@colors/colors@1.5.0': optional: true @@ -13627,6 +13672,8 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@polka/url@1.0.0-next.29': {} + '@popperjs/core@2.11.8': {} '@puppeteer/browsers@2.3.0': @@ -18649,8 +18696,6 @@ snapshots: from2: 2.3.0 p-is-promise: 3.0.0 - iobuffer@5.4.0: {} - ip-address@10.0.1: {} ip-address@10.1.0: {} @@ -20395,6 +20440,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -20812,8 +20859,6 @@ snapshots: pako@1.0.11: {} - pako@2.1.0: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -22426,6 +22471,12 @@ snapshots: dependencies: semver: 7.7.3 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} skin-tone@2.0.0: @@ -22900,11 +22951,6 @@ snapshots: through@2.3.8: {} - tiff@6.2.0: - dependencies: - iobuffer: 5.4.0 - pako: 2.1.0 - time-span@5.1.0: dependencies: convert-hrtime: 5.0.0 @@ -22962,6 +23008,8 @@ snapshots: toidentifier@1.0.1: {} + totalist@3.0.1: {} + touch@3.1.1: {} tough-cookie@5.1.2: @@ -23379,6 +23427,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.11 + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} util@0.12.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ecf08de77..f552aa2169 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -105,7 +105,7 @@ catalog: typescript: ^5.9.2 typescript-eslint: ^8.49.0 unified: 11.0.5 - tiff: ^6.1.1 + utif2: ^4.1.0 uuid: ^9.0.1 verdaccio: ^6.1.6 vite: ^7.2.7 From 1f0dcc57a7cf0cd747ea01ab4f369cee5200839d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 4 Mar 2026 10:47:06 -0300 Subject: [PATCH 12/12] fix: prefer domEnvironment over global document in createCanvas - createCanvas() now checks domEnvironment first, fixing silent failures in JSDOM environments where global document lacks canvas support - Add dataUriToArrayBuffer unit tests covering all input branches and both throw paths - Add explanatory comment for query-string module re-imports in tests --- .../src/core/super-converter/helpers.test.js | 38 +++++++++++++++++++ .../v3/handlers/wp/helpers/tiff-converter.js | 13 ++++--- .../wp/helpers/tiff-converter.test.js | 7 +++- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/helpers.test.js b/packages/super-editor/src/core/super-converter/helpers.test.js index ef890393df..30422776e8 100644 --- a/packages/super-editor/src/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/core/super-converter/helpers.test.js @@ -7,6 +7,7 @@ import { getArrayBufferFromUrl, computeCrc32Hex, base64ToUint8Array, + dataUriToArrayBuffer, detectImageType, } from './helpers.js'; @@ -385,6 +386,43 @@ describe('base64ToUint8Array', () => { }); }); +describe('dataUriToArrayBuffer', () => { + it('returns the same ArrayBuffer when given an ArrayBuffer', () => { + const buf = new ArrayBuffer(4); + expect(dataUriToArrayBuffer(buf)).toBe(buf); + }); + + it('slices a TypedArray into a new ArrayBuffer', () => { + const bytes = new Uint8Array([10, 20, 30, 40]); + const result = dataUriToArrayBuffer(bytes); + expect(result).toBeInstanceOf(ArrayBuffer); + expect(Array.from(new Uint8Array(result))).toEqual([10, 20, 30, 40]); + }); + + it('decodes a data URI string', () => { + const bytes = new Uint8Array([11, 22, 33]); + const base64 = Buffer.from(bytes).toString('base64'); + const result = dataUriToArrayBuffer(`data:image/tiff;base64,${base64}`); + expect(Array.from(new Uint8Array(result))).toEqual([11, 22, 33]); + }); + + it('decodes a raw base64 string', () => { + const bytes = new Uint8Array([55, 66, 77]); + const base64 = Buffer.from(bytes).toString('base64'); + const result = dataUriToArrayBuffer(base64); + expect(Array.from(new Uint8Array(result))).toEqual([55, 66, 77]); + }); + + it('throws on a data URI missing the comma', () => { + expect(() => dataUriToArrayBuffer('data:image/png;base64')).toThrow('Invalid data URI'); + }); + + it('throws on unsupported data types', () => { + expect(() => dataUriToArrayBuffer(12345)).toThrow('Unsupported data type'); + expect(() => dataUriToArrayBuffer({})).toThrow('Unsupported data type'); + }); +}); + describe('detectImageType', () => { it('detects PNG from magic bytes', () => { // PNG signature: 89 50 4E 47 0D 0A 1A 0A diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js index 1c6074425d..cf13e12462 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -40,21 +40,24 @@ export function isTiffExtension(extension) { } /** - * Get a canvas element, trying the global document first, then the domEnvironment. + * Get a canvas element, preferring the injected domEnvironment (if set) over + * the global document. This ensures callers that provide a DOM with canvas + * support (e.g., node-canvas via JSDOM) aren't bypassed by a global document + * whose canvas lacks getContext('2d'). * * @returns {HTMLCanvasElement|null} */ function createCanvas() { - if (typeof document !== 'undefined') { - return document.createElement('canvas'); - } - const env = domEnvironment || {}; const doc = env.document || env.mockDocument || env.window?.document || env.mockWindow?.document || null; if (doc) { return doc.createElement('canvas'); } + if (typeof document !== 'undefined') { + return document.createElement('canvas'); + } + return null; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js index cefaf7b4b5..8cc440dc0c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -60,11 +60,16 @@ describe('tiff-converter', () => { expect(result).toBeNull(); }); + // Query strings (e.g. ?happy) force Vite to re-evaluate the module with the + // mocked utif2 — vi.doMock applies lazily and needs a fresh module graph entry. it('returns a PNG data URI for valid TIFF input', () => { const fakeRgba = new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255]); vi.doMock('utif2', () => ({ decode: () => [{ t256: [2], t257: [2] }], - decodeImage: (_buf, ifd) => { ifd.width = 2; ifd.height = 2; }, + decodeImage: (_buf, ifd) => { + ifd.width = 2; + ifd.height = 2; + }, toRGBA8: () => fakeRgba, }));