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
82 changes: 82 additions & 0 deletions packages/layout-engine/contracts/src/clip-path-inset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { parseInsetClipPathForScale, formatInsetClipPathTransform } from './clip-path-inset.js';

describe('parseInsetClipPathForScale', () => {
it('returns scale and translate for valid inset(top right bottom left)', () => {
const result = parseInsetClipPathForScale('inset(10% 20% 30% 40%)');
expect(result).not.toBeNull();
// visibleW = 100 - 40 - 20 = 40, visibleH = 100 - 10 - 30 = 60
// scaleX = 100/40 = 2.5, scaleY = 100/60 = 5/3
// translateX = -40*2.5 = -100, translateY = -10*(5/3) = -50/3
expect(result!.scaleX).toBeCloseTo(2.5);
expect(result!.scaleY).toBeCloseTo(100 / 60);
expect(result!.translateX).toBeCloseTo(-100);
expect(result!.translateY).toBeCloseTo(-50 / 3);
});

it('returns scale 1 and translate 0 when no inset (full image visible)', () => {
const result = parseInsetClipPathForScale('inset(0% 0% 0% 0%)');
expect(result).not.toBeNull();
expect(result!.scaleX).toBe(1);
expect(result!.scaleY).toBe(1);
expect(result!.translateX).toBeCloseTo(0, 10);
expect(result!.translateY).toBeCloseTo(0, 10);
});

it('trims whitespace around clipPath', () => {
const result = parseInsetClipPathForScale(' inset(5% 10% 15% 20%) ');
expect(result).not.toBeNull();
expect(result!.scaleX).toBeCloseTo(100 / (100 - 20 - 10));
expect(result!.scaleY).toBeCloseTo(100 / (100 - 5 - 15));
});

it('returns null for non-inset clipPath', () => {
expect(parseInsetClipPathForScale('circle(50%)')).toBeNull();
expect(parseInsetClipPathForScale('polygon(0 0, 100% 0, 100% 100%)')).toBeNull();
expect(parseInsetClipPathForScale('')).toBeNull();
});

it('returns null for malformed inset', () => {
expect(parseInsetClipPathForScale('inset(10 20 30 40)')).toBeNull(); // no %
expect(parseInsetClipPathForScale('inset(10% 20% 30%)')).toBeNull(); // only 3 values
expect(parseInsetClipPathForScale('inset()')).toBeNull();
});

it('returns null when visible area has zero or negative size', () => {
// left + right >= 100 => visibleW <= 0
expect(parseInsetClipPathForScale('inset(0% 50% 0% 50%)')).toBeNull();
// top + bottom >= 100 => visibleH <= 0
expect(parseInsetClipPathForScale('inset(50% 0% 50% 0%)')).toBeNull();
});

it('handles decimal percentages', () => {
const result = parseInsetClipPathForScale('inset(12.5% 25.5% 12.5% 24.5%)');
expect(result).not.toBeNull();
const visibleW = 100 - 24.5 - 25.5;
const visibleH = 100 - 12.5 - 12.5;
expect(result!.scaleX).toBeCloseTo(100 / visibleW);
expect(result!.scaleY).toBeCloseTo(100 / visibleH);
});
});

describe('formatInsetClipPathTransform', () => {
it('returns CSS transform string for valid inset', () => {
const result = formatInsetClipPathTransform('inset(10% 20% 30% 40%)');
expect(result).toBeDefined();
expect(result).toContain('transform-origin: 0 0');
expect(result).toContain('transform: translate(');
expect(result).toContain('%) scale(');
expect(result).toMatch(/translate\([-\d.]+%,\s*[-\d.]+%\)/);
expect(result).toMatch(/scale\([-\d.]+,\s*[-\d.]+\)/);
});

it('returns undefined for invalid clipPath', () => {
expect(formatInsetClipPathTransform('circle(50%)')).toBeUndefined();
expect(formatInsetClipPathTransform('')).toBeUndefined();
});

it('output can be applied as inline style', () => {
const result = formatInsetClipPathTransform('inset(0% 0% 0% 0%)');
expect(result).toBe('transform-origin: 0 0; transform: translate(0%, 0%) scale(1, 1);');
});
});
49 changes: 49 additions & 0 deletions packages/layout-engine/contracts/src/clip-path-inset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Shared utilities for inset(top% right% bottom% left%) clip-path (e.g. from DOCX a:srcRect).
* Used by both the layout-engine painters and super-editor image extension so the same
* scale/translate math is applied everywhere.
*/

/** Result of parsing an inset() clip-path for scale/translate. */
export type InsetClipPathScale = {
scaleX: number;
scaleY: number;
translateX: number;
translateY: number;
};

/**
* Parses inset(top% right% bottom% left%) from a clipPath string and returns scale + translate
* so the visible clipped portion fills the container and is aligned to top-left.
*
* @param clipPath - e.g. "inset(10% 20% 30% 40%)"
* @returns Scale and translate values, or null if not a valid inset()
*/
export function parseInsetClipPathForScale(clipPath: string): InsetClipPathScale | null {
const m = clipPath.trim().match(/^inset\(\s*([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s+([\d.]+)%\s*\)$/);
if (!m) return null;
const top = Number(m[1]);
const right = Number(m[2]);
const bottom = Number(m[3]);
const left = Number(m[4]);
const visibleW = 100 - left - right;
const visibleH = 100 - top - bottom;
if (visibleW <= 0 || visibleH <= 0) return null;
const scaleX = 100 / visibleW;
const scaleY = 100 / visibleH;
const translateX = -left * scaleX;
const translateY = -top * scaleY;
return { scaleX, scaleY, translateX, translateY };
}

/**
* Builds the CSS transform-origin and transform string from a parsed inset scale result.
*
* @param clipPath - e.g. "inset(10% 20% 30% 40%)"
* @returns CSS fragment: "transform-origin: 0 0; transform: translate(...) scale(...);"
*/
export function formatInsetClipPathTransform(clipPath: string): string | undefined {
const scale = parseInsetClipPathForScale(clipPath);
if (!scale) return undefined;
return `transform-origin: 0 0; transform: translate(${scale.translateX}%, ${scale.translateY}%) scale(${scale.scaleX}, ${scale.scaleY});`;
}
9 changes: 9 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export {
type CalculateJustifySpacingParams,
} from './justify-utils.js';

export {
parseInsetClipPathForScale,
formatInsetClipPathTransform,
type InsetClipPathScale,
} from './clip-path-inset.js';

export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';
/** Inline field annotation metadata extracted from w:sdt nodes. */
export type FieldAnnotationMetadata = {
Expand Down Expand Up @@ -262,6 +268,8 @@ export type ImageRun = {
alt?: string;
/** Image title (tooltip). */
title?: string;
/** Clip-path value for cropped images. */
clipPath?: string;

/**
* Spacing around the image (from DOCX distT/distB/distL/distR attributes).
Expand Down Expand Up @@ -695,6 +703,7 @@ export type ShapeGroupImageChild = {
attrs: PositionedDrawingGeometry & {
src: string;
alt?: string;
clipPath?: string;
imageId?: string;
imageName?: string;
};
Expand Down
71 changes: 71 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4527,6 +4527,77 @@ describe('DomPainter', () => {
expect(img).toBeNull();
});

it('renders cropped inline image with clipPath in wrapper (overflow hidden, img with clip-path and transform)', () => {
const clipPath = 'inset(10% 20% 30% 40%)';
const imageBlock: FlowBlock = {
kind: 'paragraph',
id: 'img-block',
runs: [
{
kind: 'image',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 80,
height: 60,
clipPath,
},
],
};

const imageMeasure: Measure = {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 0,
width: 80,
ascent: 60,
descent: 0,
lineHeight: 60,
},
],
totalHeight: 60,
};

const imageLayout: Layout = {
pageSize: { w: 400, h: 500 },
pages: [
{
number: 1,
fragments: [
{
kind: 'para',
blockId: 'img-block',
fromLine: 0,
toLine: 1,
x: 0,
y: 0,
width: 80,
},
],
},
],
};

const painter = createDomPainter({ blocks: [imageBlock], measures: [imageMeasure] });
painter.paint(imageLayout, mount);

const wrapper = mount.querySelector('.superdoc-inline-image-clip-wrapper');
expect(wrapper).toBeTruthy();
expect((wrapper as HTMLElement).style.overflow).toBe('hidden');
expect((wrapper as HTMLElement).style.width).toBe('80px');
expect((wrapper as HTMLElement).style.height).toBe('60px');

const img = wrapper?.querySelector('img');
expect(img).toBeTruthy();
expect((img as HTMLElement).style.clipPath).toBe(clipPath);
expect((img as HTMLElement).style.transformOrigin).toBe('0 0');
expect((img as HTMLElement).style.transform).toMatch(
/translate\([-\d.]+%,\s*[-\d.]+%\)\s*scale\([-\d.]+,\s*[-\d.]+\)/,
);
});

it('returns null for data URLs exceeding MAX_DATA_URL_LENGTH (10MB)', () => {
// Create a data URL that exceeds 10MB
const largeBase64 = 'A'.repeat(10 * 1024 * 1024 + 1);
Expand Down
64 changes: 60 additions & 4 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { DOM_CLASS_NAMES } from './constants.js';
import { sanitizeHref, encodeTooltip } from '@superdoc/url-validation';
import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js';
import { assertPmPositions, assertFragmentPmPositions } from './pm-position-validation.js';
import { applyImageClipPath } from './utils/image-clip-path.js';
import { applySdtContainerStyling, getSdtContainerKey, type SdtBoundaryOptions } from './utils/sdt-helpers.js';
import {
generateRulerDefinitionFromPx,
Expand Down Expand Up @@ -2594,6 +2595,11 @@ export class DomPainter {
fragmentEl.setAttribute('data-image-metadata', JSON.stringify(fragment.metadata));
}

// When clipPath is applied we scale the image so the cropped portion fills the box; clip overflow so it doesn't overlap text
if (block.attrs?.clipPath) {
fragmentEl.style.overflow = 'hidden';
}

// behindDoc images are supported via z-index; suppress noisy debug logs

const img = this.doc.createElement('img');
Expand All @@ -2608,6 +2614,7 @@ export class DomPainter {
if (block.objectFit === 'cover') {
img.style.objectPosition = 'left top';
}
applyImageClipPath(img, block.attrs?.clipPath);
img.style.display = block.display === 'inline' ? 'inline-block' : 'block';

// Apply VML image adjustments (gain/blacklevel) as CSS filters for watermark effects
Expand Down Expand Up @@ -2735,6 +2742,7 @@ export class DomPainter {
if (drawing.objectFit === 'cover') {
img.style.objectPosition = 'left top';
}
applyImageClipPath(img, drawing.attrs?.clipPath);
img.style.display = 'block';
return img;
}
Expand Down Expand Up @@ -3384,12 +3392,14 @@ export class DomPainter {
const attrs = child.attrs as PositionedDrawingGeometry & {
src: string;
alt?: string;
clipPath?: string;
};
const img = this.doc!.createElement('img');
img.src = attrs.src;
img.alt = attrs.alt ?? '';
img.style.objectFit = 'contain';
img.style.display = 'block';
applyImageClipPath(img, attrs.clipPath);
return img;
}
return this.createDrawingPlaceholder();
Expand Down Expand Up @@ -3872,6 +3882,8 @@ export class DomPainter {
return null;
}

const hasClipPath = typeof run.clipPath === 'string' && run.clipPath.trim().length > 0;

// Create img element
const img = this.doc.createElement('img');
img.classList.add('superdoc-inline-image');
Expand Down Expand Up @@ -3901,9 +3913,20 @@ export class DomPainter {
}
}

// Set dimensions
img.width = run.width;
img.height = run.height;
// Set dimensions: when we have clipPath we put img in a wrapper that has the layout size and overflow:hidden; img fills wrapper so cropped portion stays within after resize
if (!hasClipPath) {
img.width = run.width;
img.height = run.height;
} else {
img.style.width = '100%';
img.style.height = '100%';
img.style.maxWidth = '100%';
img.style.maxHeight = '100%';
img.style.boxSizing = 'border-box';
img.style.minWidth = '0';
img.style.minHeight = '0';
}
applyImageClipPath(img, run.clipPath);

// Add metadata for interactive image resizing (inline images)
// Only add metadata if dimensions are valid (positive, non-zero values)
Expand Down Expand Up @@ -3959,7 +3982,40 @@ export class DomPainter {
// Assert PM positions are present for cursor fallback
assertPmPositions(run, 'inline image run');

// Apply PM position tracking for cursor placement
// When clipPath is set, scale makes the img paint outside its box;
// wrap in a clip container so only the cropped portion occupies space in the document.
// Wrapper size is the only layout box (position calculation uses run.width/run.height).
// PM position attributes go on the wrapper only so selection highlight and selection rects use the wrapper, not the scaled img.
if (hasClipPath && run.width > 0 && run.height > 0) {
// Margins live on the wrapper only; img must fill wrapper exactly so transform percentages stay correct after resize
img.style.marginTop = '0';
img.style.marginBottom = '0';
img.style.marginLeft = '0';
img.style.marginRight = '0';
img.style.verticalAlign = 'unset';
const wrapper = this.doc.createElement('span');
wrapper.classList.add('superdoc-inline-image-clip-wrapper');
wrapper.style.display = 'inline-block';
wrapper.style.width = `${run.width}px`;
wrapper.style.height = `${run.height}px`;
wrapper.style.boxSizing = 'border-box';
wrapper.style.overflow = 'hidden';
wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom';
if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`;
if (run.distBottom) wrapper.style.marginBottom = `${run.distBottom}px`;
if (run.distLeft) wrapper.style.marginLeft = `${run.distLeft}px`;
if (run.distRight) wrapper.style.marginRight = `${run.distRight}px`;
wrapper.style.zIndex = '1';
if (run.pmStart != null) wrapper.dataset.pmStart = String(run.pmStart);
if (run.pmEnd != null) wrapper.dataset.pmEnd = String(run.pmEnd);
wrapper.dataset.layoutEpoch = String(this.layoutEpoch);
this.applySdtDataset(wrapper, run.sdt);
if (run.dataAttrs) applyRunDataAttributes(wrapper, run.dataAttrs);
wrapper.appendChild(img);
return wrapper;
}

// Apply PM position tracking for cursor placement (only on img when not wrapped)
if (run.pmStart != null) {
img.dataset.pmStart = String(run.pmStart);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,11 @@ const IMAGE_SELECTION_STYLES = `
.superdoc-inline-image.superdoc-image-selected {
outline-offset: 2px;
}

/* Selection on clip wrapper so outline matches the visible cropped portion, not the scaled image */
.superdoc-inline-image-clip-wrapper.superdoc-image-selected {
outline-offset: 2px;
}
`;

/**
Expand Down
Loading