Skip to content
Merged
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
2 changes: 1 addition & 1 deletion devtools/visual-testing/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 84 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,84 @@
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();
expect(parseInsetClipPathForScale('inset(1..2% 0% 0% 0%)')).toBeNull(); // malformed number token
});

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();
expect(formatInsetClipPathTransform('inset(1..2% 0% 0% 0%)')).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);');
});
});
54 changes: 54 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,54 @@
/**
* 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+(?:\.\d+)?|\.\d+)%\s+(\d+(?:\.\d+)?|\.\d+)%\s+(\d+(?:\.\d+)?|\.\d+)%\s+(\d+(?:\.\d+)?|\.\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]);
if (![top, right, bottom, left].every(Number.isFinite)) return null;
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
17 changes: 17 additions & 0 deletions packages/layout-engine/painters/dom/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ export const DOM_CLASS_NAMES = {
* Applied/removed by SdtGroupedHover to highlight all fragments of the same SDT.
*/
SDT_HOVER: 'sdt-hover',

/**
* Class name for block-level image fragments (ImageBlock).
*/
IMAGE_FRAGMENT: 'superdoc-image-fragment',

/**
* Class name for inline image elements (ImageRun inside paragraphs).
*/
INLINE_IMAGE: 'superdoc-inline-image',

/**
* Class name for the clip wrapper around cropped inline images.
* When an inline image has a clipPath, it is wrapped in a span with this class
* so the resizer and selection outline work on the visible cropped portion.
*/
INLINE_IMAGE_CLIP_WRAPPER: 'superdoc-inline-image-clip-wrapper',
} as const;

/**
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 @@ -4544,6 +4544,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
1 change: 1 addition & 0 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type { RulerOptions } from './renderer.js';
export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './renderer.js';

export { applySquareWrapExclusionsToLines } from './utils/anchor-helpers';
export { buildImagePmSelector, buildInlineImagePmSelector } from './utils/image-selectors.js';

// Re-export PM position validation utilities
export {
Expand Down
Loading
Loading