diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 77bd061260..d02fb0eaa7 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -297,6 +297,17 @@ export type ImageRun = { * Custom data attributes propagated from ProseMirror marks (keys must be data-*). */ dataAttrs?: Record; + + // Image transformations from OOXML a:xfrm (applies to inline images) + rotation?: number; // Rotation angle in degrees + flipH?: boolean; // Horizontal flip + flipV?: boolean; // Vertical flip + + // VML image adjustments for watermark effects + gain?: string | number; // Brightness/washout (VML hex string or number) + blacklevel?: string | number; // Contrast adjustment (VML hex string or number) + // OOXML image effects + grayscale?: boolean; // Apply grayscale filter to image }; export type BreakRun = { @@ -540,6 +551,12 @@ export type ImageBlock = { // VML image adjustments for watermark effects gain?: string | number; // Brightness/washout (VML hex string or number) blacklevel?: string | number; // Contrast adjustment (VML hex string or number) + // OOXML image effects + grayscale?: boolean; // Apply grayscale filter to image + // Image transformations from OOXML a:xfrm (applies to both inline and anchored images) + rotation?: number; // Rotation angle in degrees + flipH?: boolean; // Horizontal flip + flipV?: boolean; // Vertical flip }; export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup'; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ae68f933f5..6152efb202 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2630,10 +2630,51 @@ export class DomPainter { } img.style.display = block.display === 'inline' ? 'inline-block' : 'block'; + // Apply rotation and flip transforms from OOXML a:xfrm + const transforms: string[] = []; + + // Calculate translation offset to keep top-left corner fixed when rotating + if (block.rotation != null && block.rotation !== 0) { + const angleRad = (block.rotation * Math.PI) / 180; + const w = block.width ?? fragment.width; + const h = block.height ?? fragment.height; + + // Calculate how much the top-left corner moves when rotating around center + // Top-left corner starts at (0, 0) in element space + // Center is at (w/2, h/2) + // After rotation, we need to translate to keep top-left at (0, 0) + const cosA = Math.cos(angleRad); + const sinA = Math.sin(angleRad); + + // Position of top-left corner after rotation (relative to original top-left) + const newTopLeftX = (w / 2) * (1 - cosA) + (h / 2) * sinA; + const newTopLeftY = (w / 2) * sinA + (h / 2) * (1 - cosA); + + transforms.push(`translate(${-newTopLeftX}px, ${-newTopLeftY}px)`); + transforms.push(`rotate(${block.rotation}deg)`); + } + if (block.flipH) { + transforms.push('scaleX(-1)'); + } + if (block.flipV) { + transforms.push('scaleY(-1)'); + } + + if (transforms.length > 0) { + img.style.transform = transforms.join(' '); + img.style.transformOrigin = 'center'; + } + // Apply VML image adjustments (gain/blacklevel) as CSS filters for watermark effects // conversion formulas calculated based on Libreoffice vml reader // https://github.com/LibreOffice/core/blob/951a74d047cfddff78014225f55ecb2bbdcd9c4c/oox/source/vml/vmlshapecontext.cxx#L465C13-L493C1 const filters: string[] = []; + + // Apply OOXML grayscale effect + if (block.grayscale) { + filters.push('grayscale(100%)'); + } + if (block.gain != null || block.blacklevel != null) { // Convert VML gain to CSS contrast // VML gain is a hex string like "19661f" - higher = more contrast @@ -2652,10 +2693,10 @@ export class DomPainter { filters.push(`brightness(${brightness})`); } } + } - if (filters.length > 0) { - img.style.filter = filters.join(' '); - } + if (filters.length > 0) { + img.style.filter = filters.join(' '); } fragmentEl.appendChild(img); @@ -3973,6 +4014,70 @@ export class DomPainter { img.style.marginRight = `${run.distRight}px`; } + // Apply rotation and flip transforms from OOXML a:xfrm + const transforms: string[] = []; + + // Calculate translation offset to keep top-left corner fixed when rotating + if (run.rotation != null && run.rotation !== 0) { + const angleRad = (run.rotation * Math.PI) / 180; + const w = run.width; + const h = run.height; + + // Calculate how much the top-left corner moves when rotating around center + // Top-left corner starts at (0, 0) in element space + // Center is at (w/2, h/2) + // After rotation, we need to translate to keep top-left at (0, 0) + const cosA = Math.cos(angleRad); + const sinA = Math.sin(angleRad); + + // Position of top-left corner after rotation (relative to original top-left) + const newTopLeftX = (w / 2) * (1 - cosA) + (h / 2) * sinA; + const newTopLeftY = (w / 2) * sinA + (h / 2) * (1 - cosA); + + transforms.push(`translate(${-newTopLeftX}px, ${-newTopLeftY}px)`); + transforms.push(`rotate(${run.rotation}deg)`); + } + if (run.flipH) { + transforms.push('scaleX(-1)'); + } + if (run.flipV) { + transforms.push('scaleY(-1)'); + } + if (transforms.length > 0) { + img.style.transform = transforms.join(' '); + img.style.transformOrigin = 'center'; + } + + // Apply image effects (grayscale, VML adjustments for watermarks) + const filters: string[] = []; + + // Apply OOXML grayscale effect + if (run.grayscale) { + filters.push('grayscale(100%)'); + } + + if (run.gain != null || run.blacklevel != null) { + // Convert VML gain to CSS contrast + if (run.gain && typeof run.gain === 'string' && run.gain.endsWith('f')) { + const contrast = Math.max(0, parseInt(run.gain) / 65536) * (2 / 3); + if (contrast > 0) { + filters.push(`contrast(${contrast})`); + } + } + + // Convert VML blacklevel to CSS brightness + if (run.blacklevel && typeof run.blacklevel === 'string' && run.blacklevel.endsWith('f')) { + const brightness = Math.max(0, 1 + parseInt(run.blacklevel) / 327 / 100) * 1.3; + if (brightness > 0) { + filters.push(`brightness(${brightness})`); + } + } + } + + if (filters.length > 0) { + img.style.filter = filters.join(' '); + } + // Position and z-index on the image only (not the line) so resize overlay can stack above. img.style.position = 'relative'; img.style.zIndex = '1'; diff --git a/packages/layout-engine/pm-adapter/src/converters/image.test.ts b/packages/layout-engine/pm-adapter/src/converters/image.test.ts index 828756e8cf..5fdda71959 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.test.ts @@ -674,4 +674,84 @@ describe('image converter', () => { expect(blocks).toHaveLength(1); }); }); + + describe('imageNodeToBlock - transformations', () => { + const mockBlockIdGenerator: BlockIdGenerator = vi.fn((kind) => `test-${kind}-id`); + const mockPositionMap: PositionMap = new Map(); + + it('extracts rotation from transformData', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.jpg', + transformData: { + rotation: 270, + horizontalFlip: false, + verticalFlip: false, + }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.rotation).toBe(270); + expect(result.flipH).toBe(false); + expect(result.flipV).toBe(false); + }); + + it('extracts horizontal and vertical flip from transformData', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.jpg', + transformData: { + rotation: 90, + horizontalFlip: true, + verticalFlip: true, + }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.rotation).toBe(90); + expect(result.flipH).toBe(true); + expect(result.flipV).toBe(true); + }); + + it('does not include rotation/flip when transformData is missing', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.jpg', + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.rotation).toBeUndefined(); + expect(result.flipH).toBeUndefined(); + expect(result.flipV).toBeUndefined(); + }); + + it('does not include rotation/flip when transformData values are invalid types', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.jpg', + transformData: { + rotation: '270', // string instead of number + horizontalFlip: 'yes', // string instead of boolean + verticalFlip: 1, // number instead of boolean + }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.rotation).toBeUndefined(); + expect(result.flipH).toBeUndefined(); + expect(result.flipV).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/image.ts index c140a0feb7..c74fc266e7 100644 --- a/packages/layout-engine/pm-adapter/src/converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/image.ts @@ -272,6 +272,11 @@ export function imageNodeToBlock( const zIndexFromRelativeHeight = normalizeZIndex(attrs.originalAttributes as Record | undefined); const zIndex = resolveFloatingZIndex(anchor?.behindDoc === true, zIndexFromRelativeHeight); + // Extract rotation/flip transforms from transformData + const transformData = isPlainObject(attrs.transformData) ? attrs.transformData : undefined; + const rotation = typeof transformData?.rotation === 'number' ? transformData.rotation : undefined; + const flipH = typeof transformData?.horizontalFlip === 'boolean' ? transformData.horizontalFlip : undefined; + const flipV = typeof transformData?.verticalFlip === 'boolean' ? transformData.verticalFlip : undefined; return { kind: 'image', id: nextBlockId('image'), @@ -292,6 +297,12 @@ export function imageNodeToBlock( gain: typeof attrs.gain === 'string' || typeof attrs.gain === 'number' ? attrs.gain : undefined, blacklevel: typeof attrs.blacklevel === 'string' || typeof attrs.blacklevel === 'number' ? attrs.blacklevel : undefined, + // OOXML image effects (grayscale, etc.) + grayscale: typeof attrs.grayscale === 'boolean' ? attrs.grayscale : undefined, + // Image transformations from OOXML a:xfrm + ...(rotation !== undefined && { rotation }), + ...(flipH !== undefined && { flipH }), + ...(flipV !== undefined && { flipV }), }; } diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index 74966ed1c4..f966cc9ee4 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -130,6 +130,32 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter run.sdt = sdtMetadata; } + // Extract rotation/flip transforms from transformData + const transformData = isPlainObject(attrs.transformData) ? attrs.transformData : undefined; + if (transformData) { + const rotation = typeof transformData.rotation === 'number' ? transformData.rotation : undefined; + if (rotation !== undefined) run.rotation = rotation; + + const flipH = typeof transformData.horizontalFlip === 'boolean' ? transformData.horizontalFlip : undefined; + if (flipH !== undefined) run.flipH = flipH; + + const flipV = typeof transformData.verticalFlip === 'boolean' ? transformData.verticalFlip : undefined; + if (flipV !== undefined) run.flipV = flipV; + } + + // VML image adjustments for watermark effects + if (typeof attrs.gain === 'string' || typeof attrs.gain === 'number') { + run.gain = attrs.gain; + } + if (typeof attrs.blacklevel === 'string' || typeof attrs.blacklevel === 'number') { + run.blacklevel = attrs.blacklevel; + } + + // OOXML image effects + if (typeof attrs.grayscale === 'boolean') { + run.grayscale = attrs.grayscale; + } + return run; } diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 3023ede6ac..e1b8f847f1 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -1,6 +1,6 @@ import * as xmljs from 'xml-js'; import JSZip from 'jszip'; -import { getContentTypesFromXml, base64ToUint8Array } from './super-converter/helpers.js'; +import { getContentTypesFromXml, base64ToUint8Array, detectImageType } from './super-converter/helpers.js'; import { ensureXmlString, isXmlLike } from './encoding-helpers.js'; /** @@ -59,9 +59,19 @@ class DocxZipper { this.mediaFiles[name] = fileBase64; } else { const fileBase64 = await zipEntry.async('base64'); - const extension = this.getFileExtension(name)?.toLowerCase(); + 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') { + detectedType = detectImageType(fileBase64); + if (detectedType) { + extension = detectedType; + } + } + if (imageTypes.has(extension)) { this.mediaFiles[name] = `data:image/${extension};base64,${fileBase64}`; const blob = await zipEntry.async('blob'); diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index b8295d1413..6236b52b01 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -299,3 +299,100 @@ describe('DocxZipper - exportFromCollaborativeDocx media handling', () => { expect(Array.from(img2)).toEqual([87, 111, 114, 108, 100]); }); }); + +describe('DocxZipper - .tmp image file detection', () => { + it('detects and processes .tmp files with PNG signatures as PNG images', async () => { + const zipper = new DocxZipper(); + const zip = new JSZip(); + + // Minimal DOCX structure + const contentTypes = ` + + + + + `; + zip.file('[Content_Types].xml', contentTypes); + zip.file('word/document.xml', ''); + + // Create a minimal PNG file with proper signature + // PNG signature: 89 50 4E 47 0D 0A 1A 0A, followed by some padding + const pngData = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + ]); + + // Add it to the ZIP with a .tmp extension + zip.file('word/media/image1.tmp', pngData); + + const buf = await zip.generateAsync({ type: 'arraybuffer' }); + await zipper.getDocxData(buf, false); + + // Verify the .tmp file was detected as PNG and processed correctly + expect(zipper.mediaFiles['word/media/image1.tmp']).toBeTruthy(); + expect(zipper.mediaFiles['word/media/image1.tmp']).toContain('data:image/png;base64'); + + // Verify it's stored in media as a blob URL + expect(zipper.media['word/media/image1.tmp']).toBeTruthy(); + expect(typeof zipper.media['word/media/image1.tmp']).toBe('string'); + }); + + it('detects and processes .tmp files with JPEG signatures as JPEG images', async () => { + const zipper = new DocxZipper(); + const zip = new JSZip(); + + const contentTypes = ` + + + + + `; + zip.file('[Content_Types].xml', contentTypes); + zip.file('word/document.xml', ''); + + // JPEG signature: FF D8 FF + const jpegData = new Uint8Array([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + ]); + + zip.file('word/media/photo.tmp', jpegData); + + const buf = await zip.generateAsync({ type: 'arraybuffer' }); + await zipper.getDocxData(buf, false); + + // Verify the .tmp file was detected as JPEG + expect(zipper.mediaFiles['word/media/photo.tmp']).toBeTruthy(); + expect(zipper.mediaFiles['word/media/photo.tmp']).toContain('data:image/jpeg;base64'); + + // Verify it's stored in media as a blob URL + expect(zipper.media['word/media/photo.tmp']).toBeTruthy(); + }); + + it('does not process .tmp files without image signatures', async () => { + const zipper = new DocxZipper(); + const zip = new JSZip(); + + const contentTypes = ` + + + + + `; + zip.file('[Content_Types].xml', contentTypes); + zip.file('word/document.xml', ''); + + // Some random data that is not an image + const randomData = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]); + zip.file('word/media/data.tmp', randomData); + + const buf = await zip.generateAsync({ type: 'arraybuffer' }); + await zipper.getDocxData(buf, false); + + // Verify the .tmp file was NOT processed as an image (stored as raw base64) + expect(zipper.mediaFiles['word/media/data.tmp']).toBeTruthy(); + expect(zipper.mediaFiles['word/media/data.tmp']).not.toContain('data:image/'); + + // Should not be in media blob URLs + expect(zipper.media['word/media/data.tmp']).toBeFalsy(); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/helpers.js b/packages/super-editor/src/core/super-converter/helpers.js index 46a29e6f12..0990e0bbf2 100644 --- a/packages/super-editor/src/core/super-converter/helpers.js +++ b/packages/super-editor/src/core/super-converter/helpers.js @@ -597,6 +597,86 @@ function convertSizeToCSS(value, type) { } } +/** + * Detects image type from file content using magic bytes (file signatures). + * Supports PNG, JPEG, GIF, BMP, TIFF, WEBP. + * + * @param {Uint8Array|string} data - Binary data as Uint8Array or base64 string + * @returns {string|null} - Detected image type (e.g., 'png', 'jpeg') or null if not detected + */ +const detectImageType = (data) => { + let bytes; + + if (typeof data === 'string') { + // Assume base64 string + try { + bytes = base64ToUint8Array(data); + } catch { + return null; + } + } else if (data instanceof Uint8Array) { + bytes = data; + } else { + return null; + } + + if (bytes.length < 12) return null; + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + 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 'png'; + } + + // JPEG: FF D8 FF + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'jpeg'; + } + + // GIF: 47 49 46 38 (GIF8) + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + return 'gif'; + } + + // BMP: 42 4D (BM) + if (bytes[0] === 0x42 && bytes[1] === 0x4d) { + return 'bmp'; + } + + // TIFF: 49 49 2A 00 (little-endian) or 4D 4D 00 2A (big-endian) + if ( + (bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) || + (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a) + ) { + return 'tiff'; + } + + // WEBP: 52 49 46 46 ... 57 45 42 50 (RIFF....WEBP) + if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return 'webp'; + } + + return null; +}; + export { PIXELS_PER_INCH, inchesToTwips, @@ -640,4 +720,5 @@ export { resolveOpcTargetPath, computeCrc32Hex, base64ToUint8Array, + detectImageType, }; diff --git a/packages/super-editor/src/core/super-converter/helpers.test.js b/packages/super-editor/src/core/super-converter/helpers.test.js index c1b6de9085..ef890393df 100644 --- a/packages/super-editor/src/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/core/super-converter/helpers.test.js @@ -7,6 +7,7 @@ import { getArrayBufferFromUrl, computeCrc32Hex, base64ToUint8Array, + detectImageType, } from './helpers.js'; describe('polygonToObj', () => { @@ -383,3 +384,82 @@ describe('base64ToUint8Array', () => { expect(Array.from(result)).toEqual([0, 1, 255]); }); }); + +describe('detectImageType', () => { + it('detects PNG from magic bytes', () => { + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); + expect(detectImageType(pngBytes)).toBe('png'); + }); + + it('detects JPEG from magic bytes', () => { + // JPEG signature: FF D8 FF + const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(detectImageType(jpegBytes)).toBe('jpeg'); + }); + + it('detects GIF from magic bytes', () => { + // GIF signature: 47 49 46 38 (GIF8) + const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0, 0, 0, 0, 0]); + expect(detectImageType(gifBytes)).toBe('gif'); + }); + + it('detects BMP from magic bytes', () => { + // BMP signature: 42 4D (BM) + const bmpBytes = new Uint8Array([0x42, 0x4d, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(detectImageType(bmpBytes)).toBe('bmp'); + }); + + it('detects TIFF little-endian from magic bytes', () => { + // TIFF little-endian: 49 49 2A 00 + const tiffBytes = new Uint8Array([0x49, 0x49, 0x2a, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(detectImageType(tiffBytes)).toBe('tiff'); + }); + + it('detects TIFF big-endian from magic bytes', () => { + // TIFF big-endian: 4D 4D 00 2A + const tiffBytes = new Uint8Array([0x4d, 0x4d, 0x00, 0x2a, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(detectImageType(tiffBytes)).toBe('tiff'); + }); + + it('detects WEBP from magic bytes', () => { + // WEBP signature: 52 49 46 46 ... 57 45 42 50 (RIFF....WEBP) + const webpBytes = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]); + expect(detectImageType(webpBytes)).toBe('webp'); + }); + + it('detects PNG from base64 string', () => { + // PNG signature in base64 + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + expect(detectImageType(pngBase64)).toBe('png'); + }); + + it('detects JPEG from base64 string', () => { + // JPEG signature in base64 (starts with /9j/) + const jpegBase64 = + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/gA=='; + expect(detectImageType(jpegBase64)).toBe('jpeg'); + }); + + it('returns null for non-image data', () => { + const nonImageBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0, 0, 0]); // ZIP signature + expect(detectImageType(nonImageBytes)).toBe(null); + }); + + it('returns null for data that is too short', () => { + const shortBytes = new Uint8Array([0x89, 0x50]); // Only 2 bytes + expect(detectImageType(shortBytes)).toBe(null); + }); + + it('returns null for invalid input types', () => { + expect(detectImageType(null)).toBe(null); + expect(detectImageType(undefined)).toBe(null); + expect(detectImageType(12345)).toBe(null); + expect(detectImageType({})).toBe(null); + }); + + it('returns null for invalid base64 string', () => { + expect(detectImageType('not-valid-base64!!!')).toBe(null); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index ba127ed394..c329392b29 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -205,6 +205,11 @@ export const translateImageNode = (params) => { attributes: { 'r:embed': imageId, }, + ...(attrs.grayscale + ? { + elements: [{ name: 'a:grayscl' }], + } + : {}), }, { name: 'a:stretch', diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js index 5370205165..02fec3c60f 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.test.js @@ -136,6 +136,31 @@ describe('translateImageNode', () => { const extent = result.elements.find((e) => e.name === 'wp:extent').attributes; expect(extent.cx).toBeLessThan(helpers.pixelsToEmu(500)); }); + + it('should export grayscale effect when present', () => { + baseParams.node.attrs.grayscale = true; + + const result = translateImageNode(baseParams); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + + expect(blip.elements).toBeDefined(); + expect(blip.elements).toEqual([{ name: 'a:grayscl' }]); + }); + + it('should not export grayscale element when not present', () => { + const result = translateImageNode(baseParams); + + const blip = result.elements + .find((e) => e.name === 'a:graphic') + .elements[0].elements[0].elements.find((e) => e.name === 'pic:blipFill') + .elements.find((e) => e.name === 'a:blip'); + + expect(blip.elements).toBeUndefined(); + }); }); describe('translateVectorShape', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index b4c21ecdcb..e3b911613e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -270,6 +270,9 @@ export function handleImageNode(node, params, isAnchor) { return null; } + // Check for image effects (grayscale, etc.) + const hasGrayscale = blip.elements?.some((el) => el.name === 'a:grayscl'); + // Check for stretch mode: // This tells Word to scale the image to fill the extent rectangle. // @@ -407,6 +410,7 @@ export function handleImageNode(node, params, isAnchor) { rId: relAttributes['Id'], ...(order.length ? { drawingChildOrder: order } : {}), ...(originalChildren.length ? { originalDrawingChildren: originalChildren } : {}), + ...(hasGrayscale ? { grayscale: true } : {}), }; return { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 2a623be1ab..0cca505bf1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -942,6 +942,32 @@ describe('handleImageNode', () => { expect(result.attrs.shouldCover).toBe(false); }); }); + + it('extracts grayscale effect from a:blip element', () => { + const node = makeNode(); + // Add grayscale effect to blip + const graphic = node.elements.find((el) => el.name === 'a:graphic'); + const graphicData = graphic.elements[0]; + const pic = graphicData.elements[0]; + const blipFill = pic.elements[0]; + const blip = blipFill.elements[0]; + + // Add grayscale element as child of blip + blip.elements = [{ name: 'a:grayscl' }]; + + const result = handleImageNode(node, makeParams(), false); + + expect(result).not.toBeNull(); + expect(result.attrs.grayscale).toBe(true); + }); + + it('does not set grayscale when effect is not present', () => { + const node = makeNode(); + const result = handleImageNode(node, makeParams(), false); + + expect(result).not.toBeNull(); + expect(result.attrs.grayscale).toBeUndefined(); + }); }); describe('getVectorShape', () => { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Bitmap.ts b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Bitmap.ts index a2d8a5775a..eff1d4139f 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Bitmap.ts +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Bitmap.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ /* The MIT License (MIT) @@ -168,39 +167,116 @@ export class DIBitmap implements Bitmap { return Helper._blobToBinary(view); } + private convertToPNG(bitmapData: Uint8Array, width: number, height: number, hasAlpha: boolean): string { + // Create a canvas element to convert bitmap to PNG + // This provides better browser compatibility than BMP format + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = Math.abs(height); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Unable to get canvas context for PNG conversion'); + } + + // Create ImageData object + const imageData = ctx.createImageData(width, Math.abs(height)); + const pixels = imageData.data; + + // Calculate row size with padding (for BMP format) + const rowSize = ((width * 32 + 31) >>> 5) << 2; + + // Convert BGRA to RGBA and copy to ImageData + // BMP rows are stored bottom-to-top (unless height is negative), so we need to flip + const absHeight = Math.abs(height); + const topDown = height < 0; // Negative height means top-down storage + + // Check if alpha channel is actually used (any non-zero alpha values) + let alphaUsed = false; + if (hasAlpha) { + for (let y = 0; y < absHeight && !alphaUsed; y++) { + const srcY = topDown ? y : absHeight - 1 - y; + for (let x = 0; x < width && !alphaUsed; x++) { + const srcOffset = srcY * rowSize + x * 4; + if (bitmapData[srcOffset + 3] > 0) { + alphaUsed = true; + } + } + } + } + + for (let y = 0; y < absHeight; y++) { + const srcY = topDown ? y : absHeight - 1 - y; + for (let x = 0; x < width; x++) { + const srcOffset = srcY * rowSize + x * 4; + const dstOffset = (y * width + x) * 4; + + // Convert BGRA to RGBA + pixels[dstOffset] = bitmapData[srcOffset + 2]; // R + pixels[dstOffset + 1] = bitmapData[srcOffset + 1]; // G + pixels[dstOffset + 2] = bitmapData[srcOffset]; // B + // Preserve alpha channel as-is if it's used, otherwise make it opaque + pixels[dstOffset + 3] = alphaUsed ? bitmapData[srcOffset + 3] : 255; + } + } + + ctx.putImageData(imageData, 0, 0); + + // Convert canvas to PNG data URL + return canvas.toDataURL('image/png'); + } + public base64ref(): string { const prevpos = this._reader.pos; this._reader.seek(this._offset); - let mime = 'image/bmp'; const header = this._info.header(); - let data; + + // Check if this is an embedded JPEG or PNG if (header instanceof BitmapInfoHeader && header.compression != null) { switch (header.compression) { case Helper.GDI.BitmapCompression.BI_JPEG: - mime = 'data:image/jpeg'; - break; + this._reader.seek(this._location.data.off); + const jpegData = 'data:image/jpeg;base64,' + btoa(this._reader.readBinary(this._location.data.size)); + this._reader.seek(prevpos); + return jpegData; case Helper.GDI.BitmapCompression.BI_PNG: - mime = 'data:image/png'; - break; - default: - data = this.makeBitmapFileHeader(); - break; + this._reader.seek(this._location.data.off); + const pngData = 'data:image/png;base64,' + btoa(this._reader.readBinary(this._location.data.size)); + this._reader.seek(prevpos); + return pngData; } - } else { - data = this.makeBitmapFileHeader(); } - this._reader.seek(this._location.header.offset); - if (data != null) { - data += this._reader.readBinary(this._location.header.size); - } else { - data = this._reader.readBinary(this._location.header.size); + // For 32-bit bitmaps, convert to PNG for better browser compatibility + // BMP format has poor support in browsers, especially on Linux + if (header instanceof BitmapInfoHeader && header.bitcount === 32) { + this._reader.seek(this._location.data.off); + + // Read bitmap data into a buffer + const bitmapBytes = new Uint8Array(this._location.data.size); + const bitmapData = this._reader.readBinary(this._location.data.size); + + // Convert binary string to Uint8Array + for (let i = 0; i < this._location.data.size; i++) { + bitmapBytes[i] = bitmapData.charCodeAt(i); + } + + // Convert to PNG format using canvas + // This provides much better browser compatibility than BMP + const pngDataUrl = this.convertToPNG(bitmapBytes, header.width, header.height, true); + + this._reader.seek(prevpos); + return pngDataUrl; } - this._reader.seek(this._location.data.offset); + // For other bitmap formats (not 32-bit), fall back to BMP format + let data = this.makeBitmapFileHeader(); + this._reader.seek(this._location.header.off); + data += this._reader.readBinary(this._location.header.size); + this._reader.seek(this._location.data.off); data += this._reader.readBinary(this._location.data.size); - const ref = 'data:' + mime + ';base64,' + btoa(data); + const ref = 'data:image/bmp;base64,' + btoa(data); this._reader.seek(prevpos); return ref; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/EMFRecords.ts b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/EMFRecords.ts index a96e6ec4e9..e7971a94a0 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/EMFRecords.ts +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/EMFRecords.ts @@ -27,6 +27,7 @@ SOFTWARE. */ import { Blob } from './Blob'; +import { DIBitmap } from './Bitmap'; import { GDIContext } from './GDIContext'; import { EMFJSError, Helper } from './Helper'; import { PointL, PointS, RectL, SizeL } from './Primitives'; @@ -117,6 +118,10 @@ class EmfHeader { } } + public getBounds(): RectL { + return this.bounds; + } + public toString(): string { return ( '{bounds: ' + @@ -242,11 +247,11 @@ export class EMFRecords { const cbBits = reader.readUint32(); const pen = new Pen(reader, { header: { - off: offBmi, + off: curpos + offBmi, size: cbBmi, }, data: { - off: offBits, + off: curpos + offBits, size: cbBits, }, }); @@ -494,6 +499,39 @@ export class EMFRecords { }); break; } + case Helper.GDI.RecordType.EMR_STRETCHDIBITS: { + const bounds = new RectL(reader); + const xDest = reader.readInt32(); + const yDest = reader.readInt32(); + const xSrc = reader.readInt32(); + const ySrc = reader.readInt32(); + const cxSrc = reader.readInt32(); + const cySrc = reader.readInt32(); + const offBmiSrc = reader.readUint32(); + const cbBmiSrc = reader.readUint32(); + const offBitsSrc = reader.readUint32(); + const cbBitsSrc = reader.readUint32(); + const iUsageSrc = reader.readUint32(); + const dwRop = reader.readUint32(); + const cxDest = reader.readInt32(); + const cyDest = reader.readInt32(); + + const dib = new DIBitmap(reader, { + header: { + off: curpos + offBmiSrc, + size: cbBmiSrc, + }, + data: { + off: curpos + offBitsSrc, + size: cbBitsSrc, + }, + }); + + this._records.push((gdi) => { + gdi.stretchDibBits(xSrc, ySrc, cxSrc, cySrc, xDest, yDest, cxDest, cyDest, dwRop, iUsageSrc, dib); + }); + break; + } case Helper.GDI.RecordType.EMR_POLYLINE: case Helper.GDI.RecordType.EMR_POLYLINETO: case Helper.GDI.RecordType.EMR_POLYPOLYLINE: @@ -536,7 +574,6 @@ export class EMFRecords { case Helper.GDI.RecordType.EMR_MASKBLT: case Helper.GDI.RecordType.EMR_PLGBLT: case Helper.GDI.RecordType.EMR_SETDIBITSTODEVICE: - case Helper.GDI.RecordType.EMR_STRETCHDIBITS: case Helper.GDI.RecordType.EMR_EXTCREATEFONTINDIRECTW: case Helper.GDI.RecordType.EMR_EXTTEXTOUTA: case Helper.GDI.RecordType.EMR_EXTTEXTOUTW: @@ -601,6 +638,10 @@ export class EMFRecords { } } + public getBounds(): RectL { + return this._header.getBounds(); + } + public play(gdi: GDIContext): void { const len = this._records.length; for (let i = 0; i < len; i++) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/GDIContext.ts b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/GDIContext.ts index 8d9b1a69b4..992cfca243 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/GDIContext.ts +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/GDIContext.ts @@ -28,6 +28,7 @@ SOFTWARE. import { SVG } from '../util'; import { SVGPathBuilder } from '../util/SVG'; +import { DIBitmap } from './Bitmap'; import { EMFJSError, Helper } from './Helper'; import { Obj, PointL, PointS, RectL } from './Primitives'; import { CreateSimpleRegion, Region } from './Region'; @@ -252,6 +253,10 @@ export class GDIContext { this.state._svggroup = null; } + public getMapMode(): number { + return this.state.mapmode; + } + public setWindowOrgEx(x: number, y: number): void { Helper.log('[gdi] setWindowOrgEx: x=' + x + ' y=' + y); this.state.wx = x; @@ -315,6 +320,72 @@ export class GDIContext { Helper.log('[gdi] setStretchBltMode: stretchMode=' + stretchMode); } + public stretchDibBits( + srcX: number, + srcY: number, + srcW: number, + srcH: number, + dstX: number, + dstY: number, + dstW: number, + dstH: number, + rasterOp: number, + colorUsage: number, + dib: DIBitmap, + ): void { + Helper.log( + '[gdi] stretchDibBits: srcX=' + + srcX + + ' srcY=' + + srcY + + ' srcW=' + + srcW + + ' srcH=' + + srcH + + ' dstX=' + + dstX + + ' dstY=' + + dstY + + ' dstW=' + + dstW + + ' dstH=' + + dstH + + ' rasterOp=0x' + + rasterOp.toString(16), + ); + srcX = this._todevX(srcX); + srcY = this._todevY(srcY); + srcW = this._todevW(srcW); + srcH = this._todevH(srcH); + dstX = this._todevX(dstX); + dstY = this._todevY(dstY); + dstW = this._todevW(dstW); + dstH = this._todevH(dstH); + Helper.log( + '[gdi] stretchDibBits: TRANSLATED:' + + ' srcX=' + + srcX + + ' srcY=' + + srcY + + ' srcW=' + + srcW + + ' srcH=' + + srcH + + ' dstX=' + + dstX + + ' dstY=' + + dstY + + ' dstW=' + + dstW + + ' dstH=' + + dstH + + ' rasterOp=0x' + + rasterOp.toString(16), + ); + this._pushGroup(); + this._svg.image(this.state._svggroup, dstX, dstY, dstW, dstH, dib.base64ref()); + } + public rectangle(rect: RectL, rw: number, rh: number): void { Helper.log( '[gdi] rectangle: rect=' + diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Renderer.ts b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Renderer.ts index 2ad08761c2..518aab6b3b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Renderer.ts +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/emfjs/Renderer.ts @@ -52,9 +52,26 @@ export class Renderer { public render(info: IRendererSettings): SVGElement { const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - this._render(new SVG(svgElement), info.mapMode, info.wExt, info.hExt, info.xExt, info.yExt); - svgElement.setAttribute('viewBox', [0, 0, info.xExt, info.yExt].join(' ')); - svgElement.setAttribute('preserveAspectRatio', 'none'); // TODO: MM_ISOTROPIC vs MM_ANISOTROPIC + // Get the actual bounds from the EMF file + const emfBounds = this._img.getBounds(); + + const gdi = this._render( + new SVG(svgElement), + info.mapMode, + emfBounds.width, + emfBounds.height, + emfBounds.width, + emfBounds.height, + ); + svgElement.setAttribute('viewBox', [0, 0, emfBounds.width, emfBounds.height].join(' ')); + + // Set preserveAspectRatio based on the final map mode after rendering: + // - MM_ANISOTROPIC (8): allows independent X/Y scaling (no aspect ratio preservation) + // - All other modes (including MM_ISOTROPIC): preserve aspect ratio + const finalMapMode = gdi.getMapMode(); + const preserveAspectRatio = finalMapMode === Helper.GDI.MapMode.MM_ANISOTROPIC ? 'none' : 'xMidYMid meet'; + svgElement.setAttribute('preserveAspectRatio', preserveAspectRatio); + svgElement.setAttribute('width', info.width); svgElement.setAttribute('height', info.height); return svgElement; @@ -81,7 +98,7 @@ export class Renderer { } } - private _render(svg: SVG, mapMode: number, w: number, h: number, xExt: number, yExt: number) { + private _render(svg: SVG, mapMode: number, w: number, h: number, xExt: number, yExt: number): GDIContext { const gdi = new GDIContext(svg); gdi.setWindowExtEx(w, h); gdi.setViewportExtEx(xExt, yExt); @@ -89,6 +106,7 @@ export class Renderer { Helper.log('[EMF] BEGIN RENDERING --->'); this._img.render(gdi); Helper.log('[EMF] <--- DONE RENDERING'); + return gdi; } } @@ -104,4 +122,12 @@ class EMF { public render(gdi: GDIContext): void { this._records.play(gdi); } + + public getBounds(): { width: number; height: number } { + const bounds = this._records.getBounds(); + return { + width: bounds.right - bounds.left, + height: bounds.bottom - bounds.top, + }; + } } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/index.ts b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/index.ts index 14ecd46931..5b2324ad19 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/index.ts +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/rtfjs/index.ts @@ -1,5 +1,5 @@ /* - * EMF/WMF rendering extracted from rtf.js (https://github.com/nicktf/rtf.js) + * EMF/WMF rendering extracted from rtf.js (https://github.com/nicktf/rtf.js https://github.com/GoodNotes/rtf.js) * Original MIT License - Copyright (c) 2016 Tom Zoehner, Copyright (c) 2018 Thomas Bluemel */ diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index 738cb3741e..2f502d4b12 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -190,6 +190,36 @@ export const Image = Node.create({ }, }, + /** + * @category Attribute + * @param {boolean} [grayscale] - Apply grayscale filter to image (OOXML effect) + * @private + */ + grayscale: { + default: false, + rendered: false, + }, + + /** + * @category Attribute + * @param {string|number} [gain] - VML gain for brightness/washout (watermark effect) + * @private + */ + gain: { + default: null, + rendered: false, + }, + + /** + * @category Attribute + * @param {string|number} [blacklevel] - VML blacklevel for contrast adjustment (watermark effect) + * @private + */ + blacklevel: { + default: null, + rendered: false, + }, + /** * @category Attribute * @param {boolean} [simplePos] - Simple positioning flag