Skip to content

Commit 643836a

Browse files
committed
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.
1 parent e76457b commit 643836a

6 files changed

Lines changed: 107 additions & 58 deletions

File tree

packages/super-editor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
"remark-parse": "catalog:",
102102
"remark-stringify": "catalog:",
103103
"unified": "catalog:",
104-
"utif2": "catalog:",
104+
"tiff": "catalog:",
105105
"uuid": "catalog:",
106106
"vue": "catalog:",
107107
"xml-js": "catalog:"

packages/super-editor/src/core/DocxZipper.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { ensureXmlString, isXmlLike } from './encoding-helpers.js';
55
import { DOCX } from '@superdoc/common';
66
import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js';
77

8+
/** Image file extensions recognized during import and export. */
9+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']);
10+
11+
/** Map file extensions to correct MIME sub-types where they differ. */
12+
const MIME_TYPE_FOR_EXT = { tif: 'tiff' };
13+
814
/**
915
* Class to handle unzipping and zipping of docx files
1016
*/
@@ -63,20 +69,17 @@ class DocxZipper {
6369
const fileBase64 = await zipEntry.async('base64');
6470
let extension = this.getFileExtension(name)?.toLowerCase();
6571
// Only build data URIs for images; keep raw base64 for other binaries (e.g., xlsx)
66-
const imageTypes = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']);
67-
const mimeTypeForExt = { tif: 'tiff' };
68-
6972
// For unknown extensions (like .tmp), try to detect the image type from content
7073
let detectedType = null;
71-
if (!imageTypes.has(extension) || extension === 'tmp') {
74+
if (!IMAGE_EXTS.has(extension) || extension === 'tmp') {
7275
detectedType = detectImageType(fileBase64);
7376
if (detectedType) {
7477
extension = detectedType;
7578
}
7679
}
7780

78-
if (imageTypes.has(extension)) {
79-
const mimeSubtype = mimeTypeForExt[extension] || extension;
81+
if (IMAGE_EXTS.has(extension)) {
82+
const mimeSubtype = MIME_TYPE_FOR_EXT[extension] || extension;
8083
this.mediaFiles[name] = `data:image/${mimeSubtype};base64,${fileBase64}`;
8184
const blob = await zipEntry.async('blob');
8285
const fileObj = new File([blob], name, { type: blob.type });
@@ -107,11 +110,9 @@ class DocxZipper {
107110
*/
108111
async updateContentTypes(docx, media, fromJson, updatedDocs = {}) {
109112
const additionalPartNames = Object.keys(updatedDocs || {});
110-
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']);
111-
const mimeTypeForExt = { tif: 'tiff' };
112113
const newMediaTypes = Object.keys(media)
113114
.map((name) => this.getFileExtension(name))
114-
.filter((ext) => ext && imageExts.has(ext));
115+
.filter((ext) => ext && IMAGE_EXTS.has(ext));
115116

116117
const contentTypesPath = '[Content_Types].xml';
117118
let contentTypesXml;
@@ -134,7 +135,7 @@ class DocxZipper {
134135
if (defaultMediaTypes.includes(type)) continue;
135136
if (seenTypes.has(type)) continue;
136137

137-
const mime = mimeTypeForExt[type] || type;
138+
const mime = MIME_TYPE_FOR_EXT[type] || type;
138139
const newContentType = `<Default Extension="${type}" ContentType="image/${mime}"/>`;
139140
typesString += newContentType;
140141
seenTypes.add(type);

packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.js

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
/**
22
* TIFF to PNG Converter
33
*
4-
* Converts TIFF images to PNG format using utif2 for decoding and Canvas for
5-
* encoding. Browsers cannot natively render TIFF images, so this converts them
6-
* at import time to a browser-friendly format.
4+
* Converts TIFF images to PNG format using the `tiff` package (image-js/tiff)
5+
* for decoding and Canvas for encoding. Browsers cannot natively render TIFF
6+
* images, so this converts them at import time to a browser-friendly format.
77
*
88
* @module tiff-converter
99
*/
1010

11-
import * as UTIF from 'utif2';
11+
import { decode } from 'tiff';
1212
import { base64ToUint8Array } from '../../../../helpers.js';
1313

1414
// Optional DOM environment provided by callers (e.g., JSDOM in Node)
@@ -58,6 +58,54 @@ function createCanvas() {
5858
return null;
5959
}
6060

61+
/**
62+
* Convert decoded pixel data to RGBA format.
63+
* The `tiff` package returns pixel data whose channel count depends on the
64+
* image (greyscale=1, grey+alpha=2, RGB=3, RGBA=4). Canvas requires RGBA.
65+
*
66+
* @param {Uint8Array} data - Decoded pixel data
67+
* @param {number} samplesPerPixel - Number of channels per pixel
68+
* @param {boolean} hasAlpha - Whether the image has an alpha channel
69+
* @param {number} pixelCount - Total number of pixels (width × height)
70+
* @returns {Uint8Array} RGBA pixel data
71+
*/
72+
function toRGBA(data, samplesPerPixel, hasAlpha, pixelCount) {
73+
if (samplesPerPixel === 4 && hasAlpha) return data;
74+
75+
const rgba = new Uint8Array(pixelCount * 4);
76+
77+
if (samplesPerPixel === 3) {
78+
// RGB → RGBA
79+
for (let i = 0; i < pixelCount; i++) {
80+
rgba[i * 4] = data[i * 3];
81+
rgba[i * 4 + 1] = data[i * 3 + 1];
82+
rgba[i * 4 + 2] = data[i * 3 + 2];
83+
rgba[i * 4 + 3] = 255;
84+
}
85+
} else if (samplesPerPixel === 2 && hasAlpha) {
86+
// Grey + Alpha → RGBA
87+
for (let i = 0; i < pixelCount; i++) {
88+
const g = data[i * 2];
89+
rgba[i * 4] = g;
90+
rgba[i * 4 + 1] = g;
91+
rgba[i * 4 + 2] = g;
92+
rgba[i * 4 + 3] = data[i * 2 + 1];
93+
}
94+
} else if (samplesPerPixel === 1) {
95+
// Greyscale → RGBA
96+
for (let i = 0; i < pixelCount; i++) {
97+
rgba[i * 4] = data[i];
98+
rgba[i * 4 + 1] = data[i];
99+
rgba[i * 4 + 2] = data[i];
100+
rgba[i * 4 + 3] = 255;
101+
}
102+
} else {
103+
return null;
104+
}
105+
106+
return rgba;
107+
}
108+
61109
/**
62110
* Converts a TIFF image to a PNG data URI.
63111
*
@@ -80,22 +128,19 @@ export function convertTiffToPng(data) {
80128
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
81129

82130
// Decode TIFF — get Image File Directories (pages)
83-
const ifds = UTIF.decode(buffer);
131+
const ifds = decode(buffer);
84132
if (!ifds || ifds.length === 0) return null;
85133

86-
// Validate dimensions from raw IFD tags before decoding pixel data.
87-
// UTIF.decode populates tag entries (t256=ImageWidth, t257=ImageLength)
88-
// but .width/.height are only set after decodeImage.
89-
const ifdWidth = ifds[0].t256?.[0];
90-
const ifdHeight = ifds[0].t257?.[0];
91-
if (!ifdWidth || !ifdHeight || ifdWidth * ifdHeight > MAX_PIXEL_COUNT) return null;
134+
const ifd = ifds[0];
135+
const { width, height } = ifd;
136+
if (!width || !height || width * height > MAX_PIXEL_COUNT) return null;
92137

93-
// Decode pixel data for the first page
94-
UTIF.decodeImage(buffer, ifds[0]);
95-
const rgba = UTIF.toRGBA8(ifds[0]);
96-
if (!rgba || rgba.length === 0) return null;
138+
const pixelData = ifd.data;
139+
if (!pixelData || pixelData.length === 0) return null;
97140

98-
const { width, height } = ifds[0];
141+
const samplesPerPixel = ifd.samplesPerPixel ?? (ifd.alpha ? 2 : 1);
142+
const rgba = toRGBA(pixelData, samplesPerPixel, ifd.alpha, width * height);
143+
if (!rgba) return null;
99144

100145
// Render to canvas and export as PNG
101146
const canvas = createCanvas();
@@ -111,7 +156,7 @@ export function convertTiffToPng(data) {
111156
if (!ctx) return null;
112157

113158
const imageData = ctx.createImageData(width, height);
114-
imageData.data.set(new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength));
159+
imageData.data.set(rgba);
115160
ctx.putImageData(imageData, 0, 0);
116161

117162
const dataUri = canvas.toDataURL('image/png');

packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/tiff-converter.test.js

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,9 @@ describe('tiff-converter', () => {
6161
});
6262

6363
it('returns a PNG data URI for valid TIFF input', () => {
64-
const fakeRgba = new Uint8Array(2 * 2 * 4); // 2x2 image, RGBA
65-
vi.doMock('utif2', () => ({
66-
decode: () => [{ t256: [2], t257: [2] }],
67-
decodeImage: (_buf, ifd) => {
68-
ifd.width = 2;
69-
ifd.height = 2;
70-
},
71-
toRGBA8: () => fakeRgba,
64+
const fakePixelData = new Uint8Array(2 * 2 * 3); // 2x2 RGB image
65+
vi.doMock('tiff', () => ({
66+
decode: () => [{ width: 2, height: 2, data: fakePixelData, samplesPerPixel: 3, alpha: false }],
7267
}));
7368

7469
const mockCanvas = {
@@ -88,24 +83,21 @@ describe('tiff-converter', () => {
8883
expect(result).toEqual({ dataUri: 'data:image/png;base64,iVBORw0KGgo=', format: 'png' });
8984

9085
spy.mockRestore();
91-
vi.doUnmock('utif2');
86+
vi.doUnmock('tiff');
9287
});
9388
});
9489

9590
it('returns null for TIFF with dimensions exceeding pixel limit', () => {
96-
// Mock utif2 to return oversized dimensions via raw IFD tags
97-
// (t256=ImageWidth, t257=ImageLength — 100k × 10k = 1 billion pixels)
98-
vi.doMock('utif2', () => ({
99-
decode: () => [{ t256: [100_000], t257: [10_000] }],
100-
decodeImage: () => undefined,
101-
toRGBA8: () => new Uint8Array(4),
91+
// Mock tiff to return oversized dimensions (100k × 10k = 1 billion pixels)
92+
vi.doMock('tiff', () => ({
93+
decode: () => [{ width: 100_000, height: 10_000, data: new Uint8Array(4), samplesPerPixel: 1, alpha: false }],
10294
}));
10395

10496
// Re-import to pick up the mock
10597
return import('./tiff-converter.js?oversized').then(({ convertTiffToPng: fn }) => {
10698
const result = fn('SU8qAA==');
10799
expect(result).toBeNull();
108-
vi.doUnmock('utif2');
100+
vi.doUnmock('tiff');
109101
});
110102
});
111103
});

pnpm-lock.yaml

Lines changed: 24 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ catalog:
104104
typescript: ^5.9.2
105105
typescript-eslint: ^8.49.0
106106
unified: 11.0.5
107-
utif2: ^4.1.0
107+
tiff: ^6.1.1
108108
uuid: ^9.0.1
109109
verdaccio: ^6.1.6
110110
vite: ^7.2.7

0 commit comments

Comments
 (0)