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');
+});