Skip to content
17 changes: 17 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,17 @@ export type ImageRun = {
* Custom data attributes propagated from ProseMirror marks (keys must be data-*).
*/
dataAttrs?: Record<string, string>;

// 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 = {
Expand Down Expand Up @@ -548,6 +559,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';
Expand Down
111 changes: 108 additions & 3 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3032,10 +3032,51 @@ export class DomPainter {
applyImageClipPath(img, imageClipPath, { clipContainer: fragmentEl });
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
Expand All @@ -3054,10 +3095,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);

Expand Down Expand Up @@ -4414,6 +4455,70 @@ export class DomPainter {
img.style.zIndex = '1';
}

// 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(' ');
}

// Assert PM positions are present for cursor fallback
assertPmPositions(run, 'inline image run');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1385,11 +1385,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen
* indentation but skips marker text rendering).
*/
const shouldRenderMarker =
markerLayout &&
markerMeasure &&
lineIdx === 0 &&
localStartLine === 0 &&
markerMeasure.markerWidth > 0;
markerLayout && markerMeasure && lineIdx === 0 && localStartLine === 0 && markerMeasure.markerWidth > 0;

if (shouldRenderMarker) {
// Prepend marker + suffix inside lineEl (mirrors renderer.ts approach)
Expand Down
80 changes: 80 additions & 0 deletions packages/layout-engine/pm-adapter/src/converters/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
11 changes: 11 additions & 0 deletions packages/layout-engine/pm-adapter/src/converters/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,11 @@ export function imageNodeToBlock(
const zIndexFromRelativeHeight = normalizeZIndex(attrs.originalAttributes as Record<string, unknown> | 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'),
Expand All @@ -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 }),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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;
}

Expand Down
14 changes: 12 additions & 2 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
@@ -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';
import { DOCX } from '@superdoc/common';
import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js';
Expand Down Expand Up @@ -61,9 +61,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');
Expand Down
Loading
Loading