Skip to content

Commit 4b5c709

Browse files
committed
test: add TIFF MIME, fallback, and branch coverage tests; extract shared dataUriToArrayBuffer helper
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.
1 parent 10d064e commit 4b5c709

8 files changed

Lines changed: 259 additions & 63 deletions

File tree

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,64 @@ describe('DocxZipper - exportFromCollaborativeDocx media handling', () => {
338338
});
339339
});
340340

341+
describe('DocxZipper - .tif MIME type mapping', () => {
342+
it('produces image/tiff data URI for .tif files on import', async () => {
343+
const zipper = new DocxZipper();
344+
const zip = new JSZip();
345+
346+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
347+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
348+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
349+
<Default Extension="xml" ContentType="application/xml"/>
350+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
351+
</Types>`;
352+
zip.file('[Content_Types].xml', contentTypes);
353+
zip.file('word/document.xml', '<w:document/>');
354+
355+
// Arbitrary binary data stored as a .tif file
356+
const tifData = new Uint8Array([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
357+
zip.file('word/media/image1.tif', tifData);
358+
359+
const buf = await zip.generateAsync({ type: 'arraybuffer' });
360+
await zipper.getDocxData(buf, false);
361+
362+
// Must use image/tiff, not image/tif
363+
expect(zipper.mediaFiles['word/media/image1.tif']).toMatch(/^data:image\/tiff;base64,/);
364+
});
365+
366+
it('writes image/tiff content type in [Content_Types].xml on export', async () => {
367+
const zipper = new DocxZipper();
368+
369+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
370+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
371+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
372+
<Default Extension="xml" ContentType="application/xml"/>
373+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
374+
</Types>`;
375+
376+
const docx = [
377+
{ name: '[Content_Types].xml', content: contentTypes },
378+
{ name: 'word/document.xml', content: '<w:document/>' },
379+
];
380+
381+
const result = await zipper.updateZip({
382+
docx,
383+
updatedDocs: {},
384+
media: { 'word/media/image1.tif': 'AAAA' },
385+
fonts: {},
386+
isHeadless: true,
387+
});
388+
389+
const readBack = await new JSZip().loadAsync(result);
390+
const updatedContentTypes = await readBack.file('[Content_Types].xml').async('string');
391+
392+
// Should contain Extension="tif" with ContentType="image/tiff"
393+
expect(updatedContentTypes).toContain('Extension="tif"');
394+
expect(updatedContentTypes).toContain('ContentType="image/tiff"');
395+
expect(updatedContentTypes).not.toContain('ContentType="image/tif"');
396+
});
397+
});
398+
341399
describe('DocxZipper - .tmp image file detection', () => {
342400
it('detects and processes .tmp files with PNG signatures as PNG images', async () => {
343401
const zipper = new DocxZipper();

packages/super-editor/src/core/super-converter/helpers.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ function base64ToUint8Array(base64) {
3333
return bytes;
3434
}
3535

36+
/**
37+
* Convert a base64 string or data URI to an ArrayBuffer.
38+
* Accepts ArrayBuffer, TypedArray, data URI, or raw base64 string.
39+
*
40+
* @param {string|ArrayBuffer|Uint8Array} data
41+
* @returns {ArrayBuffer}
42+
*/
43+
function dataUriToArrayBuffer(data) {
44+
if (data instanceof ArrayBuffer) return data;
45+
if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
46+
47+
if (typeof data !== 'string') {
48+
throw new Error('Unsupported data type for conversion to ArrayBuffer');
49+
}
50+
51+
let base64 = data;
52+
if (data.startsWith('data:')) {
53+
const commaIndex = data.indexOf(',');
54+
if (commaIndex === -1) {
55+
throw new Error('Invalid data URI: missing base64 content');
56+
}
57+
base64 = data.substring(commaIndex + 1);
58+
}
59+
60+
return base64ToUint8Array(base64).buffer;
61+
}
62+
3663
// CSS pixels per inch; used to convert between Word's inch-based measurements and DOM pixels.
3764
const PIXELS_PER_INCH = 96;
3865

@@ -720,5 +747,6 @@ export {
720747
resolveOpcTargetPath,
721748
computeCrc32Hex,
722749
base64ToUint8Array,
750+
dataUriToArrayBuffer,
723751
detectImageType,
724752
};

packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ vi.mock('@converter/v2/importer/index.js', () => ({
1313
}));
1414

1515
// Simple and predictable conversion for positions
16-
vi.mock('@converter/helpers.js', () => ({
17-
twipsToPixels: (twips) => (twips === undefined ? undefined : Number(twips) / 20),
18-
twipsToInches: (twips) => (twips === undefined ? undefined : Number(twips) / 10),
19-
twipsToLines: (twips) => (twips === undefined ? undefined : Number(twips) / 240),
20-
pixelsToTwips: (pixels) => (pixels === undefined ? undefined : Math.round(Number(pixels) * 20)),
21-
}));
16+
vi.mock('@converter/helpers.js', async (importOriginal) => {
17+
const actual = await importOriginal();
18+
return {
19+
...actual,
20+
twipsToPixels: (twips) => (twips === undefined ? undefined : Number(twips) / 20),
21+
twipsToInches: (twips) => (twips === undefined ? undefined : Number(twips) / 10),
22+
twipsToLines: (twips) => (twips === undefined ? undefined : Number(twips) / 240),
23+
pixelsToTwips: (pixels) => (pixels === undefined ? undefined : Math.round(Number(pixels) * 20)),
24+
};
25+
});
2226

2327
import { handleParagraphNode } from './legacy-handle-paragraph-node.js';
2428
import { parseMarks, mergeTextNodes } from '@converter/v2/importer/index.js';

packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@ import {
55
import * as helpers from '@converter/helpers.js';
66
import * as annotationHelpers from '@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js';
77

8-
vi.mock('@converter/helpers.js', () => ({
9-
emuToPixels: vi.fn((v) => v / 9525), // 1 emu ≈ 1/9525 px
10-
pixelsToEmu: vi.fn((v) => v * 9525),
11-
getTextIndentExportValue: vi.fn((v) => v),
12-
inchesToTwips: vi.fn((v) => v),
13-
linesToTwips: vi.fn((v) => v),
14-
pixelsToEightPoints: vi.fn((v) => v),
15-
pixelsToTwips: vi.fn((v) => v),
16-
ptToTwips: vi.fn((v) => v),
17-
rgbToHex: vi.fn(() => '#000000'),
18-
degreesToRot: vi.fn((v) => v),
19-
}));
8+
vi.mock('@converter/helpers.js', async (importOriginal) => {
9+
const actual = await importOriginal();
10+
return {
11+
...actual,
12+
emuToPixels: vi.fn((v) => v / 9525), // 1 emu ≈ 1/9525 px
13+
pixelsToEmu: vi.fn((v) => v * 9525),
14+
getTextIndentExportValue: vi.fn((v) => v),
15+
inchesToTwips: vi.fn((v) => v),
16+
linesToTwips: vi.fn((v) => v),
17+
pixelsToEightPoints: vi.fn((v) => v),
18+
pixelsToTwips: vi.fn((v) => v),
19+
ptToTwips: vi.fn((v) => v),
20+
rgbToHex: vi.fn(() => '#000000'),
21+
degreesToRot: vi.fn((v) => v),
22+
};
23+
});
2024

2125
vi.mock('@converter/v3/handlers/w/sdt/helpers/translate-field-annotation.js', () => ({
2226
prepareTextAnnotation: vi.fn(() => ({ type: 'text', text: 'annotation' })),

packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,20 @@ describe('handleImageNode', () => {
244244
expect(result.attrs.extension).toBe('png');
245245
});
246246

247+
it('returns alt text when convertTiffToPng returns null', () => {
248+
convertTiffToPng.mockReturnValue(null);
249+
const node = makeNode();
250+
const params = {
251+
...makeParams('media/photo.tif'),
252+
converter: { media: { 'word/media/photo.tif': 'data:image/tiff;base64,AAAA' } },
253+
};
254+
const result = handleImageNode(node, params, false);
255+
256+
expect(convertTiffToPng).toHaveBeenCalledWith('data:image/tiff;base64,AAAA');
257+
expect(result.attrs.alt).toBe('Unable to render image');
258+
expect(result.attrs.extension).toBe('tif');
259+
});
260+
247261
it('captures unhandled drawing children for passthrough preservation', () => {
248262
const node = makeNode();
249263
node.elements.push({

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

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
/* global btoa, XMLSerializer */
1616

1717
import { EMFJS, WMFJS } from './rtfjs';
18-
import { base64ToUint8Array } from '../../../../helpers.js';
18+
import { dataUriToArrayBuffer } from '../../../../helpers.js';
1919

2020
// Disable verbose logging from the renderers
2121
EMFJS.loggingEnabled(false);
@@ -74,39 +74,8 @@ const MM_ANISOTROPIC = 8;
7474
const EMF_SIGNATURE = 0x464d4520; // ' EMF'
7575
const EMF_PLUS_SIGNATURE = 0x2b464d45; // 'EMF+' inside EMR_COMMENT
7676

77-
/**
78-
* Converts input data to an ArrayBuffer.
79-
*
80-
* Accepts:
81-
* - ArrayBuffer
82-
* - TypedArray / Buffer
83-
* - base64 string or data URI
84-
*
85-
* @param {string|ArrayBuffer|Uint8Array} data
86-
* @returns {ArrayBuffer}
87-
*/
88-
function base64ToArrayBuffer(data) {
89-
if (data instanceof ArrayBuffer) return data;
90-
if (ArrayBuffer.isView(data)) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
91-
92-
if (typeof data !== 'string') {
93-
throw new Error('Unsupported data type for conversion to ArrayBuffer');
94-
}
95-
96-
// Handle both data URI format and raw base64
97-
let base64 = data;
98-
99-
// Check if it's a data URI and extract the base64 portion
100-
if (data.startsWith('data:')) {
101-
const commaIndex = data.indexOf(',');
102-
if (commaIndex === -1) {
103-
throw new Error('Invalid data URI: missing base64 content');
104-
}
105-
base64 = data.substring(commaIndex + 1);
106-
}
107-
108-
return base64ToUint8Array(base64).buffer;
109-
}
77+
// Re-export for local use — shared implementation lives in ../../../../helpers.js
78+
const base64ToArrayBuffer = dataUriToArrayBuffer;
11079

11180
/**
11281
* Encodes a Uint8Array into base64 using chunked processing to avoid call stack overflows.

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { decode } from 'tiff';
12-
import { base64ToUint8Array } from '../../../../helpers.js';
12+
import { dataUriToArrayBuffer } from '../../../../helpers.js';
1313

1414
// Optional DOM environment provided by callers (e.g., JSDOM in Node)
1515
let domEnvironment = null;
@@ -116,16 +116,7 @@ export function convertTiffToPng(data) {
116116
try {
117117
if (typeof data !== 'string') return null;
118118

119-
// Parse input — accept data URI or raw base64
120-
let base64 = data;
121-
if (data.startsWith('data:')) {
122-
const commaIndex = data.indexOf(',');
123-
if (commaIndex === -1) return null;
124-
base64 = data.substring(commaIndex + 1);
125-
}
126-
const bytes = base64ToUint8Array(base64);
127-
128-
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
119+
const buffer = dataUriToArrayBuffer(data);
129120

130121
// Read metadata first without decompressing pixel data so the size
131122
// guard fires before a huge RGBA buffer is allocated.

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,134 @@ describe('tiff-converter', () => {
9090
});
9191
});
9292

93+
it('converts a greyscale (1 channel) TIFF to PNG', () => {
94+
// 2x2 greyscale: pixel values [50, 100, 150, 200]
95+
const greyData = new Uint8Array([50, 100, 150, 200]);
96+
vi.doMock('tiff', () => ({
97+
decode: (_buf, opts) => {
98+
if (opts?.ignoreImageData) return [{ width: 2, height: 2 }];
99+
return [{ width: 2, height: 2, data: greyData, samplesPerPixel: 1, alpha: false }];
100+
},
101+
}));
102+
103+
const mockCanvas = {
104+
width: 0,
105+
height: 0,
106+
getContext: () => ({
107+
createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }),
108+
putImageData: () => {},
109+
}),
110+
toDataURL: () => 'data:image/png;base64,grey',
111+
};
112+
const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
113+
114+
return import('./tiff-converter.js?grey').then(({ convertTiffToPng: fn }) => {
115+
const result = fn('SU8qAA==');
116+
expect(result).toEqual({ dataUri: 'data:image/png;base64,grey', format: 'png' });
117+
spy.mockRestore();
118+
vi.doUnmock('tiff');
119+
});
120+
});
121+
122+
it('converts a grey+alpha (2 channel) TIFF to PNG', () => {
123+
// 2x1 grey+alpha: [grey, alpha, grey, alpha]
124+
const greyAlphaData = new Uint8Array([128, 255, 64, 128]);
125+
vi.doMock('tiff', () => ({
126+
decode: (_buf, opts) => {
127+
if (opts?.ignoreImageData) return [{ width: 2, height: 1 }];
128+
return [{ width: 2, height: 1, data: greyAlphaData, samplesPerPixel: 2, alpha: true }];
129+
},
130+
}));
131+
132+
const mockCanvas = {
133+
width: 0,
134+
height: 0,
135+
getContext: () => ({
136+
createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }),
137+
putImageData: () => {},
138+
}),
139+
toDataURL: () => 'data:image/png;base64,greyAlpha',
140+
};
141+
const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
142+
143+
return import('./tiff-converter.js?greyAlpha').then(({ convertTiffToPng: fn }) => {
144+
const result = fn('SU8qAA==');
145+
expect(result).toEqual({ dataUri: 'data:image/png;base64,greyAlpha', format: 'png' });
146+
spy.mockRestore();
147+
vi.doUnmock('tiff');
148+
});
149+
});
150+
151+
it('normalizes Uint16Array pixel data to 8-bit', () => {
152+
// 1x1 RGB with 16-bit values; 65535 → 255, 32768 → 128, 0 → 0
153+
const uint16Data = new Uint16Array([65535, 32768, 0]);
154+
vi.doMock('tiff', () => ({
155+
decode: (_buf, opts) => {
156+
if (opts?.ignoreImageData) return [{ width: 1, height: 1 }];
157+
return [{ width: 1, height: 1, data: uint16Data, samplesPerPixel: 3, alpha: false }];
158+
},
159+
}));
160+
161+
const putCalls = [];
162+
const mockCanvas = {
163+
width: 0,
164+
height: 0,
165+
getContext: () => ({
166+
createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }),
167+
putImageData: (imageData) => putCalls.push(Array.from(imageData.data)),
168+
}),
169+
toDataURL: () => 'data:image/png;base64,u16',
170+
};
171+
const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
172+
173+
return import('./tiff-converter.js?uint16').then(({ convertTiffToPng: fn }) => {
174+
const result = fn('SU8qAA==');
175+
expect(result).toEqual({ dataUri: 'data:image/png;base64,u16', format: 'png' });
176+
// Verify normalization: (65535+128)/257|0 = 255, (32768+128)/257|0 = 128, (0+128)/257|0 = 0
177+
expect(putCalls[0][0]).toBe(255);
178+
expect(putCalls[0][1]).toBe(128);
179+
expect(putCalls[0][2]).toBe(0);
180+
expect(putCalls[0][3]).toBe(255); // alpha
181+
spy.mockRestore();
182+
vi.doUnmock('tiff');
183+
});
184+
});
185+
186+
it('normalizes Float32Array pixel data to 8-bit', () => {
187+
// 1x1 RGB with float values; 1.0 → 255, 0.5 → 128, 0.0 → 0
188+
const floatData = new Float32Array([1.0, 0.5, 0.0]);
189+
vi.doMock('tiff', () => ({
190+
decode: (_buf, opts) => {
191+
if (opts?.ignoreImageData) return [{ width: 1, height: 1 }];
192+
return [{ width: 1, height: 1, data: floatData, samplesPerPixel: 3, alpha: false }];
193+
},
194+
}));
195+
196+
const putCalls = [];
197+
const mockCanvas = {
198+
width: 0,
199+
height: 0,
200+
getContext: () => ({
201+
createImageData: (w, h) => ({ data: new Uint8Array(w * h * 4), width: w, height: h }),
202+
putImageData: (imageData) => putCalls.push(Array.from(imageData.data)),
203+
}),
204+
toDataURL: () => 'data:image/png;base64,f32',
205+
};
206+
const spy = vi.spyOn(document, 'createElement').mockReturnValue(mockCanvas);
207+
208+
return import('./tiff-converter.js?float32').then(({ convertTiffToPng: fn }) => {
209+
const result = fn('SU8qAA==');
210+
expect(result).toEqual({ dataUri: 'data:image/png;base64,f32', format: 'png' });
211+
// Verify normalization: 1.0 → 255, 0.5 → 128, 0.0 → 0
212+
expect(putCalls[0][0]).toBe(255);
213+
expect(putCalls[0][1]).toBe(128);
214+
expect(putCalls[0][2]).toBe(0);
215+
expect(putCalls[0][3]).toBe(255); // alpha
216+
spy.mockRestore();
217+
vi.doUnmock('tiff');
218+
});
219+
});
220+
93221
it('returns null for TIFF with dimensions exceeding pixel limit', () => {
94222
// Mock tiff metadata-only decode to return oversized dimensions
95223
// (100k × 10k = 1 billion pixels). The full decode should never be called.

0 commit comments

Comments
 (0)