From 7900e7f5ebdfcede04b21af0a4f2b96f84ae2f48 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 9 May 2026 13:14:40 +0530 Subject: [PATCH 1/3] feat(converter): render EMF+ images via embedded bitmaps (SD-2503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EMF+ payloads use GDI+ drawing records that the rtf.js renderer doesn't implement, so prior to this change every EMF+ image rendered as an "Unable to render EMF+ image" placeholder. Most real-world EMF+ files generated by Office (cover slides, charts, illustrations) embed a complete PNG/JPEG inside an EmfPlusObject(Image) record with BitmapDataType=Compressed. Walk the EMR_COMMENT records in the EMF stream, parse the inner EMF+ records, reassemble continuation series via the TotalObjectSize prefix on the first chunk (MS-EMFPLUS § 2.3.5.1), and return the embedded image directly. Pure-vector and pixel-format EMF+ images still fall back to the placeholder — a full GDI+ rasterizer is out of scope here. Closes #3172 --- .../handlers/wp/helpers/metafile-converter.js | 252 +++++++++++++- .../wp/helpers/metafile-converter.test.js | 316 +++++++++++++++++- 2 files changed, 562 insertions(+), 6 deletions(-) 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..15213d7724 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,221 @@ 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 using the TotalObjectSize prefix + * present on the first chunk (MS-EMFPLUS § 2.3.5.1). + * + * @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; + + // Continued EmfPlusObject records are accumulated here, keyed by ObjectId. + 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); + const epDataSize = view.getUint32(pos + 8, true); + if (epSize < 12 || pos + epSize > emfPlusEnd || epDataSize > epSize - 12) break; + + if (epType === EMF_PLUS_OBJECT) { + const objectId = epFlags & 0x00ff; + const objectType = (epFlags >> 8) & 0x7f; + const continueBit = (epFlags & 0x8000) !== 0; + + if (objectType === EMF_PLUS_OBJECT_TYPE_IMAGE) { + const dataStart = pos + 12; + let result = null; + + if (continueBit) { + // Continuing a multi-record object. Per MS-EMFPLUS § 2.3.5.1, the first + // chunk's ObjectData starts with TotalObjectSize; subsequent chunks are + // raw appended bytes. ContinueBit stays 1 until the final record. + let entry = pendingByObjectId.get(objectId); + if (!entry) { + if (epDataSize < 4) { + pos += epSize; + continue; + } + const totalSize = view.getUint32(dataStart, true); + entry = { totalSize, parts: [], collected: 0 }; + pendingByObjectId.set(objectId, entry); + const chunk = new Uint8Array(buffer, dataStart + 4, epDataSize - 4); + entry.parts.push(chunk); + entry.collected += chunk.byteLength; + } else { + const chunk = new Uint8Array(buffer, dataStart, epDataSize); + entry.parts.push(chunk); + entry.collected += chunk.byteLength; + } + + // Some encoders also set ContinueBit on the very last record (against + // the strict spec, but observed in the wild). Flush early once + // TotalObjectSize is satisfied so we don't stall waiting for a + // ContinueBit=0 record that never arrives. + if (entry.totalSize > 0 && entry.collected >= entry.totalSize) { + result = parseEmfPlusImageObject(concatBytes(entry.parts)); + pendingByObjectId.delete(objectId); + } + } else { + // ContinueBit=0 marks either a standalone object or the final chunk of + // a continuation series. + const pending = pendingByObjectId.get(objectId); + if (pending) { + pending.parts.push(new Uint8Array(buffer, dataStart, epDataSize)); + result = parseEmfPlusImageObject(concatBytes(pending.parts)); + pendingByObjectId.delete(objectId); + } else { + result = parseEmfPlusImageObject(new Uint8Array(buffer, dataStart, epDataSize)); + } + } + + 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 +470,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 +588,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..8a6ddf9294 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,318 @@ 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 single-record EmfPlusObject(Image) carrying a compressed bitmap. + */ + function writeEmfPlusObjectImage(imageBytes, objectId = 0, continueBit = false, totalObjectSize = null) { + const imagePayload = 28 + imageBytes.length; // EmfPlusImage + EmfPlusBitmap header + bitmap data + const dataSize = continueBit ? 4 + imagePayload : imagePayload; + const recordSize = alignTo4(12 + dataSize); + + const rec = new Uint8Array(recordSize); + const v = new DataView(rec.buffer); + v.setUint16(0, 0x4008, true); // EmfPlusObject + const flags = (continueBit ? 0x8000 : 0) | ((5 & 0x7f) << 8) | (objectId & 0xff); + v.setUint16(2, flags, true); + v.setUint32(4, recordSize, true); + v.setUint32(8, dataSize, true); + + let p = 12; + if (continueBit) { + v.setUint32(p, totalObjectSize ?? imagePayload, true); + p += 4; + } + // EmfPlusImage header + v.setUint32(p, 0xdbc01001, true); // Version + v.setUint32(p + 4, 1, true); // Type = Bitmap + // EmfPlusBitmap header + v.setUint32(p + 8, 1, true); // Width + v.setUint32(p + 12, 1, true); // Height + v.setUint32(p + 16, 4, true); // Stride + v.setUint32(p + 20, 0x0026200a, true); // PixelFormat (32bppARGB) + v.setUint32(p + 24, 1, true); // Bitmap Type = Compressed + rec.set(imageBytes, p + 28); + return rec; + } + + /** + * Build a continuation EmfPlusObject record carrying raw appended bytes. + */ + function writeEmfPlusObjectContinuation(appendBytes, objectId, continueBit) { + const dataSize = appendBytes.length; + const recordSize = alignTo4(12 + dataSize); + const rec = new Uint8Array(recordSize); + const v = new DataView(rec.buffer); + v.setUint16(0, 0x4008, true); + const flags = (continueBit ? 0x8000 : 0) | ((5 & 0x7f) << 8) | (objectId & 0xff); + v.setUint16(2, flags, true); + v.setUint32(4, recordSize, true); + v.setUint32(8, dataSize, true); + rec.set(appendBytes, 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(writeEmfPlusObjectImage(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(writeEmfPlusObjectImage(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 (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; + } + + /** + * Build the first EmfPlusObject(Image) chunk in a continuation series. The first + * chunk's ObjectData is `TotalObjectSize (uint32) | first slice of payload`. + */ + function writeFirstContinuedImageRecord(firstChunk, totalObjectSize, objectId) { + const body = new Uint8Array(4 + firstChunk.length); + new DataView(body.buffer).setUint32(0, totalObjectSize, true); + body.set(firstChunk, 4); + + const dataSize = body.length; + const recSize = alignTo4(12 + dataSize); + const rec = new Uint8Array(recSize); + const dv = new DataView(rec.buffer); + dv.setUint16(0, 0x4008, true); + dv.setUint16(2, 0x8000 | ((5 & 0x7f) << 8) | (objectId & 0xff), true); // continue, image + dv.setUint32(4, recSize, true); + dv.setUint32(8, dataSize, true); + rec.set(body, 12); + return rec; + } + + 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 the final chunk has ContinueBit=0. + const firstRec = writeFirstContinuedImageRecord(firstChunk, payload.length, 0x07); + const finalRec = writeEmfPlusObjectContinuation(finalChunk, 0x07, false); + + 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('flushes a continuation series early when TotalObjectSize is reached without a ContinueBit=0 terminator', () => { + // Some encoders set ContinueBit on every record including the last one; the parser + // should still recover the image as soon as TotalObjectSize bytes are accumulated. + 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 = writeFirstContinuedImageRecord(firstChunk, payload.length, 0x09); + // Final record with ContinueBit still set (off-spec but observed in the wild). + const finalRec = writeEmfPlusObjectContinuation(finalChunk, 0x09, true); + + 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'); + }); + }); }); From b9425b529ffcd613e4cc38cf988b006d6a77ccc2 Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 9 May 2026 13:38:06 +0530 Subject: [PATCH 2/3] fix(converter): parse EmfPlusObject continued-record header per spec (SD-2503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per MS-EMFPLUS § 2.3.5.1, when ContinueBit=1 the EmfPlusObject record header is 16 bytes — TotalObjectSize sits between Size and DataSize and is present on every continued record (not only the first). The previous implementation read offset 8 as DataSize for continued records, which is actually TotalObjectSize, and treated the first 4 bytes of ObjectData as TotalObjectSize. The synthetic continuation tests built buffers with the same wrong layout, so they passed without exercising the bug. Real EMF+ files written by Office (the multi-record cover-image case) follow the spec layout, so the prior code would have either bailed on the bounds check or copied from the wrong offset and fallen through to the placeholder. Now: ContinueBit=1: Type(2) Flags(2) Size(4) TotalObjectSize(4) DataSize(4) ObjectData ContinueBit=0: Type(2) Flags(2) Size(4) DataSize(4) ObjectData Tests rebuild the synthetic buffers with the correct layout and add coverage for a 3+ record continuation series. --- .../handlers/wp/helpers/metafile-converter.js | 64 +++++---- .../wp/helpers/metafile-converter.test.js | 133 ++++++++++-------- 2 files changed, 109 insertions(+), 88 deletions(-) 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 15213d7724..87338b9229 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 @@ -219,8 +219,12 @@ function parseEmfPlusImageObject(bytes) { * * 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 using the TotalObjectSize prefix - * present on the first chunk (MS-EMFPLUS § 2.3.5.1). + * (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} @@ -259,58 +263,60 @@ function extractBitmapFromEmfPlus(buffer) { const epType = view.getUint16(pos, true); const epFlags = view.getUint16(pos + 2, true); const epSize = view.getUint32(pos + 4, true); - const epDataSize = view.getUint32(pos + 8, true); - if (epSize < 12 || pos + epSize > emfPlusEnd || epDataSize > epSize - 12) break; + 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; + // Per MS-EMFPLUS § 2.3.5.1 the EmfPlusObject header layout depends on the + // ContinueBit: + // ContinueBit=1: Type(2) Flags(2) Size(4) TotalObjectSize(4) DataSize(4) ObjectData(...) + // ContinueBit=0: Type(2) Flags(2) Size(4) DataSize(4) ObjectData(...) + // The TotalObjectSize field is present on every continued record, not only + // the first. When TotalObjectSize bytes of ObjectData have been accumulated + // across the series, the continued object is complete and the next record + // is a separate object. + 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) { - const dataStart = pos + 12; let result = null; if (continueBit) { - // Continuing a multi-record object. Per MS-EMFPLUS § 2.3.5.1, the first - // chunk's ObjectData starts with TotalObjectSize; subsequent chunks are - // raw appended bytes. ContinueBit stays 1 until the final record. let entry = pendingByObjectId.get(objectId); if (!entry) { - if (epDataSize < 4) { - pos += epSize; - continue; - } - const totalSize = view.getUint32(dataStart, true); - entry = { totalSize, parts: [], collected: 0 }; + entry = { totalSize: totalObjectSize, parts: [], collected: 0 }; pendingByObjectId.set(objectId, entry); - const chunk = new Uint8Array(buffer, dataStart + 4, epDataSize - 4); - entry.parts.push(chunk); - entry.collected += chunk.byteLength; - } else { - const chunk = new Uint8Array(buffer, dataStart, epDataSize); - entry.parts.push(chunk); - entry.collected += chunk.byteLength; + } 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; - // Some encoders also set ContinueBit on the very last record (against - // the strict spec, but observed in the wild). Flush early once - // TotalObjectSize is satisfied so we don't stall waiting for a - // ContinueBit=0 record that never arrives. + // 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 { - // ContinueBit=0 marks either a standalone object or the final chunk of - // a continuation series. + // ContinueBit=0: either a standalone object or the final chunk of a + // continued series. const pending = pendingByObjectId.get(objectId); if (pending) { - pending.parts.push(new Uint8Array(buffer, dataStart, epDataSize)); + pending.parts.push(new Uint8Array(buffer, dataStart, dataSize)); result = parseEmfPlusImageObject(concatBytes(pending.parts)); pendingByObjectId.delete(objectId); } else { - result = parseEmfPlusImageObject(new Uint8Array(buffer, dataStart, epDataSize)); + result = parseEmfPlusImageObject(new Uint8Array(buffer, dataStart, dataSize)); } } 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 8a6ddf9294..4b7cd11d19 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 @@ -197,53 +197,66 @@ describe('metafile-converter', () => { } /** - * Build a single-record EmfPlusObject(Image) carrying a compressed bitmap. + * Build a standalone EmfPlusObject(Image) record (ContinueBit=0) carrying a + * compressed bitmap. Standard 12-byte header per MS-EMFPLUS § 2.3.5.1. */ - function writeEmfPlusObjectImage(imageBytes, objectId = 0, continueBit = false, totalObjectSize = null) { - const imagePayload = 28 + imageBytes.length; // EmfPlusImage + EmfPlusBitmap header + bitmap data - const dataSize = continueBit ? 4 + imagePayload : imagePayload; + 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 - const flags = (continueBit ? 0x8000 : 0) | ((5 & 0x7f) << 8) | (objectId & 0xff); - v.setUint16(2, flags, true); + v.setUint16(2, ((5 & 0x7f) << 8) | (objectId & 0xff), true); // Image, no continue v.setUint32(4, recordSize, true); v.setUint32(8, dataSize, true); - let p = 12; - if (continueBit) { - v.setUint32(p, totalObjectSize ?? imagePayload, true); - p += 4; - } - // EmfPlusImage header - v.setUint32(p, 0xdbc01001, true); // Version - v.setUint32(p + 4, 1, true); // Type = Bitmap + // EmfPlusImage header at offset 12 + v.setUint32(12, 0xdbc01001, true); // Version + v.setUint32(16, 1, true); // Type = Bitmap // EmfPlusBitmap header - v.setUint32(p + 8, 1, true); // Width - v.setUint32(p + 12, 1, true); // Height - v.setUint32(p + 16, 4, true); // Stride - v.setUint32(p + 20, 0x0026200a, true); // PixelFormat (32bppARGB) - v.setUint32(p + 24, 1, true); // Bitmap Type = Compressed - rec.set(imageBytes, p + 28); + 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 a continuation EmfPlusObject record carrying raw appended bytes. + * Build the terminating EmfPlusObject record of a continued series (ContinueBit=0). + * Standard 12-byte header; ObjectData carries the final chunk of object payload. */ - function writeEmfPlusObjectContinuation(appendBytes, objectId, continueBit) { - const dataSize = appendBytes.length; + 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); - const flags = (continueBit ? 0x8000 : 0) | ((5 & 0x7f) << 8) | (objectId & 0xff); - v.setUint16(2, flags, 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(appendBytes, 12); + rec.set(chunkBytes, 12); return rec; } @@ -278,7 +291,7 @@ describe('metafile-converter', () => { it('extracts an embedded PNG from an EmfPlusObject(Image)', () => { const png = hexToBytes(TINY_PNG_HEX); - const buffer = buildEmfBuffer([writeEmrComment(writeEmfPlusObjectImage(png))]); + const buffer = buildEmfBuffer([writeEmrComment(writeStandaloneImageRecord(png))]); const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); @@ -292,7 +305,7 @@ describe('metafile-converter', () => { it('extracts an embedded JPEG from an EmfPlusObject(Image)', () => { const jpeg = hexToBytes(TINY_JPEG_HEX); - const buffer = buildEmfBuffer([writeEmrComment(writeEmfPlusObjectImage(jpeg))]); + const buffer = buildEmfBuffer([writeEmrComment(writeStandaloneImageRecord(jpeg))]); const result = convertMetafileToSvg(asEmfDataUri(buffer), 'emf'); @@ -301,8 +314,8 @@ describe('metafile-converter', () => { }); /** - * Build the EmfPlusImage payload (header + compressed bitmap) that gets split across - * EmfPlusObject continuation records. + * 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); @@ -318,27 +331,6 @@ describe('metafile-converter', () => { return payload; } - /** - * Build the first EmfPlusObject(Image) chunk in a continuation series. The first - * chunk's ObjectData is `TotalObjectSize (uint32) | first slice of payload`. - */ - function writeFirstContinuedImageRecord(firstChunk, totalObjectSize, objectId) { - const body = new Uint8Array(4 + firstChunk.length); - new DataView(body.buffer).setUint32(0, totalObjectSize, true); - body.set(firstChunk, 4); - - const dataSize = body.length; - const recSize = alignTo4(12 + dataSize); - const rec = new Uint8Array(recSize); - const dv = new DataView(rec.buffer); - dv.setUint16(0, 0x4008, true); - dv.setUint16(2, 0x8000 | ((5 & 0x7f) << 8) | (objectId & 0xff), true); // continue, image - dv.setUint32(4, recSize, true); - dv.setUint32(8, dataSize, true); - rec.set(body, 12); - return rec; - } - it('reassembles a compressed bitmap split across continued EmfPlusObject records', () => { const png = hexToBytes(TINY_PNG_HEX); const payload = buildImagePayload(png); @@ -347,9 +339,11 @@ describe('metafile-converter', () => { const firstChunk = payload.slice(0, splitAt); const finalChunk = payload.slice(splitAt); - // Per MS-EMFPLUS § 2.3.5.1 the final chunk has ContinueBit=0. - const firstRec = writeFirstContinuedImageRecord(firstChunk, payload.length, 0x07); - const finalRec = writeEmfPlusObjectContinuation(finalChunk, 0x07, false); + // 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)]); @@ -359,9 +353,30 @@ describe('metafile-converter', () => { expect(Buffer.from(png).equals(extracted)).toBe(true); }); + it('reassembles a compressed bitmap split across three or more continued records', () => { + // Real-world EMF+ images frequently span more than two records. Verify the + // accumulator handles middle chunks correctly. + 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', () => { - // Some encoders set ContinueBit on every record including the last one; the parser - // should still recover the image as soon as TotalObjectSize bytes are accumulated. + // Some encoders leave ContinueBit set on the final record (against the spec). + // The parser should still recover the image once TotalObjectSize bytes have + // been accumulated across the continued records. const png = hexToBytes(TINY_PNG_HEX); const payload = buildImagePayload(png); @@ -369,9 +384,9 @@ describe('metafile-converter', () => { const firstChunk = payload.slice(0, splitAt); const finalChunk = payload.slice(splitAt); - const firstRec = writeFirstContinuedImageRecord(firstChunk, payload.length, 0x09); - // Final record with ContinueBit still set (off-spec but observed in the wild). - const finalRec = writeEmfPlusObjectContinuation(finalChunk, 0x09, true); + 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)]); From ebdd9089d9283d9075e48aa92a2002f66ed4226f Mon Sep 17 00:00:00 2001 From: G Pardhiv Varma Date: Sat, 9 May 2026 13:42:13 +0530 Subject: [PATCH 3/3] chore(converter): tighten comments in EMF+ extractor --- .../v3/handlers/wp/helpers/metafile-converter.js | 13 ++----------- .../handlers/wp/helpers/metafile-converter.test.js | 7 ++----- 2 files changed, 4 insertions(+), 16 deletions(-) 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 87338b9229..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 @@ -239,7 +239,6 @@ function extractBitmapFromEmfPlus(buffer) { if (type !== 1 || signature !== EMF_SIGNATURE) return null; if (headerSize <= 0 || headerSize >= view.byteLength) return null; - // Continued EmfPlusObject records are accumulated here, keyed by ObjectId. const pendingByObjectId = new Map(); let offset = headerSize; @@ -270,14 +269,8 @@ function extractBitmapFromEmfPlus(buffer) { const objectType = (epFlags >> 8) & 0x7f; const continueBit = (epFlags & 0x8000) !== 0; - // Per MS-EMFPLUS § 2.3.5.1 the EmfPlusObject header layout depends on the - // ContinueBit: - // ContinueBit=1: Type(2) Flags(2) Size(4) TotalObjectSize(4) DataSize(4) ObjectData(...) - // ContinueBit=0: Type(2) Flags(2) Size(4) DataSize(4) ObjectData(...) - // The TotalObjectSize field is present on every continued record, not only - // the first. When TotalObjectSize bytes of ObjectData have been accumulated - // across the series, the continued object is complete and the next record - // is a separate object. + // 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; @@ -308,8 +301,6 @@ function extractBitmapFromEmfPlus(buffer) { pendingByObjectId.delete(objectId); } } else { - // ContinueBit=0: either a standalone object or the final chunk of a - // continued series. const pending = pendingByObjectId.get(objectId); if (pending) { pending.parts.push(new Uint8Array(buffer, dataStart, dataSize)); 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 4b7cd11d19..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 @@ -354,8 +354,7 @@ describe('metafile-converter', () => { }); it('reassembles a compressed bitmap split across three or more continued records', () => { - // Real-world EMF+ images frequently span more than two records. Verify the - // accumulator handles middle chunks correctly. + // Exercises the middle-chunk path of the accumulator, not just first+final. const jpeg = hexToBytes(TINY_JPEG_HEX); const payload = buildImagePayload(jpeg); @@ -374,9 +373,7 @@ describe('metafile-converter', () => { }); it('flushes a continuation series early when TotalObjectSize is reached without a ContinueBit=0 terminator', () => { - // Some encoders leave ContinueBit set on the final record (against the spec). - // The parser should still recover the image once TotalObjectSize bytes have - // been accumulated across the continued records. + // Defends against off-spec encoders that leave ContinueBit=1 on the final record. const png = hexToBytes(TINY_PNG_HEX); const payload = buildImagePayload(png);