Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -297,6 +297,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 @@ -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';
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 @@ -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
Expand All @@ -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);

Expand Down Expand Up @@ -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';
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 @@ -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;
}

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';

/**
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading