Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/document-api/src/inline-semantics/token-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
import type { CoreTogglePropertyId } from './property-ids.js';
import type { InvalidInlineTokenError, InvalidInlineTokenToggle, InvalidInlineTokenUnderline } from './error-types.js';
import type { DirectState } from './directives.js';
import { ST_ON_OFF_VALUE_SET, ST_ON_OFF_ON_VALUES, ST_ON_OFF_OFF_VALUES, ST_UNDERLINE_VALUE_SET, ST_THEME_COLOR_VALUE_SET } from './token-sets.js';
import {
ST_ON_OFF_VALUE_SET,

Check warning on line 13 in packages/document-api/src/inline-semantics/token-parsers.ts

View workflow job for this annotation

GitHub Actions / validate

'ST_ON_OFF_VALUE_SET' is defined but never used. Allowed unused vars must match /^_/u
ST_ON_OFF_ON_VALUES,
ST_ON_OFF_OFF_VALUES,
ST_UNDERLINE_VALUE_SET,
ST_THEME_COLOR_VALUE_SET,
} from './token-sets.js';

// ---------------------------------------------------------------------------
// Result types
Expand Down
1 change: 1 addition & 0 deletions packages/super-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"remark-parse": "catalog:",
"remark-stringify": "catalog:",
"unified": "catalog:",
"utif2": "catalog:",
"uuid": "catalog:",
"vue": "catalog:",
"xml-js": "catalog:"
Expand Down
12 changes: 8 additions & 4 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ 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']);
const mimeTypeForExt = { tif: 'tiff' };

// For unknown extensions (like .tmp), try to detect the image type from content
let detectedType = null;
Expand All @@ -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);
Expand Down Expand Up @@ -105,7 +107,8 @@ 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 mimeTypeForExt = { tif: 'tiff' };
const newMediaTypes = Object.keys(media)
.map((name) => this.getFileExtension(name))
.filter((ext) => ext && imageExts.has(ext));
Expand All @@ -131,7 +134,8 @@ class DocxZipper {
if (defaultMediaTypes.includes(type)) continue;
if (seenTypes.has(type)) continue;

const newContentType = `<Default Extension="${type}" ContentType="image/${type}"/>`;
const mime = mimeTypeForExt[type] || type;
const newContentType = `<Default Extension="${type}" ContentType="image/${mime}"/>`;
typesString += newContentType;
seenTypes.add(type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -404,6 +405,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.
Expand All @@ -415,8 +432,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -292,7 +315,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');
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* 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;

// 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, 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 {
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);

// 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
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;
}
}
Loading
Loading