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 @@ -267,6 +273,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 @@ -702,6 +710,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 @@ -4528,6 +4528,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
Loading
Loading