diff --git a/packages/layout-engine/contracts/src/clip-path-inset.test.ts b/packages/layout-engine/contracts/src/clip-path-inset.test.ts new file mode 100644 index 0000000000..e25fc0d14f --- /dev/null +++ b/packages/layout-engine/contracts/src/clip-path-inset.test.ts @@ -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);'); + }); +}); diff --git a/packages/layout-engine/contracts/src/clip-path-inset.ts b/packages/layout-engine/contracts/src/clip-path-inset.ts new file mode 100644 index 0000000000..ddafd9f69e --- /dev/null +++ b/packages/layout-engine/contracts/src/clip-path-inset.ts @@ -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});`; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index eaebd6444c..6aefe34944 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -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 = { @@ -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). @@ -695,6 +703,7 @@ export type ShapeGroupImageChild = { attrs: PositionedDrawingGeometry & { src: string; alt?: string; + clipPath?: string; imageId?: string; imageName?: string; }; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b2b2513dc7..91d58af601 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -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); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5b69abb9be..79a212443c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -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, @@ -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'); @@ -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 @@ -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; } @@ -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(); @@ -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'); @@ -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) @@ -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); } diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 0ea2ce6e88..3c73e6b6cb 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -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; +} `; /** diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 96ad375ba9..434aaf9d5b 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -18,6 +18,7 @@ import type { RenderedLineInfo, } from '@superdoc/contracts'; import { applyCellBorders } from './border-utils.js'; +import { applyImageClipPath } from '../utils/image-clip-path.js'; import type { FragmentRenderContext, BlockLookup } from '../renderer.js'; import { applyParagraphBorderStyles, applyParagraphShadingStyles } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; @@ -775,6 +776,9 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen imageWrapper.style.height = `${blockMeasure.height}px`; imageWrapper.style.maxWidth = '100%'; imageWrapper.style.boxSizing = 'border-box'; + if ((block as ImageBlock).attrs?.clipPath) { + imageWrapper.style.overflow = 'hidden'; + } applySdtDataset(imageWrapper, (block as ImageBlock).attrs?.sdt); const imgEl = doc.createElement('img'); @@ -790,6 +794,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (block.objectFit === 'cover') { imgEl.style.objectPosition = 'left top'; } + applyImageClipPath(imgEl, block.attrs?.clipPath); imgEl.style.display = 'block'; imageWrapper.appendChild(imgEl); @@ -833,6 +838,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (block.objectFit === 'cover') { img.style.objectPosition = 'left top'; } + applyImageClipPath(img, block.attrs?.clipPath); drawingInner.appendChild(img); } else if (renderDrawingContent) { // Use the callback for other drawing types (vectorShape, shapeGroup, etc.) @@ -1095,6 +1101,9 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen imageWrapper.style.maxWidth = '100%'; imageWrapper.style.boxSizing = 'border-box'; imageWrapper.style.zIndex = String(zIndex); + if (anchoredBlock.attrs?.clipPath) { + imageWrapper.style.overflow = 'hidden'; + } applySdtDataset(imageWrapper, anchoredBlock.attrs?.sdt); const imgEl = doc.createElement('img'); @@ -1109,6 +1118,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (anchoredBlock.objectFit === 'cover') { imgEl.style.objectPosition = 'left top'; } + applyImageClipPath(imgEl, anchoredBlock.attrs?.clipPath); imgEl.style.display = 'block'; imageWrapper.appendChild(imgEl); content.appendChild(imageWrapper); @@ -1144,6 +1154,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen if (anchoredBlock.objectFit === 'cover') { img.style.objectPosition = 'left top'; } + applyImageClipPath(img, anchoredBlock.attrs?.clipPath); drawingInner.appendChild(img); } else if (renderDrawingContent) { const drawingContent = renderDrawingContent(anchoredBlock as DrawingBlock); diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts b/packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts new file mode 100644 index 0000000000..23fb4eb969 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for image clip-path helpers: resolveClipPath and applyImageClipPath. + * These are used when rendering cropped images (a:srcRect / inset clip-path). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { resolveClipPath, applyImageClipPath } from './image-clip-path.js'; + +describe('resolveClipPath', () => { + it('returns trimmed string for non-empty string value', () => { + expect(resolveClipPath('inset(10% 20% 30% 40%)')).toBe('inset(10% 20% 30% 40%)'); + expect(resolveClipPath(' inset(0% 0% 0% 0%) ')).toBe('inset(0% 0% 0% 0%)'); + }); + + it('returns undefined for empty or whitespace-only string', () => { + expect(resolveClipPath('')).toBeUndefined(); + expect(resolveClipPath(' ')).toBeUndefined(); + }); + + it('returns undefined for non-string values', () => { + expect(resolveClipPath(null)).toBeUndefined(); + expect(resolveClipPath(undefined)).toBeUndefined(); + expect(resolveClipPath(123)).toBeUndefined(); + expect(resolveClipPath({})).toBeUndefined(); + }); +}); + +describe('applyImageClipPath', () => { + let el: HTMLElement; + + beforeEach(() => { + el = document.createElement('div'); + }); + + it('applies clip-path and transform for valid inset() value', () => { + applyImageClipPath(el, 'inset(10% 20% 30% 40%)'); + expect(el.style.clipPath).toBe('inset(10% 20% 30% 40%)'); + expect(el.style.transformOrigin).toBe('0 0'); + expect(el.style.transform).toMatch(/translate\([-\d.]+%,\s*[-\d.]+%\)\s*scale\([-\d.]+,\s*[-\d.]+\)/); + }); + + it('applies only clip-path when value is not inset() (no scale)', () => { + applyImageClipPath(el, 'circle(50%)'); + expect(el.style.clipPath).toBe('circle(50%)'); + expect(el.style.transformOrigin).toBe(''); + expect(el.style.transform).toBe(''); + }); + + it('does nothing for empty or invalid value', () => { + applyImageClipPath(el, ''); + expect(el.style.clipPath).toBe(''); + applyImageClipPath(el, null as unknown); + expect(el.style.clipPath).toBe(''); + }); + + it('cropped portion math: full inset 0,0,0,0 gives identity transform', () => { + applyImageClipPath(el, 'inset(0% 0% 0% 0%)'); + expect(el.style.transform).toBe('translate(0%, 0%) scale(1, 1)'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts b/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts new file mode 100644 index 0000000000..00fc4daa40 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts @@ -0,0 +1,27 @@ +import { parseInsetClipPathForScale } from '@superdoc/contracts'; + +/** + * Resolves a clip-path value to a trimmed non-empty string, or undefined if invalid. + */ +export function resolveClipPath(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Applies clip-path and optional scale/translate (for inset() srcRect) to an element. + * When the clipPath is inset(top% right% bottom% left%), also sets transform so the + * visible portion fills the element and is aligned to top-left. + */ +export function applyImageClipPath(el: HTMLElement, clipPath: unknown): void { + const resolved = resolveClipPath(clipPath); + if (resolved) { + el.style.clipPath = resolved; + const scale = parseInsetClipPathForScale(resolved); + if (scale) { + el.style.transformOrigin = '0 0'; + el.style.transform = `translate(${scale.translateX}%, ${scale.translateY}%) scale(${scale.scaleX}, ${scale.scaleY})`; + } + } +} diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index 74966ed1c4..4472720cdd 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -101,6 +101,7 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter // Optional properties if (typeof attrs.alt === 'string') run.alt = attrs.alt; if (typeof attrs.title === 'string') run.title = attrs.title; + if (typeof attrs.clipPath === 'string') run.clipPath = attrs.clipPath; // Spacing attributes (from wrap.attrs.distT/distB/distL/distR) const distTop = pickNumber(wrapAttrs.distTop ?? wrapAttrs.distT); diff --git a/packages/super-editor/src/components/ImageResizeOverlay.vue b/packages/super-editor/src/components/ImageResizeOverlay.vue index d423bdaec7..09c8d98b67 100644 --- a/packages/super-editor/src/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/components/ImageResizeOverlay.vue @@ -267,7 +267,11 @@ function parseImageMetadata() { } try { - const metadataAttr = props.imageElement.getAttribute('data-image-metadata'); + // When image has clipPath the overlay receives the wrapper; metadata is on the inner img + const metaEl = props.imageElement.hasAttribute('data-image-metadata') + ? props.imageElement + : props.imageElement.querySelector?.('[data-image-metadata]'); + const metadataAttr = metaEl?.getAttribute?.('data-image-metadata'); if (!metadataAttr) { imageMetadata.value = null; return; @@ -300,9 +304,14 @@ function parseImageMetadata() { imageMetadata.value = parsed; } catch (error) { imageMetadata.value = null; + const metaElForError = props.imageElement?.hasAttribute('data-image-metadata') + ? props.imageElement + : props.imageElement?.querySelector?.('[data-image-metadata]'); emit('resize-error', { error: error instanceof Error ? error.message : 'Failed to parse image metadata', - rawMetadata: props.imageElement?.getAttribute('data-image-metadata'), + rawMetadata: + metaElForError?.getAttribute?.('data-image-metadata') ?? + props.imageElement?.getAttribute?.('data-image-metadata'), }); } } diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index a5abebabca..2238ab1bc8 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -596,14 +596,25 @@ const updateImageResizeOverlay = (event: MouseEvent): void => { return; } - // Check for inline images (ImageRun inside paragraphs) - if (target.classList?.contains('superdoc-inline-image') && target.hasAttribute('data-image-metadata')) { + // Check for clip wrapper first (cropped inline image): use wrapper so resizer works on cropped portion + if ( + target.classList?.contains('superdoc-inline-image-clip-wrapper') && + target.querySelector?.('[data-image-metadata]') + ) { imageResizeState.visible = true; imageResizeState.imageElement = target as HTMLElement; - // Inline images don't have block IDs, use pmStart as identifier imageResizeState.blockId = target.getAttribute('data-pm-start'); return; } + // Check for inline images (ImageRun inside paragraphs). When image has clipPath it is wrapped; + // use the wrapper so the resizer works on the cropped portion's box. + if (target.classList?.contains('superdoc-inline-image') && target.hasAttribute('data-image-metadata')) { + imageResizeState.visible = true; + const wrapper = target.closest?.('.superdoc-inline-image-clip-wrapper') as HTMLElement | null; + imageResizeState.imageElement = (wrapper ?? target) as HTMLElement; + imageResizeState.blockId = (wrapper ?? target).getAttribute('data-pm-start'); + return; + } target = target.parentElement; } @@ -815,13 +826,18 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = if (imageResizeState.visible && imageResizeState.blockId) { // Re-acquire element reference (may have been recreated after re-render) const escapedBlockId = CSS.escape(imageResizeState.blockId); - const newElement = editorElem.value?.querySelector( + let newElement = editorElem.value?.querySelector( `.superdoc-image-fragment[data-sd-block-id="${escapedBlockId}"]`, ); + if (!newElement) { + // Inline images (and cropped inline use wrapper): re-acquire by pmStart + newElement = editorElem.value?.querySelector( + `.superdoc-inline-image-clip-wrapper[data-pm-start="${escapedBlockId}"], .superdoc-inline-image[data-pm-start="${escapedBlockId}"]`, + ); + } if (newElement) { - imageResizeState.imageElement = newElement; + imageResizeState.imageElement = newElement as HTMLElement; } else { - // Image virtualized away - hide overlay imageResizeState.visible = false; imageResizeState.imageElement = null; imageResizeState.blockId = null; @@ -838,7 +854,7 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } = } else { // Try pmStart-based re-acquisition (inline images) if (selectedImageState.pmStart != null) { - const pmSelector = `.superdoc-image-fragment[data-pm-start="${selectedImageState.pmStart}"], .superdoc-inline-image[data-pm-start="${selectedImageState.pmStart}"]`; + const pmSelector = `.superdoc-image-fragment[data-pm-start="${selectedImageState.pmStart}"], .superdoc-inline-image-clip-wrapper[data-pm-start="${selectedImageState.pmStart}"], .superdoc-inline-image[data-pm-start="${selectedImageState.pmStart}"]`; const pmElement = editorElem.value?.querySelector(pmSelector); if (pmElement) { setSelectedImage(pmElement, selectedImageState.blockId, selectedImageState.pmStart); diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index c613bfd6d4..bb95fb4369 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1287,10 +1287,13 @@ export class EditorInputManager { ): boolean { if (!targetImg) return false; - const imgPmStart = targetImg.dataset?.pmStart ? Number(targetImg.dataset.pmStart) : null; + // When image has clipPath it is wrapped in .superdoc-inline-image-clip-wrapper; pm-start is on the wrapper + const wrapper = targetImg.closest?.('.superdoc-inline-image-clip-wrapper') as HTMLElement | null; + const pmStartSource = wrapper ?? targetImg; + const imgPmStart = pmStartSource?.dataset?.pmStart ? Number(pmStartSource.dataset.pmStart) : null; if (Number.isNaN(imgPmStart) || imgPmStart == null) return false; - const imgLayoutEpochRaw = targetImg.dataset?.layoutEpoch; + const imgLayoutEpochRaw = pmStartSource?.dataset?.layoutEpoch; const imgLayoutEpoch = imgLayoutEpochRaw != null ? Number(imgLayoutEpochRaw) : NaN; const rawLayoutEpoch = Number.isFinite(rawHit.layoutEpoch) ? rawHit.layoutEpoch : NaN; const effectiveEpoch = @@ -1322,11 +1325,14 @@ export class EditorInputManager { const tr = editor!.state.tr.setSelection(NodeSelection.create(doc, clampedImgPos)); editor!.view?.dispatch(tr); - const selector = `.superdoc-inline-image[data-pm-start="${imgPmStart}"]`; + // Prefer wrapper (clip container) so selection outline is on the visible cropped box only, not the full image const viewportHost = this.#deps?.getViewportHost(); - const targetElement = viewportHost?.querySelector(selector); + const wrapperSelector = `.superdoc-inline-image-clip-wrapper[data-pm-start="${imgPmStart}"]`; + const inlineSelector = `.superdoc-inline-image[data-pm-start="${imgPmStart}"]`; + const targetElement = viewportHost?.querySelector(wrapperSelector) ?? viewportHost?.querySelector(inlineSelector); + const elementForHighlight = (wrapper ?? targetElement ?? targetImg) as HTMLElement; this.#callbacks.emit?.('imageSelected', { - element: targetElement ?? targetImg, + element: elementForHighlight, blockId: null, pmStart: clampedImgPos, }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js index ba127ed394..b53847ef06 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/decode-image-node-helpers.js @@ -125,6 +125,8 @@ export const translateImageNode = (params) => { } } + const rawSrcRect = attrs.rawSrcRect; + const drawingXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const pictureXmlns = 'http://schemas.openxmlformats.org/drawingml/2006/picture'; @@ -206,6 +208,7 @@ export const translateImageNode = (params) => { 'r:embed': imageId, }, }, + ...(rawSrcRect ? [rawSrcRect] : []), { name: 'a:stretch', elements: [{ name: 'a:fillRect' }], diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index b4c21ecdcb..33df55e9a8 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -72,6 +72,41 @@ const extractEffectExtent = (node) => { return { left, top, right, bottom }; }; +const buildClipPathFromSrcRect = (srcRectAttrs = {}) => { + const edges = { + left: srcRectAttrs.l, + top: srcRectAttrs.t, + right: srcRectAttrs.r, + bottom: srcRectAttrs.b, + }; + + let hasValue = false; + let hasPositive = false; + const percentEdges = {}; + + for (const [edge, value] of Object.entries(edges)) { + if (value == null) continue; + const numeric = Number(value); + if (!Number.isFinite(numeric)) continue; + hasValue = true; + if (numeric < 0) { + return null; + } + const percent = Math.max(0, Math.min(100, numeric / 1000)); + if (percent > 0) hasPositive = true; + percentEdges[edge] = percent; + } + + if (!hasValue || !hasPositive) return null; + + const top = percentEdges.top ?? 0; + const right = percentEdges.right ?? 0; + const bottom = percentEdges.bottom ?? 0; + const left = percentEdges.left ?? 0; + + return `inset(${top}% ${right}% ${bottom}% ${left}%)`; +}; + /** * Encodes image XML into Editor node. * @@ -278,12 +313,12 @@ export function handleImageNode(node, params, isAnchor) { // - Negative values (e.g., b="-3978"): Word extended the mapping (image doesn't need clipping) // - Empty/no srcRect: no pre-adjustment, use cover+clip for aspect ratio mismatch // - // Since we don't implement actual srcRect cropping, we still need cover mode for positive values. // Only skip cover mode when srcRect has negative values (Word already adjusted the mapping). const stretch = blipFill?.elements?.find((el) => el.name === 'a:stretch'); const fillRect = stretch?.elements?.find((el) => el.name === 'a:fillRect'); const srcRect = blipFill?.elements?.find((el) => el.name === 'a:srcRect'); const srcRectAttrs = srcRect?.attributes || {}; + const clipPath = buildClipPathFromSrcRect(srcRectAttrs); // Check if srcRect has negative values (indicating Word extended/adjusted the image mapping) const srcRectHasNegativeValues = ['l', 't', 'r', 'b'].some((attr) => { @@ -397,6 +432,8 @@ export function handleImageNode(node, params, isAnchor) { : {}), wrapTopAndBottom: wrap.type === 'TopAndBottom', shouldCover, + ...(clipPath ? { clipPath } : {}), + rawSrcRect: srcRect, originalPadding: { distT: attributes['distT'], distB: attributes['distB'], diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 2a623be1ab..ad58b660d9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -767,6 +767,42 @@ describe('handleImageNode', () => { expect(result.attrs.shouldCover).toBe(true); }); + it('sets clipPath when srcRect has positive values', () => { + const node = makeNodeWithBlipFill([ + { + name: 'a:stretch', + elements: [{ name: 'a:fillRect' }], + }, + { + name: 'a:srcRect', + attributes: { r: '84800' }, + }, + ]); + + const result = handleImageNode(node, makeParams(), false); + + expect(result).not.toBeNull(); + expect(result.attrs.clipPath).toBe('inset(0% 84.8% 0% 0%)'); + }); + + it('does not set clipPath when srcRect has negative values', () => { + const node = makeNodeWithBlipFill([ + { + name: 'a:stretch', + elements: [{ name: 'a:fillRect' }], + }, + { + name: 'a:srcRect', + attributes: { b: '-3978' }, + }, + ]); + + const result = handleImageNode(node, makeParams(), false); + + expect(result).not.toBeNull(); + expect(result.attrs.clipPath).toBeUndefined(); + }); + it('sets shouldCover=true when stretch+fillRect with multiple positive srcRect values', () => { const node = makeNodeWithBlipFill([ { diff --git a/packages/super-editor/src/extensions/image/image.js b/packages/super-editor/src/extensions/image/image.js index 742c56eb61..1aef5343a5 100644 --- a/packages/super-editor/src/extensions/image/image.js +++ b/packages/super-editor/src/extensions/image/image.js @@ -1,4 +1,5 @@ import { Attribute, Node } from '@core/index.js'; +import { formatInsetClipPathTransform } from '@superdoc/contracts'; import { ImageRegistrationPlugin } from './imageHelpers/imageRegistrationPlugin.js'; import { ImagePositionPlugin } from './imageHelpers/imagePositionPlugin.js'; import { getNormalizedImageAttrs } from './imageHelpers/legacyAttributes.js'; @@ -202,6 +203,24 @@ export const Image = Node.create({ rendered: false, }, + clipPath: { + default: null, + renderDOM: (attrs) => { + const clipPath = attrs.clipPath; + if (typeof clipPath !== 'string' || clipPath.trim().length === 0) { + return {}; + } + // When we have size we render a wrapper in renderDOM; clip-path and scale go on the inner img only, so don't add here + if (attrs.size?.width && attrs.size?.height) { + return {}; + } + let style = `clip-path: ${clipPath};`; + const scaleStyle = formatInsetClipPathTransform(clipPath); + if (scaleStyle) style += ` ${scaleStyle}`; + return { style }; + }, + }, + size: { default: {}, renderDOM: ({ size, shouldCover }) => { @@ -244,6 +263,10 @@ export const Image = Node.create({ default: null, rendered: false, }, + rawSrcRect: { + default: null, + rendered: false, + }, }; }, @@ -545,6 +568,34 @@ export const Image = Node.create({ finalAttributes.style = existingStyle + (existingStyle ? ' ' : '') + style; } + const clipPath = node.attrs.clipPath; + const hasClipPath = typeof clipPath === 'string' && clipPath.trim().length > 0; + const { width: sizeW, height: sizeH } = size ?? {}; + + // When clipPath is set we scale the image so the cropped portion fills the box; wrap in a container so only that portion occupies space and overflow is hidden. Resize updates node size so wrapper gets new dimensions and cropped portion stays within. + if (hasClipPath && sizeW > 0 && sizeH > 0) { + const wrapperStyle = + (finalAttributes.style || '') + + ' overflow: hidden; width: ' + + sizeW + + 'px; height: ' + + sizeH + + 'px; display: inline-block; box-sizing: border-box;'; + const imgInnerStyle = + 'width: 100%; height: 100%; max-width: 100%; max-height: 100%; min-width: 0; min-height: 0; box-sizing: border-box;' + + ' clip-path: ' + + clipPath + + '; ' + + (formatInsetClipPathTransform(clipPath) || ''); + const imgAttrs = Attribute.mergeAttributes(this.options.htmlAttributes, { + src: this.storage.media[node.attrs.src] ?? node.attrs.src, + alt: node.attrs.alt ?? 'Uploaded picture', + title: node.attrs.title ?? undefined, + style: imgInnerStyle, + }); + return ['span', { ...finalAttributes, style: wrapperStyle }, ['img', imgAttrs]]; + } + return ['img', Attribute.mergeAttributes(this.options.htmlAttributes, finalAttributes)]; }, diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index dbb95aa8fc..3571e52a19 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -483,6 +483,8 @@ export interface ImageAttrs extends ShapeNodeAttributes { originalSrc?: string; /** @internal Should use cover+clip mode (from empty srcRect with stretch/fillRect) */ shouldCover?: boolean; + /** @internal Clip-path value for srcRect image crops */ + clipPath?: string; } // ============================================