Skip to content
Merged
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
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
21 changes: 13 additions & 8 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -131,7 +135,8 @@ class DocxZipper {
if (defaultMediaTypes.includes(type)) continue;
if (seenTypes.has(type)) continue;

const newContentType = `<Default Extension="${type}" ContentType="image/${type}"/>`;
const mime = MIME_TYPE_FOR_EXT[type] || type;
const newContentType = `<Default Extension="${type}" ContentType="image/${mime}"/>`;
typesString += newContentType;
seenTypes.add(type);
}
Expand Down
58 changes: 58 additions & 0 deletions packages/super-editor/src/core/DocxZipper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;
zip.file('[Content_Types].xml', contentTypes);
zip.file('word/document.xml', '<w:document/>');

// 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 = `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;

const docx = [
{ name: '[Content_Types].xml', content: contentTypes },
{ name: 'word/document.xml', content: '<w:document/>' },
];

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();
Expand Down
28 changes: 28 additions & 0 deletions packages/super-editor/src/core/super-converter/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -720,5 +747,6 @@ export {
resolveOpcTargetPath,
computeCrc32Hex,
base64ToUint8Array,
dataUriToArrayBuffer,
detectImageType,
};
38 changes: 38 additions & 0 deletions packages/super-editor/src/core/super-converter/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getArrayBufferFromUrl,
computeCrc32Hex,
base64ToUint8Array,
dataUriToArrayBuffer,
detectImageType,
} from './helpers.js';

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' })),
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 @@ -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.
Expand All @@ -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
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,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({
Expand Down Expand Up @@ -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');
});

Expand Down
Loading
Loading