diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js index bf9f1ff793..a0d5d46a8a 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js @@ -1,9 +1,14 @@ /** - * EMF/WMF to SVG Converter + * EMF/WMF to browser-renderable image converter. * - * Converts Windows Enhanced Metafile (EMF) and Windows Metafile (WMF) images - * to SVG format. The converted SVG is returned as a data URI that can be used - * as an image source. + * Converts Windows Enhanced Metafile (EMF/EMF+) and Windows Metafile (WMF) images + * into a format browsers can render, returned as a data URI plus short format tag. + * Strategy, in order of preference: + * 1. Embedded bitmap fast paths (no rasterization required): + * - Classic EMR_STRETCHDIBITS DIB → BMP + * - EmfPlusObject(Image) compressed bitmap → original PNG/JPEG/GIF + * 2. Vector rasterization via the rtf.js renderer → SVG (classic EMF/WMF only) + * 3. Placeholder SVG when an EMF+ payload uses GDI+ records we can't render * * EMF/WMF rendering code extracted from rtf.js (MIT License) * Original: https://github.com/nicktf/rtf.js @@ -74,6 +79,21 @@ const MM_ANISOTROPIC = 8; const EMF_SIGNATURE = 0x464d4520; // ' EMF' const EMF_PLUS_SIGNATURE = 0x2b464d45; // 'EMF+' inside EMR_COMMENT +// Classic EMR record type for comments (MS-EMF § 2.3.3.1) +const EMR_COMMENT = 70; + +// EMF+ record types (MS-EMFPLUS § 2.1.1.1) +const EMF_PLUS_OBJECT = 0x4008; + +// EMF+ object types encoded in EmfPlusObject Flags bits 8–14 (MS-EMFPLUS § 2.1.1.21) +const EMF_PLUS_OBJECT_TYPE_IMAGE = 5; + +// EmfPlusImage Type field (MS-EMFPLUS § 2.2.1.4) +const EMF_PLUS_IMAGE_TYPE_BITMAP = 1; + +// EmfPlusBitmap Type field (MS-EMFPLUS § 2.2.2.2) +const EMF_PLUS_BITMAP_TYPE_COMPRESSED = 1; + // Re-export for local use — shared implementation lives in ../../../../helpers.js const base64ToArrayBuffer = dataUriToArrayBuffer; @@ -95,6 +115,218 @@ function uint8ToBase64(bytes) { return btoa(binary); } +/** + * Detect a compressed image format (PNG/JPEG/GIF) from its leading bytes and return + * the matching MIME type and short extension. + * + * @param {Uint8Array} bytes + * @returns {{ mime: string, format: string } | null} + */ +function detectCompressedImageFormat(bytes) { + if (!bytes || bytes.length < 4) return null; + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return { mime: 'image/png', format: 'png' }; + } + + // JPEG: FF D8 FF + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return { mime: 'image/jpeg', format: 'jpeg' }; + } + + // GIF: 'GIF8' + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + return { mime: 'image/gif', format: 'gif' }; + } + + return null; +} + +/** + * Concatenate a list of Uint8Arrays into a single Uint8Array. + * + * @param {Uint8Array[]} parts + * @returns {Uint8Array} + */ +function concatBytes(parts) { + let totalLength = 0; + for (const part of parts) totalLength += part.byteLength; + + const result = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +/** + * Parse the body of an EmfPlusObject(Image) record and, if it carries a compressed + * bitmap (PNG/JPEG/GIF), return it as a data URI. Pure-pixel and metafile image + * variants are rejected because they require a full GDI+ rasterizer. + * + * Layout (MS-EMFPLUS § 2.2.1.4 EmfPlusImage + § 2.2.2.2 EmfPlusBitmap): + * 0: Version (4 bytes, ignored) + * 4: Type (4 bytes) — 1 = Bitmap, 2 = Metafile + * For Bitmap: + * 8: Width (4 bytes, ignored) + * 12: Height (4 bytes, ignored) + * 16: Stride (4 bytes, ignored) + * 20: PixelFormat (4 bytes, ignored) + * 24: Type (4 bytes) — 1 = Compressed + * 28: BitmapData — encoded PNG/JPEG/GIF bytes when Type = Compressed + * + * @param {Uint8Array} bytes - EmfPlusImage object data + * @returns {{ dataUri: string, format: string } | null} + */ +function parseEmfPlusImageObject(bytes) { + if (!bytes || bytes.byteLength < 28) return null; + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const imageType = view.getUint32(4, true); + if (imageType !== EMF_PLUS_IMAGE_TYPE_BITMAP) return null; + + const bitmapType = view.getUint32(24, true); + if (bitmapType !== EMF_PLUS_BITMAP_TYPE_COMPRESSED) return null; + + const compressed = bytes.subarray(28); + const formatInfo = detectCompressedImageFormat(compressed); + if (!formatInfo) return null; + + return { + dataUri: `data:${formatInfo.mime};base64,${uint8ToBase64(compressed)}`, + format: formatInfo.format, + }; +} + +/** + * Some EMF files (notably PowerPoint cover slides and Office charts) carry their visual + * payload as a compressed bitmap embedded inside an EmfPlusObject(Image) record rather + * than as classic GDI records. Extract that bitmap so it can be rendered without + * implementing a full GDI+ renderer. + * + * Walks the outer EMF stream looking for EMR_COMMENT records carrying EMF+ data, then + * walks the inner EMF+ records for EmfPlusObject(Image) entries. Continued objects + * (Flags.ContinueBit set) are reassembled by ObjectId. Per MS-EMFPLUS § 2.3.5.1: + * - When ContinueBit=1, the record header is 16 bytes and includes a TotalObjectSize + * field at offset 8 between Size and DataSize. ObjectData starts at offset 16. + * - When ContinueBit=0, the record header is the standard 12 bytes. ObjectData + * starts at offset 12, and this record is either standalone or the final chunk + * of a continuation series (in which case the buffered chunks are reassembled). + * + * @param {ArrayBuffer} buffer + * @returns {{ dataUri: string, format: string } | null} + */ +function extractBitmapFromEmfPlus(buffer) { + const view = new DataView(buffer); + if (view.byteLength < 108) return null; + + const type = view.getUint32(0, true); + const headerSize = view.getUint32(4, true); + const signature = view.getUint32(40, true); + if (type !== 1 || signature !== EMF_SIGNATURE) return null; + if (headerSize <= 0 || headerSize >= view.byteLength) return null; + + const pendingByObjectId = new Map(); + + let offset = headerSize; + while (offset + 8 <= view.byteLength) { + const recordType = view.getUint32(offset, true); + const recordSize = view.getUint32(offset + 4, true); + if (recordSize < 8 || offset + recordSize > view.byteLength) break; + + if (recordType === EMR_COMMENT && recordSize >= 16) { + const dataSize = view.getUint32(offset + 8, true); + // EMR_COMMENT layout: Type (4) | Size (4) | DataSize (4) | Data (DataSize bytes). + // The CommentIdentifier is the first 4 bytes of Data; EMF+ records follow it. + if (dataSize >= 4 && dataSize <= recordSize - 12) { + const identifier = view.getUint32(offset + 12, true); + if (identifier === EMF_PLUS_SIGNATURE) { + const emfPlusStart = offset + 16; + const emfPlusEnd = offset + 12 + dataSize; + + let pos = emfPlusStart; + while (pos + 12 <= emfPlusEnd) { + const epType = view.getUint16(pos, true); + const epFlags = view.getUint16(pos + 2, true); + const epSize = view.getUint32(pos + 4, true); + if (epSize < 12 || pos + epSize > emfPlusEnd) break; + + if (epType === EMF_PLUS_OBJECT) { + const objectId = epFlags & 0x00ff; + const objectType = (epFlags >> 8) & 0x7f; + const continueBit = (epFlags & 0x8000) !== 0; + + // ContinueBit=1: Type(2) Flags(2) Size(4) TotalObjectSize(4) DataSize(4) ObjectData + // ContinueBit=0: Type(2) Flags(2) Size(4) DataSize(4) ObjectData + const headerBytes = continueBit ? 16 : 12; + if (epSize < headerBytes) break; + const totalObjectSize = continueBit ? view.getUint32(pos + 8, true) : 0; + const dataSize = view.getUint32(pos + (continueBit ? 12 : 8), true); + const dataStart = pos + headerBytes; + if (dataSize > epSize - headerBytes || dataStart + dataSize > emfPlusEnd) break; + + if (objectType === EMF_PLUS_OBJECT_TYPE_IMAGE) { + let result = null; + + if (continueBit) { + let entry = pendingByObjectId.get(objectId); + if (!entry) { + entry = { totalSize: totalObjectSize, parts: [], collected: 0 }; + pendingByObjectId.set(objectId, entry); + } else if (entry.totalSize === 0 && totalObjectSize > 0) { + entry.totalSize = totalObjectSize; + } + const chunk = new Uint8Array(buffer, dataStart, dataSize); + entry.parts.push(chunk); + entry.collected += chunk.byteLength; + + // The strict spec terminates the series with a ContinueBit=0 record, + // but flush early once TotalObjectSize is satisfied so an off-spec + // encoder that leaves ContinueBit=1 on the final record still resolves. + if (entry.totalSize > 0 && entry.collected >= entry.totalSize) { + result = parseEmfPlusImageObject(concatBytes(entry.parts)); + pendingByObjectId.delete(objectId); + } + } else { + const pending = pendingByObjectId.get(objectId); + if (pending) { + pending.parts.push(new Uint8Array(buffer, dataStart, dataSize)); + result = parseEmfPlusImageObject(concatBytes(pending.parts)); + pendingByObjectId.delete(objectId); + } else { + result = parseEmfPlusImageObject(new Uint8Array(buffer, dataStart, dataSize)); + } + } + + if (result) return result; + } + } + + pos += epSize; + } + } + } + } + + offset += recordSize; + } + + return null; +} + /** * Some EMF files generated by Office contain a single STRETCHDIBITS record with an embedded bitmap. * rtf.js does not render this record, which results in an empty SVG. This helper extracts the bitmap @@ -235,7 +467,7 @@ function isEmfPlus(buffer) { const recordSize = view.getUint32(offset + 4, true); if (recordSize < 8 || offset + recordSize > view.byteLength) break; - if (recordType === 70 /* EMR_COMMENT */ && recordSize >= 20) { + if (recordType === EMR_COMMENT && recordSize >= 20) { // EMR_COMMENT layout: Type (4) | Size (4) | DataSize (4) | CommentIdentifier (4) | Data... const identifier = view.getUint32(offset + 12, true); if (identifier === EMF_PLUS_SIGNATURE) return true; @@ -353,6 +585,13 @@ export function convertEmfToSvg(data, size = {}) { const dimensions = getEmfDimensions(buffer); if (isEmfPlus(buffer)) { + // EMF+ payloads use GDI+ drawing records that rtf.js does not implement. + // Many real-world EMF+ files (Office cover slides, charts) embed a complete + // PNG/JPEG inside an EmfPlusObject(Image) record — extract that for a + // pixel-perfect render before falling back to the placeholder. + const embedded = extractBitmapFromEmfPlus(buffer); + if (embedded) return embedded; + return createPlaceholder({ width: size.width || dimensions.width, height: size.height || dimensions.height, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.test.js index 4dff34968e..1d11a22924 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/metafile-converter.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { join } from 'path'; import { readFile } from 'fs/promises'; import { JSDOM } from 'jsdom'; @@ -116,4 +116,330 @@ describe('metafile-converter', () => { expect(result?.format).toBe('svg'); }); }); + + describe('EMF+ embedded bitmap extraction', () => { + // The file's vitest environment is 'node' (see packages/super-editor/vite.config.js), + // so each test installs a JSDOM via setMetafileDomEnvironment — convertEmfToSvg + // needs `document` and `XMLSerializer` to render the placeholder branch. + let dom; + + beforeEach(() => { + dom = new JSDOM(''); + setMetafileDomEnvironment({ window: dom.window, document: dom.window.document }); + }); + + afterEach(() => { + setMetafileDomEnvironment(null); + // ensureDomEnvironment promotes window/document onto globalThis on first use. + // Tear those down so neighbouring node-env tests don't see the JSDOM globals. + if (globalThis.window === dom.window) delete globalThis.window; + if (globalThis.document === dom.window.document) delete globalThis.document; + if (globalThis.XMLSerializer === dom.window.XMLSerializer) delete globalThis.XMLSerializer; + }); + + // Smallest valid 1x1 transparent PNG (67 bytes). + const TINY_PNG_HEX = + '89504E470D0A1A0A0000000D49484452000000010000000108060000001F15C489' + + '0000000A49444154789C6300010000000500010D0A2DB40000000049454E44AE426082'; + + // Smallest valid JPEG: 134 bytes, 1x1 grayscale. + const TINY_JPEG_HEX = + 'FFD8FFE000104A46494600010100000100010000FFDB004300080606070605080707' + + '07090908' + + '0A0C140D0C0B0B0C1912130F141D1A1F1E1D1A1C1C20242E2720222C231C1C28372928' + + '2C30313434' + + '1F2739' + + '3D38323C2E333432FFC0000B080001000101011100FFC4001F00' + + '0001050101010101010000000000000000010203040506070809' + + '0A0BFFC400B5100002010303020403050504040000017D010203000411051221314106' + + '13516107227114328191A1082342B1C11552D1F02433627282090A161718191A252627' + + '28292A3435363738393A434445464748494A535455565758595A636465666768696A73' + + '7475767778797A838485868788898A92939495969798999AA2A3A4A5A6A7A8A9AAB2B3' + + 'B4B5B6B7B8B9BAC2C3C4C5C6C7C8C9CAD2D3D4D5D6D7D8D9DAE1E2E3E4E5E6E7E8E9EA' + + 'F1F2F3F4F5F6F7F8F9FAFFDA0008010100003F00FB7CFFD9'; + + function hexToBytes(hex) { + const clean = hex.replace(/\s+/g, ''); + const bytes = new Uint8Array(clean.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(clean.substr(i * 2, 2), 16); + } + return bytes; + } + + function bytesToBase64(bytes) { + return Buffer.from(bytes).toString('base64'); + } + + function alignTo4(n) { + return (n + 3) & ~3; + } + + function writeEmfHeader(totalBytes, recordCount) { + const hdr = new Uint8Array(88); + const v = new DataView(hdr.buffer); + v.setUint32(0, 1, true); // type + v.setUint32(4, 88, true); // headerSize + v.setInt32(24, 0, true); + v.setInt32(28, 0, true); + v.setInt32(32, 254, true); // frame right (0.01mm) → ~9.6px @96dpi + v.setInt32(36, 254, true); // frame bottom + v.setUint32(40, 0x464d4520, true); // ' EMF' signature + v.setUint32(44, 0x00010000, true); // version + v.setUint32(48, totalBytes, true); // bytes + v.setUint32(52, recordCount, true); // records + v.setUint16(56, 0, true); // handles + v.setInt32(72, 100, true); // szlDevice cx + v.setInt32(76, 100, true); // szlDevice cy + v.setInt32(80, 200, true); // szlMillimeters cx + v.setInt32(84, 200, true); // szlMillimeters cy + return hdr; + } + + /** + * Build a standalone EmfPlusObject(Image) record (ContinueBit=0) carrying a + * compressed bitmap. Standard 12-byte header per MS-EMFPLUS § 2.3.5.1. + */ + function writeStandaloneImageRecord(imageBytes, objectId = 0) { + const dataSize = 28 + imageBytes.length; // EmfPlusImage(8) + EmfPlusBitmap header(20) + bitmap data + const recordSize = alignTo4(12 + dataSize); + + const rec = new Uint8Array(recordSize); + const v = new DataView(rec.buffer); + v.setUint16(0, 0x4008, true); // EmfPlusObject + v.setUint16(2, ((5 & 0x7f) << 8) | (objectId & 0xff), true); // Image, no continue + v.setUint32(4, recordSize, true); + v.setUint32(8, dataSize, true); + + // EmfPlusImage header at offset 12 + v.setUint32(12, 0xdbc01001, true); // Version + v.setUint32(16, 1, true); // Type = Bitmap + // EmfPlusBitmap header + v.setUint32(20, 1, true); // Width + v.setUint32(24, 1, true); // Height + v.setUint32(28, 4, true); // Stride + v.setUint32(32, 0x0026200a, true); // PixelFormat (32bppARGB) + v.setUint32(36, 1, true); // Bitmap Type = Compressed + rec.set(imageBytes, 40); + return rec; + } + + /** + * Build an EmfPlusObject record with ContinueBit=1. Header is 16 bytes per + * MS-EMFPLUS § 2.3.5.1: Type(2) Flags(2) Size(4) TotalObjectSize(4) DataSize(4), + * then ObjectData. TotalObjectSize is present on every continued record. + */ + function writeContinuedRecord(chunkBytes, objectId, totalObjectSize) { + const dataSize = chunkBytes.length; + const recordSize = alignTo4(16 + dataSize); + const rec = new Uint8Array(recordSize); + const v = new DataView(rec.buffer); + v.setUint16(0, 0x4008, true); + v.setUint16(2, 0x8000 | ((5 & 0x7f) << 8) | (objectId & 0xff), true); // continue, image + v.setUint32(4, recordSize, true); + v.setUint32(8, totalObjectSize, true); // TotalObjectSize lives in header + v.setUint32(12, dataSize, true); + rec.set(chunkBytes, 16); + return rec; + } + + /** + * Build the terminating EmfPlusObject record of a continued series (ContinueBit=0). + * Standard 12-byte header; ObjectData carries the final chunk of object payload. + */ + function writeFinalRecord(chunkBytes, objectId) { + const dataSize = chunkBytes.length; + const recordSize = alignTo4(12 + dataSize); + const rec = new Uint8Array(recordSize); + const v = new DataView(rec.buffer); + v.setUint16(0, 0x4008, true); + v.setUint16(2, ((5 & 0x7f) << 8) | (objectId & 0xff), true); // no continue, image + v.setUint32(4, recordSize, true); + v.setUint32(8, dataSize, true); + rec.set(chunkBytes, 12); + return rec; + } + + function writeEmrComment(emfPlusBytes) { + const dataSize = 4 + emfPlusBytes.length; // CommentIdentifier + payload + const recSize = alignTo4(12 + dataSize); + const rec = new Uint8Array(recSize); + const v = new DataView(rec.buffer); + v.setUint32(0, 70, true); // EMR_COMMENT + v.setUint32(4, recSize, true); + v.setUint32(8, dataSize, true); + v.setUint32(12, 0x2b464d45, true); // 'EMF+' identifier + rec.set(emfPlusBytes, 16); + return rec; + } + + function buildEmfBuffer(commentRecords) { + const totalSize = commentRecords.reduce((s, r) => s + r.length, 88); + const out = new Uint8Array(totalSize); + out.set(writeEmfHeader(totalSize, commentRecords.length), 0); + let pos = 88; + for (const rec of commentRecords) { + out.set(rec, pos); + pos += rec.length; + } + return out.buffer; + } + + function asEmfDataUri(buffer) { + return `data:image/emf;base64,${bytesToBase64(new Uint8Array(buffer))}`; + } + + it('extracts an embedded PNG from an EmfPlusObject(Image)', () => { + const png = hexToBytes(TINY_PNG_HEX); + const buffer = buildEmfBuffer([writeEmrComment(writeStandaloneImageRecord(png))]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + + expect(result).toBeTruthy(); + expect(result.format).toBe('png'); + expect(result.dataUri.startsWith('data:image/png;base64,')).toBe(true); + const extractedB64 = result.dataUri.slice('data:image/png;base64,'.length); + const extracted = Buffer.from(extractedB64, 'base64'); + expect(Buffer.from(png).equals(extracted)).toBe(true); + }); + + it('extracts an embedded JPEG from an EmfPlusObject(Image)', () => { + const jpeg = hexToBytes(TINY_JPEG_HEX); + const buffer = buildEmfBuffer([writeEmrComment(writeStandaloneImageRecord(jpeg))]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + + expect(result?.format).toBe('jpeg'); + expect(result?.dataUri.startsWith('data:image/jpeg;base64,')).toBe(true); + }); + + /** + * Build the EmfPlusImage payload (8-byte image header + 20-byte bitmap header + + * compressed bitmap) that gets split across EmfPlusObject continuation records. + */ + function buildImagePayload(imageBytes) { + const payload = new Uint8Array(28 + imageBytes.length); + const v = new DataView(payload.buffer); + v.setUint32(0, 0xdbc01001, true); // Version + v.setUint32(4, 1, true); // Image Type = Bitmap + v.setUint32(8, 1, true); // Width + v.setUint32(12, 1, true); // Height + v.setUint32(16, 4, true); // Stride + v.setUint32(20, 0x0026200a, true); // PixelFormat + v.setUint32(24, 1, true); // Bitmap Type = Compressed + payload.set(imageBytes, 28); + return payload; + } + + it('reassembles a compressed bitmap split across continued EmfPlusObject records', () => { + const png = hexToBytes(TINY_PNG_HEX); + const payload = buildImagePayload(png); + + const splitAt = 40; + const firstChunk = payload.slice(0, splitAt); + const finalChunk = payload.slice(splitAt); + + // Per MS-EMFPLUS § 2.3.5.1 every continued record (ContinueBit=1) carries a + // TotalObjectSize header field; the terminating record has ContinueBit=0 with + // the standard 12-byte header. + const firstRec = writeContinuedRecord(firstChunk, 0x07, payload.length); + const finalRec = writeFinalRecord(finalChunk, 0x07); + + const buffer = buildEmfBuffer([writeEmrComment(firstRec), writeEmrComment(finalRec)]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + expect(result?.format).toBe('png'); + const extracted = Buffer.from(result.dataUri.slice('data:image/png;base64,'.length), 'base64'); + expect(Buffer.from(png).equals(extracted)).toBe(true); + }); + + it('reassembles a compressed bitmap split across three or more continued records', () => { + // Exercises the middle-chunk path of the accumulator, not just first+final. + const jpeg = hexToBytes(TINY_JPEG_HEX); + const payload = buildImagePayload(jpeg); + + const a = payload.slice(0, 40); + const b = payload.slice(40, 90); + const c = payload.slice(90); + + const buffer = buildEmfBuffer([ + writeEmrComment(writeContinuedRecord(a, 0x05, payload.length)), + writeEmrComment(writeContinuedRecord(b, 0x05, payload.length)), + writeEmrComment(writeFinalRecord(c, 0x05)), + ]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + expect(result?.format).toBe('jpeg'); + }); + + it('flushes a continuation series early when TotalObjectSize is reached without a ContinueBit=0 terminator', () => { + // Defends against off-spec encoders that leave ContinueBit=1 on the final record. + const png = hexToBytes(TINY_PNG_HEX); + const payload = buildImagePayload(png); + + const splitAt = 40; + const firstChunk = payload.slice(0, splitAt); + const finalChunk = payload.slice(splitAt); + + const firstRec = writeContinuedRecord(firstChunk, 0x09, payload.length); + // Off-spec final chunk: still carries a ContinueBit=1 header (with TotalObjectSize). + const finalRec = writeContinuedRecord(finalChunk, 0x09, payload.length); + + const buffer = buildEmfBuffer([writeEmrComment(firstRec), writeEmrComment(finalRec)]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + expect(result?.format).toBe('png'); + const extracted = Buffer.from(result.dataUri.slice('data:image/png;base64,'.length), 'base64'); + expect(Buffer.from(png).equals(extracted)).toBe(true); + }); + + it('falls back to the EMF+ placeholder when no compressed bitmap is present', () => { + // Build an EMF+ stream containing only a non-Image EmfPlusObject (object type=1, brush). + const brush = new Uint8Array(20); + const dv = new DataView(brush.buffer); + dv.setUint16(0, 0x4008, true); + dv.setUint16(2, (1 << 8) | 0, true); // ObjectType=1 (Brush), no continue + dv.setUint32(4, 20, true); + dv.setUint32(8, 8, true); + + const buffer = buildEmfBuffer([writeEmrComment(brush)]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + expect(result?.format).toBe('svg'); + expect(result?.dataUri.startsWith('data:image/svg+xml;base64,')).toBe(true); + const decoded = decodeDataUri(result.dataUri); + expect(decoded).toContain('Unable to render EMF+ image'); + }); + + it('rejects pixel-format (uncompressed) EmfPlusBitmap instead of misreporting it', () => { + // Build an EmfPlusObject(Image) with Bitmap.Type = 0 (Pixel) instead of 1 (Compressed). + const dummyPixels = new Uint8Array(16); + const recBody = new Uint8Array(28 + dummyPixels.length); + const dv = new DataView(recBody.buffer); + dv.setUint32(0, 0xdbc01001, true); + dv.setUint32(4, 1, true); // Bitmap + dv.setUint32(8, 2, true); + dv.setUint32(12, 2, true); + dv.setUint32(16, 8, true); + dv.setUint32(20, 0x0026200a, true); + dv.setUint32(24, 0, true); // Pixel, not Compressed + recBody.set(dummyPixels, 28); + + const recordSize = alignTo4(12 + recBody.length); + const rec = new Uint8Array(recordSize); + const rv = new DataView(rec.buffer); + rv.setUint16(0, 0x4008, true); + rv.setUint16(2, (5 << 8) | 0, true); + rv.setUint32(4, recordSize, true); + rv.setUint32(8, recBody.length, true); + rec.set(recBody, 12); + + const buffer = buildEmfBuffer([writeEmrComment(rec)]); + + const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); + // Pixel bitmaps require a full GDI+ rasterizer — fall back to the placeholder. + expect(result?.format).toBe('svg'); + expect(decodeDataUri(result.dataUri)).toContain('Unable to render EMF+ image'); + }); + }); });