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..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,19 +69,18 @@ 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']); - // 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)) { - this.mediaFiles[name] = `data:image/${extension};base64,${fileBase64}`; + 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 }); const imageUrl = URL.createObjectURL(fileObj); @@ -105,10 +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', 'emf', 'wmf', 'svg', 'webp']); 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; @@ -131,7 +135,8 @@ class DocxZipper { if (defaultMediaTypes.includes(type)) continue; if (seenTypes.has(type)) continue; - const newContentType = ``; + const mime = MIME_TYPE_FOR_EXT[type] || type; + const newContentType = ``; typesString += newContentType; seenTypes.add(type); } 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/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/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.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 3c03d1f974..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 @@ -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); + 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..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 @@ -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,34 @@ 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('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({ @@ -292,7 +329,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/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 new file mode 100644 index 0000000000..cf13e12462 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js @@ -0,0 +1,119 @@ +/** + * 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 { dataUriToArrayBuffer } 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. + * + * @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, 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() { + 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; +} + +/** + * 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 { + if (typeof data !== 'string') return null; + + const buffer = dataUriToArrayBuffer(data); + + // Decode TIFF — get Image File Directories (pages) + const ifds = UTIF.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; + + // 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 { width, height } = ifds[0]; + + // 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..8cc440dc0c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js @@ -0,0 +1,152 @@ +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(); + }); + + // 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; + }, + 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('SU8qAA=='); + 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 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), + })); + + return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toBeNull(); + expect(decodeImageSpy).not.toHaveBeenCalled(); + vi.doUnmock('utif2'); + }); + }); + + it('returns null when decode returns empty IFDs', () => { + vi.doMock('utif2', () => ({ + decode: () => [], + decodeImage: () => {}, + toRGBA8: () => new Uint8Array(0), + })); + + return import('./tiff-converter.js?emptyIfds').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toBeNull(); + vi.doUnmock('utif2'); + }); + }); + + 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?emptyRgba').then(({ convertTiffToPng: fn }) => { + const result = fn('SU8qAA=='); + expect(result).toBeNull(); + vi.doUnmock('utif2'); + }); + }); + }); + + 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..12b1a2f414 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 @@ -314,7 +317,7 @@ importers: devDependencies: '@clack/prompts': specifier: ^1.0.1 - version: 1.0.1 + version: 1.1.0 '@commitlint/cli': specifier: 'catalog:' version: 19.8.1(@types/node@22.19.2)(typescript@5.9.3) @@ -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 @@ -1856,14 +1862,14 @@ 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/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.0.1': - resolution: {integrity: sha512-/42G73JkuYdyWZ6m8d/CJtBrGl1Hegyc7Fy78m5Ob+jF85TOUmLR5XLce/U3LxYAw0kJ8CT5aI99RIvPHcGp/Q==} + '@clack/prompts@1.1.0': + resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} @@ -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==} @@ -12133,9 +12142,8 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/core@1.0.1': + '@clack/core@1.1.0': dependencies: - picocolors: 1.1.1 sisteransi: 1.0.5 '@clack/prompts@1.0.0': @@ -12144,10 +12152,9 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/prompts@1.0.1': + '@clack/prompts@1.1.0': dependencies: - '@clack/core': 1.0.1 - picocolors: 1.1.1 + '@clack/core': 1.1.0 sisteransi: 1.0.5 '@colors/colors@1.5.0': @@ -23420,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 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 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 0000000000..9e0b78ceff Binary files /dev/null and b/tests/behavior/tests/importing/fixtures/tiff-image.docx differ 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'); +});