From 5279b00b24906264f14a334c027c6abf488b7f9c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:05:47 -0300 Subject: [PATCH 01/14] refactor(painters/dom): unify image block rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract image element creation, clip-path resolution, and hyperlink-anchor wrapping into shared helpers under `painters/dom/src/images/`, and route the renderer's block/drawing image paths plus renderTableCell's flowing, drawing, and anchored image paths through them. Eliminates five near-identical copies of the same img setup (objectFit, cover→left-top, clip-path, filters, hyperlink wrap) so behavior stays consistent across all image surfaces. Adds coverage for clip-path, filters, and hyperlinks on both flowing and anchored images inside table cells (SD-2838). --- .../painters/dom/src/images/hyperlink.ts | 49 +++++ .../dom/src/images/image-block.test.ts | 21 ++ .../painters/dom/src/images/image-block.ts | 76 +++++++ .../painters/dom/src/renderer.ts | 128 ++---------- .../dom/src/table/renderTableCell.test.ts | 186 ++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 104 +++++----- 6 files changed, 397 insertions(+), 167 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/images/hyperlink.ts create mode 100644 packages/layout-engine/painters/dom/src/images/image-block.test.ts create mode 100644 packages/layout-engine/painters/dom/src/images/image-block.ts diff --git a/packages/layout-engine/painters/dom/src/images/hyperlink.ts b/packages/layout-engine/painters/dom/src/images/hyperlink.ts new file mode 100644 index 0000000000..2ebb26c4da --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/hyperlink.ts @@ -0,0 +1,49 @@ +import type { ImageHyperlink } from '@superdoc/contracts'; +import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; + +export const buildImageHyperlinkAnchor = ( + doc: Document, + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +): HTMLElement => { + if (!hyperlink?.url) return imageEl; + + const sanitized = sanitizeHref(hyperlink.url); + if (!sanitized?.href) return imageEl; + + const anchor = doc.createElement('a'); + anchor.href = sanitized.href; + anchor.classList.add('superdoc-link'); + + if (sanitized.protocol === 'http' || sanitized.protocol === 'https') { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + + const tooltipSource = + typeof hyperlink.tooltip === 'string' && hyperlink.tooltip.trim().length > 0 ? hyperlink.tooltip : hyperlink.url; + const tooltipResult = encodeTooltip(tooltipSource); + if (tooltipResult?.text) { + anchor.title = tooltipResult.text; + } + + for (const titledElement of [imageEl, ...Array.from(imageEl.querySelectorAll('[title]'))]) { + titledElement.removeAttribute('title'); + } + + anchor.setAttribute('role', 'link'); + anchor.setAttribute('tabindex', '0'); + + if (display === 'block') { + anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;'; + } else { + anchor.style.display = 'inline-block'; + anchor.style.lineHeight = '0'; + anchor.style.cursor = 'pointer'; + anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom'; + } + + anchor.appendChild(imageEl); + return anchor; +}; diff --git a/packages/layout-engine/painters/dom/src/images/image-block.test.ts b/packages/layout-engine/painters/dom/src/images/image-block.test.ts new file mode 100644 index 0000000000..3f34493af6 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-block.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { resolveBlockImageClipPath } from './image-block.js'; + +describe('resolveBlockImageClipPath', () => { + it('prefers a top-level clipPath over attrs.clipPath', () => { + expect( + resolveBlockImageClipPath({ + clipPath: 'inset(1% 2% 3% 4%)', + attrs: { clipPath: 'inset(5% 6% 7% 8%)' }, + }), + ).toBe('inset(1% 2% 3% 4%)'); + }); + + it('falls back to attrs.clipPath when top-level clipPath is absent', () => { + expect(resolveBlockImageClipPath({ attrs: { clipPath: 'inset(5% 6% 7% 8%)' } })).toBe('inset(5% 6% 7% 8%)'); + }); + + it('ignores unsupported clip-path values', () => { + expect(resolveBlockImageClipPath({ clipPath: 'url(#clip)' })).toBe(''); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts new file mode 100644 index 0000000000..70d56d0e43 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -0,0 +1,76 @@ +import type { ImageBlock, ImageDrawing, ImageHyperlink } from '@superdoc/contracts'; +import { buildImageFilters } from '../runs/image-run.js'; +import { applyImageClipPath } from '../utils/image-clip-path.js'; + +type BlockImageSource = ImageBlock | ImageDrawing; + +type BuildImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +) => HTMLElement; + +export type CreateBlockImageContentOptions = { + doc: Document; + block: BlockImageSource; + className?: string; + clipContainer?: HTMLElement; + hyperlinkDisplay?: 'block' | 'inline-block'; + buildImageHyperlinkAnchor?: BuildImageHyperlinkAnchor; +}; + +const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +export const readImageClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + +const resolveClipPathFromAttrs = (attrs: unknown): string => { + if (!attrs || typeof attrs !== 'object') return ''; + const record = attrs as Record; + return readImageClipPathValue(record.clipPath); +}; + +export const resolveBlockImageClipPath = (block: unknown): string => { + if (!block || typeof block !== 'object') return ''; + const record = block as Record; + return readImageClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); +}; + +export const createBlockImageContent = ({ + doc, + block, + className, + clipContainer, + hyperlinkDisplay = 'block', + buildImageHyperlinkAnchor, +}: CreateBlockImageContentOptions): HTMLElement => { + const img = doc.createElement('img'); + if (className) { + img.classList.add(className); + } + if (block.src) { + img.src = block.src; + } + img.alt = block.alt ?? ''; + img.style.width = '100%'; + img.style.height = '100%'; + img.style.objectFit = block.objectFit ?? 'contain'; + if (block.objectFit === 'cover') { + img.style.objectPosition = 'left top'; + } + applyImageClipPath(img, resolveBlockImageClipPath(block), clipContainer ? { clipContainer } : undefined); + img.style.display = block.display === 'inline' ? 'inline-block' : 'block'; + + const filters = buildImageFilters(block); + if (filters.length > 0) { + img.style.filter = filters.join(' '); + } + + return buildImageHyperlinkAnchor?.(img, block.hyperlink, hyperlinkDisplay) ?? img; +}; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8a97c2a890..2bd9c9b736 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -43,7 +43,6 @@ import type { } from '@superdoc/contracts'; import { expandRunsForInlineNewlines, getCellSpacingPx, normalizeColumnLayout } from '@superdoc/contracts'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; import { hashCellBorders, hashTableBorders } from './paragraph-hash-utils.js'; @@ -94,7 +93,8 @@ import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; import { renderLine as renderRunLine } from './runs/render-line.js'; import type { RunRenderContext } from './runs/types.js'; -import { buildImageFilters } from './runs/image-run.js'; +import { createBlockImageContent, readImageClipPathValue, resolveBlockImageClipPath } from './images/image-block.js'; +import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; export type { @@ -2584,22 +2584,6 @@ export class DomPainter { // behindDoc images are supported via z-index; suppress noisy debug logs - const img = this.doc.createElement('img'); - if (block.src) { - img.src = block.src; - } - img.alt = block.alt ?? ''; - img.style.width = '100%'; - img.style.height = '100%'; - img.style.objectFit = block.objectFit ?? 'contain'; - // MS Word anchors stretched images to top-left, clipping from right/bottom - if (block.objectFit === 'cover') { - img.style.objectPosition = 'left top'; - } - const imageClipPath = resolveBlockClipPath(block); - applyImageClipPath(img, imageClipPath, { clipContainer: fragmentEl }); - img.style.display = block.display === 'inline' ? 'inline-block' : 'block'; - // Keep srcRect crop/zoom transforms on the image element. Apply geometry transforms // on the fragment wrapper so rotation/flip do not overwrite clip-path scaling. this.applyImageGeometryTransform(fragmentEl, { @@ -2610,13 +2594,12 @@ export class DomPainter { flipV: block.flipV, }); - const filters = buildImageFilters(block); - if (filters.length > 0) { - img.style.filter = filters.join(' '); - } - - // Wrap in anchor when block has a DrawingML hyperlink (a:hlinkClick) - const imageChild = this.buildImageHyperlinkAnchor(img, block.hyperlink, 'block'); + const imageChild = createBlockImageContent({ + doc: this.doc, + block, + clipContainer: fragmentEl, + buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + }); fragmentEl.appendChild(imageChild); return fragmentEl; @@ -2691,47 +2674,8 @@ export class DomPainter { hyperlink: ImageHyperlink | undefined, display: 'block' | 'inline-block', ): HTMLElement { - if (!hyperlink?.url || !this.doc) return imageEl; - - const sanitized = sanitizeHref(hyperlink.url); - if (!sanitized?.href) return imageEl; - - const anchor = this.doc.createElement('a'); - anchor.href = sanitized.href; - anchor.classList.add('superdoc-link'); - - if (sanitized.protocol === 'http' || sanitized.protocol === 'https') { - anchor.target = '_blank'; - anchor.rel = 'noopener noreferrer'; - } - - const tooltipSource = - typeof hyperlink.tooltip === 'string' && hyperlink.tooltip.trim().length > 0 ? hyperlink.tooltip : hyperlink.url; - const tooltipResult = encodeTooltip(tooltipSource); - if (tooltipResult?.text) { - anchor.title = tooltipResult.text; - } - - for (const titledElement of [imageEl, ...Array.from(imageEl.querySelectorAll('[title]'))]) { - titledElement.removeAttribute('title'); - } - - // Accessibility: explicit role and keyboard focus (mirrors applyLinkAttributes for text links) - anchor.setAttribute('role', 'link'); - anchor.setAttribute('tabindex', '0'); - - if (display === 'block') { - anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;'; - } else { - // inline-block preserves the image's layout box inside a paragraph line - anchor.style.display = 'inline-block'; - anchor.style.lineHeight = '0'; - anchor.style.cursor = 'pointer'; - anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom'; - } - - anchor.appendChild(imageEl); - return anchor; + if (!this.doc) return imageEl; + return buildSharedImageHyperlinkAnchor(this.doc, imageEl, hyperlink, display); } private renderDrawingFragment( @@ -2813,23 +2757,12 @@ export class DomPainter { private createDrawingImageElement(block: DrawingBlock): HTMLElement { const drawing = block as ImageDrawing; - const img = this.doc!.createElement('img'); - img.classList.add('superdoc-drawing-image'); - if (drawing.src) { - img.src = drawing.src; - } - img.alt = drawing.alt ?? ''; - img.style.width = '100%'; - img.style.height = '100%'; - img.style.objectFit = drawing.objectFit ?? 'contain'; - // MS Word anchors stretched images to top-left, clipping from right/bottom - if (drawing.objectFit === 'cover') { - img.style.objectPosition = 'left top'; - } - const imageClipPath = resolveBlockClipPath(drawing); - applyImageClipPath(img, imageClipPath); - img.style.display = 'block'; - return img; + return createBlockImageContent({ + doc: this.doc!, + block: drawing, + className: 'superdoc-drawing-image', + buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + }); } private createVectorShapeElement( @@ -4195,7 +4128,7 @@ const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => */ const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'paragraph') { - return deriveParagraphBlockVersion(block, getSdtMetadataVersion, readClipPathValue); + return deriveParagraphBlockVersion(block, getSdtMetadataVersion, readImageClipPathValue); } if (block.kind === 'image') { @@ -4207,7 +4140,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { block.height ?? '', block.alt ?? '', block.title ?? '', - resolveBlockClipPath(block), + resolveBlockImageClipPath(block), imgSdtVersion, ].join('|'); } @@ -4222,7 +4155,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { imageLike.width ?? '', imageLike.height ?? '', imageLike.alt ?? '', - resolveBlockClipPath(imageLike), + resolveBlockImageClipPath(imageLike), ].join('|'); } if (block.drawingKind === 'vectorShape') { @@ -4393,29 +4326,6 @@ const deriveBlockVersion = (block: FlowBlock): string => { return block.id; }; -const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; - -const readClipPathValue = (value: unknown): string => { - if (typeof value !== 'string') return ''; - const normalized = value.trim(); - if (normalized.length === 0) return ''; - const lower = normalized.toLowerCase(); - if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; - return normalized; -}; - -const resolveClipPathFromAttrs = (attrs: unknown): string => { - if (!attrs || typeof attrs !== 'object') return ''; - const record = attrs as Record; - return readClipPathValue(record.clipPath); -}; - -const resolveBlockClipPath = (block: unknown): string => { - if (!block || typeof block !== 'object') return ''; - const record = block as Record; - return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); -}; - const applyStyles = (el: HTMLElement, styles: Partial): void => { Object.entries(styles).forEach(([key, value]) => { if (value != null && value !== '' && key in el.style) { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 1c46042b39..bfd6433223 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -241,6 +241,86 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.height).toBe('40px'); }); + it('applies top-level clipPath to flowing image blocks inside table cells', () => { + const imageBlock = { + kind: 'image', + id: 'img-clipped-flow', + src: 'data:image/png;base64,AAA', + clipPath: 'inset(10% 20% 30% 40%)', + } as ImageBlock; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [{ kind: 'image' as const, width: 50, height: 40 }], + width: 80, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-clipped-flow', blocks: [imageBlock], attrs: {} }, + }); + + const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; + expect(imgEl?.style.clipPath).toBe('inset(10% 20% 30% 40%)'); + expect(imgEl?.parentElement?.style.overflow).toBe('hidden'); + }); + + it('applies filter styles to flowing image blocks inside table cells', () => { + const imageBlock: ImageBlock = { + kind: 'image', + id: 'img-filtered-flow', + src: 'data:image/png;base64,AAA', + grayscale: true, + gain: 2, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [{ kind: 'image' as const, width: 50, height: 40 }], + width: 80, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-filtered-flow', blocks: [imageBlock], attrs: {} }, + }); + + const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; + expect(imgEl?.style.filter).toContain('grayscale(100%)'); + expect(imgEl?.style.filter).toContain('contrast(2)'); + }); + + it('wraps flowing image blocks with hyperlinks inside table cells', () => { + const imageBlock: ImageBlock = { + kind: 'image', + id: 'img-linked-flow', + src: 'data:image/png;base64,AAA', + hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [{ kind: 'image' as const, width: 50, height: 40 }], + width: 80, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-linked-flow', blocks: [imageBlock], attrs: {} }, + }); + + const anchor = cellElement.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.href).toBe('https://example.com/image'); + expect(anchor?.querySelector('img.superdoc-table-image')).toBeTruthy(); + }); + it('absolutely positions anchored image blocks inside table cells', () => { const para: ParagraphBlock = { kind: 'paragraph', @@ -292,6 +372,112 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.top).toBe('5px'); }); + it('applies top-level clipPath to anchored image blocks inside table cells', () => { + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-anchor-clip', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 16 }], + }; + const anchoredImage = { + kind: 'image', + id: 'img-clipped-anchor', + src: 'data:image/png;base64,AAA', + clipPath: 'inset(5% 10% 15% 20%)', + anchor: { isAnchored: true, alignH: 'left', offsetH: 10, vRelativeFrom: 'paragraph', offsetV: 5 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-anchor-clip' }, + } as ImageBlock; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 10 }], + width: 80, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-clipped-anchor', blocks: [para, anchoredImage], attrs: {} }, + }); + + const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; + expect(imgEl?.style.clipPath).toBe('inset(5% 10% 15% 20%)'); + expect(imgEl?.parentElement?.style.overflow).toBe('hidden'); + }); + + it('applies filter styles to anchored image blocks inside table cells', () => { + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-anchor-filter', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 16 }], + }; + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'img-filtered-anchor', + src: 'data:image/png;base64,AAA', + grayscale: true, + lum: { bright: 25000, contrast: -50000 }, + anchor: { isAnchored: true, alignH: 'left', offsetH: 10, vRelativeFrom: 'paragraph', offsetV: 5 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-anchor-filter' }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 10 }], + width: 80, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-filtered-anchor', blocks: [para, anchoredImage], attrs: {} }, + }); + + const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; + expect(imgEl?.style.filter).toContain('grayscale(100%)'); + expect(imgEl?.style.filter).toContain('contrast(0.5)'); + expect(imgEl?.style.filter).toContain('brightness(1.25)'); + }); + + it('wraps anchored image blocks with hyperlinks inside table cells', () => { + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-anchor-link', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 16 }], + }; + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'img-linked-anchor', + src: 'data:image/png;base64,AAA', + hyperlink: { url: 'https://example.com/anchored-image' }, + anchor: { isAnchored: true, alignH: 'left', offsetH: 10, vRelativeFrom: 'paragraph', offsetV: 5 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-anchor-link' }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 10 }], + width: 80, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-linked-anchor', blocks: [para, anchoredImage], attrs: {} }, + }); + + const anchor = cellElement.querySelector('a.superdoc-link') as HTMLAnchorElement | null; + expect(anchor).toBeTruthy(); + expect(anchor?.href).toBe('https://example.com/anchored-image'); + expect(anchor?.parentElement?.style.position).toBe('absolute'); + expect(anchor?.querySelector('img.superdoc-table-image')).toBeTruthy(); + }); + it('keeps partial-row segment indexing aligned when anchored blocks are between paragraphs', () => { const paraBefore: ParagraphBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 8be7e9b621..716e83f2f1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1,9 +1,11 @@ import type { CellBorders, DrawingBlock, + ImageDrawing, DrawingMeasure, Fragment, ImageBlock, + ImageHyperlink, ImageMeasure, Line, ParagraphBlock, @@ -20,7 +22,8 @@ import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdo import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; -import { applyImageClipPath } from '../utils/image-clip-path.js'; +import { createBlockImageContent } from '../images/image-block.js'; +import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; import { getSdtContainerKeyForBlock, getSdtSiblingBoundaries, @@ -686,6 +689,12 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const attrs = cell?.attrs; const padding = attrs?.padding || { top: 0, left: 4, right: 4, bottom: 0 }; + const buildTableImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', + ): HTMLElement => buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display); + // RTL: swap left↔right cell margins (ECMA-376 Part 4 §14.3.3–14.3.4, §14.3.7–14.3.8) const paddingLeft = isRtl ? (padding.right ?? 4) : (padding.left ?? 4); const paddingTop = padding.top ?? 0; @@ -849,23 +858,15 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen imageWrapper.style.boxSizing = 'border-box'; applySdtDataset(imageWrapper, (block as ImageBlock).attrs?.sdt); - const imgEl = doc.createElement('img'); - imgEl.classList.add('superdoc-table-image'); - if (block.src) { - imgEl.src = block.src; - } - imgEl.alt = block.alt ?? ''; - imgEl.style.width = '100%'; - imgEl.style.height = '100%'; - imgEl.style.objectFit = block.objectFit ?? 'contain'; - // MS Word anchors stretched images to top-left, clipping from right/bottom - if (block.objectFit === 'cover') { - imgEl.style.objectPosition = 'left top'; - } - applyImageClipPath(imgEl, block.attrs?.clipPath, { clipContainer: imageWrapper }); - imgEl.style.display = 'block'; - - imageWrapper.appendChild(imgEl); + imageWrapper.appendChild( + createBlockImageContent({ + doc, + block, + className: 'superdoc-table-image', + clipContainer: imageWrapper, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }), + ); content.appendChild(imageWrapper); flowCursorY += blockMeasure.height; continue; @@ -909,19 +910,15 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen drawingInner.style.overflow = 'hidden'; if (block.drawingKind === 'image' && 'src' in block && block.src) { - const img = doc.createElement('img'); - img.classList.add('superdoc-drawing-image'); - img.src = block.src; - img.alt = block.alt ?? ''; - img.style.width = '100%'; - img.style.height = '100%'; - img.style.objectFit = block.objectFit ?? 'contain'; - // MS Word anchors stretched images to top-left, clipping from right/bottom - if (block.objectFit === 'cover') { - img.style.objectPosition = 'left top'; - } - applyImageClipPath(img, block.attrs?.clipPath, { clipContainer: drawingInner }); - drawingInner.appendChild(img); + drawingInner.appendChild( + createBlockImageContent({ + doc, + block: block as ImageDrawing, + className: 'superdoc-drawing-image', + clipContainer: drawingInner, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }), + ); } else if (renderDrawingContent) { // Use the callback for other drawing types (vectorShape, shapeGroup, etc.) const drawingContent = renderDrawingContent(block as DrawingBlock); @@ -1093,21 +1090,15 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen imageWrapper.style.zIndex = String(zIndex); applySdtDataset(imageWrapper, anchoredBlock.attrs?.sdt); - const imgEl = doc.createElement('img'); - imgEl.classList.add('superdoc-table-image'); - if (anchoredBlock.src) { - imgEl.src = anchoredBlock.src; - } - imgEl.alt = anchoredBlock.alt ?? ''; - imgEl.style.width = '100%'; - imgEl.style.height = '100%'; - imgEl.style.objectFit = anchoredBlock.objectFit ?? 'contain'; - if (anchoredBlock.objectFit === 'cover') { - imgEl.style.objectPosition = 'left top'; - } - applyImageClipPath(imgEl, anchoredBlock.attrs?.clipPath, { clipContainer: imageWrapper }); - imgEl.style.display = 'block'; - imageWrapper.appendChild(imgEl); + imageWrapper.appendChild( + createBlockImageContent({ + doc, + block: anchoredBlock, + className: 'superdoc-table-image', + clipContainer: imageWrapper, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }), + ); content.appendChild(imageWrapper); } else { const drawingWrapper = doc.createElement('div'); @@ -1131,18 +1122,15 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen drawingInner.style.overflow = 'hidden'; if (anchoredBlock.drawingKind === 'image' && 'src' in anchoredBlock && anchoredBlock.src) { - const img = doc.createElement('img'); - img.classList.add('superdoc-drawing-image'); - img.src = anchoredBlock.src; - img.alt = anchoredBlock.alt ?? ''; - img.style.width = '100%'; - img.style.height = '100%'; - img.style.objectFit = anchoredBlock.objectFit ?? 'contain'; - if (anchoredBlock.objectFit === 'cover') { - img.style.objectPosition = 'left top'; - } - applyImageClipPath(img, anchoredBlock.attrs?.clipPath, { clipContainer: drawingInner }); - drawingInner.appendChild(img); + drawingInner.appendChild( + createBlockImageContent({ + doc, + block: anchoredBlock as ImageDrawing, + className: 'superdoc-drawing-image', + clipContainer: drawingInner, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }), + ); } else if (renderDrawingContent) { const drawingContent = renderDrawingContent(anchoredBlock as DrawingBlock); drawingContent.style.width = '100%'; From d5b9f05ad838a2ba1815dd414efb8cb8ed46242a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:12:47 -0300 Subject: [PATCH 02/14] fix(painters/dom): invalidate table image visual edits --- .../src/versionSignature.test.ts | 47 ++++++++++++++++- .../layout-resolved/src/versionSignature.ts | 51 ++++++++++++------- .../painters/dom/src/images/image-block.ts | 30 +++++++++++ .../painters/dom/src/renderer.ts | 21 ++------ 4 files changed, 113 insertions(+), 36 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index f5ba0ede5d..98720504c9 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageBlock, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -66,3 +66,48 @@ describe('deriveBlockVersion - bidi', () => { expect(a).toBe(b); }); }); + +describe('deriveBlockVersion - table image content', () => { + const makeTableWithImage = (image: ImageBlock): TableBlock => ({ + kind: 'table', + id: 'table-with-image', + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [image], + }, + ], + }, + ], + }); + + const baseImage: ImageBlock = { + kind: 'image', + id: 'image-1', + src: 'data:image/png;base64,AAA', + width: 40, + height: 20, + }; + + it('changes when a table image filter changes', () => { + const plain = deriveBlockVersion(makeTableWithImage(baseImage)); + const filtered = deriveBlockVersion(makeTableWithImage({ ...baseImage, grayscale: true })); + + expect(filtered).not.toBe(plain); + }); + + it('changes when a table image hyperlink changes', () => { + const unlinked = deriveBlockVersion(makeTableWithImage(baseImage)); + const linked = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' }, + }), + ); + + expect(linked).not.toBe(unlinked); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 911d7c984d..1181043ff2 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -78,6 +78,36 @@ const resolveBlockClipPath = (block: unknown): string => { return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); }; +const imageHyperlinkVersion = (hyperlink: ImageBlock['hyperlink'] | undefined): string => { + if (!hyperlink) return ''; + return [hyperlink.url ?? '', hyperlink.tooltip ?? ''].join(':'); +}; + +const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { + if (!lum) return ''; + return [lum.bright ?? '', lum.contrast ?? ''].join(':'); +}; + +const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string => + [ + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + image.objectFit ?? '', + image.display ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + resolveBlockClipPath(image), + ].join('|'); + // --------------------------------------------------------------------------- // List marker validation // --------------------------------------------------------------------------- @@ -313,28 +343,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'image') { const imgSdt = (block as ImageBlock).attrs?.sdt; const imgSdtVersion = getSdtMetadataVersion(imgSdt); - return [ - block.src ?? '', - block.width ?? '', - block.height ?? '', - block.alt ?? '', - block.title ?? '', - resolveBlockClipPath(block), - imgSdtVersion, - ].join('|'); + return [renderedBlockImageVersion(block), imgSdtVersion].join('|'); } if (block.kind === 'drawing') { if (block.drawingKind === 'image') { const imageLike = block as ImageDrawing; - return [ - 'drawing:image', - imageLike.src ?? '', - imageLike.width ?? '', - imageLike.height ?? '', - imageLike.alt ?? '', - resolveBlockClipPath(imageLike), - ].join('|'); + return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|'); } if (block.drawingKind === 'vectorShape') { const vector = block as VectorShapeDrawing; @@ -466,6 +481,8 @@ export const deriveBlockVersion = (block: FlowBlock): string => { const bidi = (run as { bidi?: unknown }).bidi; hash = hashString(hash, bidi ? JSON.stringify(bidi) : ''); } + } else if (cellBlock?.kind) { + hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock)); } } } diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index 70d56d0e43..2dd9ad9e49 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -42,6 +42,36 @@ export const resolveBlockImageClipPath = (block: unknown): string => { return readImageClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); }; +const imageHyperlinkVersion = (hyperlink: ImageHyperlink | undefined): string => { + if (!hyperlink) return ''; + return [hyperlink.url ?? '', hyperlink.tooltip ?? ''].join(':'); +}; + +const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { + if (!lum) return ''; + return [lum.bright ?? '', lum.contrast ?? ''].join(':'); +}; + +export const renderedBlockImageVersion = (image: BlockImageSource): string => + [ + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + image.objectFit ?? '', + image.display ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + resolveBlockImageClipPath(image), + ].join('|'); + export const createBlockImageContent = ({ doc, block, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2bd9c9b736..8bb3dcdf4b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -93,7 +93,7 @@ import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; import { renderLine as renderRunLine } from './runs/render-line.js'; import type { RunRenderContext } from './runs/types.js'; -import { createBlockImageContent, readImageClipPathValue, resolveBlockImageClipPath } from './images/image-block.js'; +import { createBlockImageContent, readImageClipPathValue, renderedBlockImageVersion } from './images/image-block.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; @@ -4134,29 +4134,14 @@ const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'image') { const imgSdt = (block as ImageBlock).attrs?.sdt; const imgSdtVersion = getSdtMetadataVersion(imgSdt); - return [ - block.src ?? '', - block.width ?? '', - block.height ?? '', - block.alt ?? '', - block.title ?? '', - resolveBlockImageClipPath(block), - imgSdtVersion, - ].join('|'); + return [renderedBlockImageVersion(block), imgSdtVersion].join('|'); } if (block.kind === 'drawing') { if (block.drawingKind === 'image') { // Type narrowing: block is ImageDrawing (not ImageBlock) const imageLike = block as ImageDrawing; - return [ - 'drawing:image', - imageLike.src ?? '', - imageLike.width ?? '', - imageLike.height ?? '', - imageLike.alt ?? '', - resolveBlockImageClipPath(imageLike), - ].join('|'); + return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|'); } if (block.drawingKind === 'vectorShape') { const vector = block as VectorShapeDrawing; From 2a9968e238144bc39ae30319ada9834d103ed9ea Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:24:17 -0300 Subject: [PATCH 03/14] refactor(painters/dom): drop unused block version derivation Removes the now-dead deriveBlockVersion machinery from renderer.ts (paragraph/image/drawing/table version hashing, SDT metadata helpers, list-marker guards) along with renderedBlockImageVersion in image-block.ts. No remaining call sites consume these helpers after the image-block rendering unification. --- .../painters/dom/src/images/image-block.ts | 30 --- .../painters/dom/src/renderer.ts | 229 +----------------- 2 files changed, 2 insertions(+), 257 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index 2dd9ad9e49..70d56d0e43 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -42,36 +42,6 @@ export const resolveBlockImageClipPath = (block: unknown): string => { return readImageClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); }; -const imageHyperlinkVersion = (hyperlink: ImageHyperlink | undefined): string => { - if (!hyperlink) return ''; - return [hyperlink.url ?? '', hyperlink.tooltip ?? ''].join(':'); -}; - -const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { - if (!lum) return ''; - return [lum.bright ?? '', lum.contrast ?? ''].join(':'); -}; - -export const renderedBlockImageVersion = (image: BlockImageSource): string => - [ - image.src ?? '', - image.width ?? '', - image.height ?? '', - image.alt ?? '', - image.title ?? '', - image.objectFit ?? '', - image.display ?? '', - image.gain ?? '', - image.blacklevel ?? '', - image.grayscale ? 1 : 0, - imageLuminanceVersion(image.lum), - image.rotation ?? '', - image.flipH ? 1 : 0, - image.flipV ? 1 : 0, - imageHyperlinkVersion(image.hyperlink), - resolveBlockImageClipPath(image), - ].join('|'); - export const createBlockImageContent = ({ doc, block, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8bb3dcdf4b..a3bdd2dd6d 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5,7 +5,6 @@ import type { DrawingBlock, DrawingFragment, DrawingGeometry, - FlowBlock, FlowMode, Fragment, GradientFill, @@ -25,9 +24,7 @@ import type { ShapeTextContent, SolidFillWithAlpha, SourceAnchor, - TableAttrs, TableBlock, - TableCellAttrs, TableFragment, TableMeasure, TextRun, @@ -45,7 +42,6 @@ import { expandRunsForInlineNewlines, getCellSpacingPx, normalizeColumnLayout } import { getPresetShapeSvg } from '@superdoc/preset-geometry'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; -import { hashCellBorders, hashTableBorders } from './paragraph-hash-utils.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { CLASS_NAMES, @@ -69,13 +65,7 @@ import { renderTableFragment as renderTableFragmentElement } from './table/rende import { applyImageClipPath } from './utils/image-clip-path.js'; import { computeSdtBoundaries } from './sdt/boundaries.js'; import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; -import { - applyContainerSdtDataset, - applySdtDataset, - getSdtMetadataId, - getSdtMetadataLockMode, - getSdtMetadataVersion, -} from './sdt/dataset.js'; +import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js'; import { createInlineSdtWrapper, expandSdtWrapperPmRange, @@ -88,12 +78,11 @@ import { type PaintSnapshotStructuredContentInlineEntity, } from './sdt/snapshot.js'; import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; -import { deriveParagraphBlockVersion, hashParagraphBlockForTableVersion } from './paragraph/block-version.js'; import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; import { renderLine as renderRunLine } from './runs/render-line.js'; import type { RunRenderContext } from './runs/types.js'; -import { createBlockImageContent, readImageClipPathValue, renderedBlockImageVersion } from './images/image-block.js'; +import { createBlockImageContent } from './images/image-block.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; @@ -4097,220 +4086,6 @@ const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => blockId.startsWith('__sd_semantic_footnote-') || blockId.startsWith('__sd_semantic_endnote-')); -/** - * Derives a version string for a flow block based on its content and styling properties. - * - * This version string is used for cache invalidation - when any visual property of the block - * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements. - * - * The version includes all properties that affect visual rendering: - * - Text content - * - Font properties (family, size, bold, italic) - * - Text decorations (underline style/color, strike, highlight) - * - Spacing (letterSpacing) - * - Position markers (pmStart, pmEnd) - * - Special tokens (page numbers, etc.) - * - List marker properties (numId, ilvl, markerText) - for list indent changes - * - Paragraph attributes (alignment, spacing, indent, borders, shading, direction, tabs) - * - Table cell content and paragraph formatting within cells - * - * For table blocks, a deep hash is computed across all rows and cells, including: - * - Cell block content (paragraph runs, text, formatting) - * - Paragraph-level attributes in cells (alignment, spacing, line height, indent, borders, shading) - * - Run-level formatting (color, highlight, bold, italic, fontSize, fontFamily, underline, strike) - * - * This ensures toolbar commands that modify paragraph or run formatting within tables - * trigger proper DOM updates. - * - * @param block - The flow block to generate a version string for - * @returns A pipe-delimited string representing all visual properties of the block. - * Changes to any included property will change the version string. - */ -const deriveBlockVersion = (block: FlowBlock): string => { - if (block.kind === 'paragraph') { - return deriveParagraphBlockVersion(block, getSdtMetadataVersion, readImageClipPathValue); - } - - if (block.kind === 'image') { - const imgSdt = (block as ImageBlock).attrs?.sdt; - const imgSdtVersion = getSdtMetadataVersion(imgSdt); - return [renderedBlockImageVersion(block), imgSdtVersion].join('|'); - } - - if (block.kind === 'drawing') { - if (block.drawingKind === 'image') { - // Type narrowing: block is ImageDrawing (not ImageBlock) - const imageLike = block as ImageDrawing; - return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|'); - } - if (block.drawingKind === 'vectorShape') { - const vector = block as VectorShapeDrawing; - return [ - 'drawing:vector', - vector.shapeKind ?? '', - vector.fillColor ?? '', - vector.strokeColor ?? '', - vector.strokeWidth ?? '', - vector.geometry.width, - vector.geometry.height, - vector.geometry.rotation ?? 0, - vector.geometry.flipH ? 1 : 0, - vector.geometry.flipV ? 1 : 0, - ].join('|'); - } - if (block.drawingKind === 'shapeGroup') { - const group = block as ShapeGroupDrawing; - const childSignature = group.shapes - .map((child) => `${child.shapeType}:${JSON.stringify(child.attrs ?? {})}`) - .join(';'); - return [ - 'drawing:group', - group.geometry.width, - group.geometry.height, - group.groupTransform ? JSON.stringify(group.groupTransform) : '', - childSignature, - ].join('|'); - } - if (block.drawingKind === 'chart') { - return [ - 'drawing:chart', - block.chartData?.chartType ?? '', - block.chartData?.series?.length ?? 0, - block.geometry.width, - block.geometry.height, - block.chartRelId ?? '', - ].join('|'); - } - // Exhaustiveness check: if a new drawingKind is added, TypeScript will error here - const _exhaustive: never = block; - return `drawing:unknown:${(block as DrawingBlock).id}`; - } - - if (block.kind === 'table') { - const tableBlock = block as TableBlock; - /** - * Local hash function for strings using FNV-1a algorithm. - * Used to create a robust hash across all table rows/cells so deep edits invalidate version. - * - * @param seed - Initial hash value - * @param value - String value to hash - * @returns Updated hash value - */ - const hashString = (seed: number, value: string): number => { - let hash = seed >>> 0; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 16777619); // FNV-style mix - } - return hash >>> 0; - }; - - /** - * Local hash function for numbers. - * Handles undefined/null values safely by treating them as 0. - * - * @param seed - Initial hash value - * @param value - Number value to hash (or undefined/null) - * @returns Updated hash value - */ - const hashNumber = (seed: number, value: number | undefined | null): number => { - const n = Number.isFinite(value) ? (value as number) : 0; - let hash = seed ^ n; - hash = Math.imul(hash, 16777619); - hash ^= hash >>> 13; - return hash >>> 0; - }; - - let hash = 2166136261; - hash = hashString(hash, block.id); - hash = hashNumber(hash, tableBlock.rows.length); - hash = (tableBlock.columnWidths ?? []).reduce((acc, width) => hashNumber(acc, Math.round(width * 1000)), hash); - - // Defensive guards: ensure rows array exists and iterate safely - const rows = tableBlock.rows ?? []; - for (const row of rows) { - if (!row || !Array.isArray(row.cells)) continue; - hash = hashNumber(hash, row.cells.length); - for (const cell of row.cells) { - if (!cell) continue; - const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); - hash = hashNumber(hash, cellBlocks.length); - // Include cell attributes that affect rendering (rowSpan, colSpan, borders, etc.) - hash = hashNumber(hash, cell.rowSpan ?? 1); - hash = hashNumber(hash, cell.colSpan ?? 1); - - // Include cell-level attributes (borders, padding, background) that affect rendering - // This ensures cache invalidation when cell formatting changes (e.g., remove borders). - if (cell.attrs) { - const cellAttrs = cell.attrs as TableCellAttrs; - if (cellAttrs.borders) { - hash = hashString(hash, hashCellBorders(cellAttrs.borders)); - } - if (cellAttrs.padding) { - const p = cellAttrs.padding; - hash = hashNumber(hash, p.top ?? 0); - hash = hashNumber(hash, p.right ?? 0); - hash = hashNumber(hash, p.bottom ?? 0); - hash = hashNumber(hash, p.left ?? 0); - } - if (cellAttrs.verticalAlign) { - hash = hashString(hash, cellAttrs.verticalAlign); - } - if (cellAttrs.background) { - hash = hashString(hash, cellAttrs.background); - } - } - - for (const cellBlock of cellBlocks) { - hash = hashString(hash, cellBlock?.kind ?? 'unknown'); - if (cellBlock?.kind === 'paragraph') { - hash = hashParagraphBlockForTableVersion(hash, cellBlock as ParagraphBlock, { hashString, hashNumber }); - } else if (cellBlock?.kind) { - // Non-paragraph cell blocks participate in the parent table version - // through their own block-level signatures. layout-bridge/cache.ts - // mirrors this policy so repaint and remeasure stay aligned for - // nested tables, images, drawings, and other embedded cell content. - hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock)); - } - } - } - } - - // Include table-level attributes (borders, etc.) that affect rendering - // This ensures cache invalidation when table formatting changes (e.g., remove borders). - if (tableBlock.attrs) { - const tblAttrs = tableBlock.attrs as TableAttrs; - if (tblAttrs.borders) { - hash = hashString(hash, hashTableBorders(tblAttrs.borders)); - } - if (tblAttrs.borderCollapse) { - hash = hashString(hash, tblAttrs.borderCollapse); - } - if (tblAttrs.cellSpacing !== undefined) { - const cs = tblAttrs.cellSpacing; - if (typeof cs === 'number') { - hash = hashNumber(hash, cs); - } else { - // Stable key: value and type only (avoid JSON.stringify key-order variance) - const v = (cs as { value?: number; type?: string }).value ?? 0; - const t = (cs as { value?: number; type?: string }).type ?? 'px'; - hash = hashString(hash, `cs:${v}:${t}`); - } - } - // Include SDT metadata so lock-mode changes invalidate the cache. - if (tblAttrs.sdt) { - hash = hashString(hash, tblAttrs.sdt.type); - hash = hashString(hash, getSdtMetadataLockMode(tblAttrs.sdt)); - hash = hashString(hash, getSdtMetadataId(tblAttrs.sdt)); - } - } - - return [block.id, tableBlock.rows.length, hash.toString(16)].join('|'); - } - - return block.id; -}; - const applyStyles = (el: HTMLElement, styles: Partial): void => { Object.entries(styles).forEach(([key, value]) => { if (value != null && value !== '' && key in el.style) { From 63cda2781f0362020a71e01b12c49f38044097dd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:31:13 -0300 Subject: [PATCH 04/14] fix(layout-resolved): invalidate inline image visual edits --- .../src/versionSignature.test.ts | 69 ++++++++++++++++++- .../layout-resolved/src/versionSignature.ts | 47 +++++++++---- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 98720504c9..e73d5f70f3 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, ImageBlock, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -111,3 +111,70 @@ describe('deriveBlockVersion - table image content', () => { expect(linked).not.toBe(unlinked); }); }); + +describe('deriveBlockVersion - inline image runs', () => { + const baseImageRun: ImageRun = { + kind: 'image', + src: 'data:image/png;base64,AAA', + width: 40, + height: 20, + }; + + const makeParagraphWithImageRun = (image: ImageRun): FlowBlock => ({ + kind: 'paragraph', + id: 'paragraph-with-image-run', + runs: [image], + }); + + const makeTableWithImageRun = (image: ImageRun): TableBlock => ({ + kind: 'table', + id: 'table-with-inline-image-run', + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [makeParagraphWithImageRun(image)], + }, + ], + }, + ], + }); + + it('changes when an inline image filter changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const filtered = deriveBlockVersion( + makeParagraphWithImageRun({ ...baseImageRun, grayscale: true, lum: { bright: 25000 } }), + ); + + expect(filtered).not.toBe(plain); + }); + + it('changes when an inline image transform changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const transformed = deriveBlockVersion(makeParagraphWithImageRun({ ...baseImageRun, rotation: 45, flipH: true })); + + expect(transformed).not.toBe(plain); + }); + + it('changes when an inline image hyperlink changes', () => { + const unlinked = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const linked = deriveBlockVersion( + makeParagraphWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/inline-image' } }), + ); + + expect(linked).not.toBe(unlinked); + }); + + it('changes when a table-cell inline image visual property changes', () => { + const plain = deriveBlockVersion(makeTableWithImageRun(baseImageRun)); + const filtered = deriveBlockVersion(makeTableWithImageRun({ ...baseImageRun, grayscale: true })); + const linked = deriveBlockVersion( + makeTableWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/table-inline-image' } }), + ); + + expect(filtered).not.toBe(plain); + expect(linked).not.toBe(plain); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 1181043ff2..92703c6f8c 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -108,6 +108,30 @@ const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string => resolveBlockClipPath(image), ].join('|'); +const renderedInlineImageRunVersion = (image: ImageRun): string => + [ + 'img', + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + readClipPathValue(image.clipPath), + image.distTop ?? '', + image.distBottom ?? '', + image.distLeft ?? '', + image.distRight ?? '', + image.verticalAlign ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + ].join('|'); + // --------------------------------------------------------------------------- // List marker validation // --------------------------------------------------------------------------- @@ -230,21 +254,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { const runsVersion = block.runs .map((run) => { if (run.kind === 'image') { - const imgRun = run as ImageRun; - return [ - 'img', - imgRun.src, - imgRun.width, - imgRun.height, - imgRun.alt ?? '', - imgRun.title ?? '', - imgRun.clipPath ?? '', - imgRun.distTop ?? '', - imgRun.distBottom ?? '', - imgRun.distLeft ?? '', - imgRun.distRight ?? '', - readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), - ].join(','); + return renderedInlineImageRunVersion(run as ImageRun); } if (run.kind === 'lineBreak') { @@ -460,6 +470,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => { } for (const run of runs) { + if (run.kind === 'image') { + hash = hashString(hash, renderedInlineImageRunVersion(run as ImageRun)); + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + continue; + } + if ('text' in run && typeof run.text === 'string') { hash = hashString(hash, run.text); } From b54d1c315ff42858a53f8c00cb1eec3788896a16 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:35:09 -0300 Subject: [PATCH 05/14] fix(layout-resolved): hash raw inline image clip paths --- .../layout-resolved/src/versionSignature.test.ts | 10 ++++++++++ .../layout-resolved/src/versionSignature.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index e73d5f70f3..da9724094b 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -167,6 +167,16 @@ describe('deriveBlockVersion - inline image runs', () => { expect(linked).not.toBe(unlinked); }); + it('changes when an inline image raw clip path changes', () => { + const clipA = { ...baseImageRun, clipPath: 'url(#clip-a)' }; + const clipB = { ...baseImageRun, clipPath: 'url(#clip-b)' }; + + expect(deriveBlockVersion(makeParagraphWithImageRun(clipA))).not.toBe( + deriveBlockVersion(makeParagraphWithImageRun(clipB)), + ); + expect(deriveBlockVersion(makeTableWithImageRun(clipA))).not.toBe(deriveBlockVersion(makeTableWithImageRun(clipB))); + }); + it('changes when a table-cell inline image visual property changes', () => { const plain = deriveBlockVersion(makeTableWithImageRun(baseImageRun)); const filtered = deriveBlockVersion(makeTableWithImageRun({ ...baseImageRun, grayscale: true })); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 92703c6f8c..a47d3e35f8 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -116,7 +116,7 @@ const renderedInlineImageRunVersion = (image: ImageRun): string => image.height ?? '', image.alt ?? '', image.title ?? '', - readClipPathValue(image.clipPath), + typeof image.clipPath === 'string' ? image.clipPath.trim() : '', image.distTop ?? '', image.distBottom ?? '', image.distLeft ?? '', From 84795f4aaf61b2f421894e26bf39cec2556b75b5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:49:59 -0300 Subject: [PATCH 06/14] refactor(painters/dom): extract image rendering helpers into modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move image fragment and drawing image creation out of renderer.ts into dedicated modules under images/. Renderer now delegates: - renderImageFragment → images/image-fragment.ts (with the buildImageGeometryTransform / applyImageGeometryTransform helpers it owned) - createDrawingImageElement, createShapeGroupImageElement, and the inline shape-text image element creation → images/drawing-image.ts No behavior change — pure extraction. Trims ~157 lines from renderer.ts. --- .../painters/dom/src/images/drawing-image.ts | 56 ++++++ .../painters/dom/src/images/image-fragment.ts | 159 ++++++++++++++++ .../painters/dom/src/renderer.ts | 180 +++--------------- 3 files changed, 238 insertions(+), 157 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/images/drawing-image.ts create mode 100644 packages/layout-engine/painters/dom/src/images/image-fragment.ts diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts new file mode 100644 index 0000000000..4c6e008313 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -0,0 +1,56 @@ +import type { + DrawingBlock, + ImageDrawing, + ImageHyperlink, + PositionedDrawingGeometry, + ShapeGroupChild, + TextPart, +} from '@superdoc/contracts'; +import { applyImageClipPath } from '../utils/image-clip-path.js'; +import { createBlockImageContent } from './image-block.js'; + +type BuildImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +) => HTMLElement; + +export const createDrawingImageElement = ( + doc: Document, + block: DrawingBlock, + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor, +): HTMLElement => { + const drawing = block as ImageDrawing; + return createBlockImageContent({ + doc, + block: drawing, + className: 'superdoc-drawing-image', + buildImageHyperlinkAnchor, + }); +}; + +export const createShapeGroupImageElement = (doc: Document, child: ShapeGroupChild): HTMLElement => { + const attrs = child.attrs as PositionedDrawingGeometry & { + src: string; + alt?: string; + clipPath?: string; + }; + const img = 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; +}; + +export const createShapeTextImageElement = (doc: Document, part: TextPart): HTMLElement => { + const img = doc.createElement('img'); + img.src = part.src!; + img.alt = part.alt ?? ''; + if (typeof part.width === 'number') img.style.width = `${part.width}px`; + if (typeof part.height === 'number') img.style.height = `${part.height}px`; + img.style.display = 'inline-block'; + img.style.verticalAlign = 'bottom'; + return img; +}; diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts new file mode 100644 index 0000000000..df75c1f9ad --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -0,0 +1,159 @@ +import type { ImageBlock, ImageFragment, ImageHyperlink, ResolvedImageItem, SdtMetadata } from '@superdoc/contracts'; +import { DOM_CLASS_NAMES } from '../constants.js'; +import type { FragmentRenderContext } from '../renderer.js'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { createBlockImageContent } from './image-block.js'; + +type BuildImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +) => HTMLElement; + +type RenderImageFragmentOptions = { + doc: Document | null; + fragment: ImageFragment; + context: FragmentRenderContext; + resolvedItem?: ResolvedImageItem; + applyResolvedFragmentFrame: ( + el: HTMLElement, + item: ResolvedImageItem, + fragment: ImageFragment, + section?: 'body' | 'header' | 'footer', + ) => void; + applyFragmentFrame: (el: HTMLElement, fragment: ImageFragment, section?: 'body' | 'header' | 'footer') => void; + applyFragmentWrapperZIndex: (el: HTMLElement, fragment: ImageFragment) => void; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + applyContainerSdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +const applyStyles = (el: HTMLElement, styles: Partial): void => { + Object.entries(styles).forEach(([key, value]) => { + if (value != null && value !== '' && key in el.style) { + (el.style as unknown as Record)[key] = String(value); + } + }); +}; + +export const buildImageGeometryTransform = (attrs: { + width: number; + height: number; + rotation?: number; + flipH?: boolean; + flipV?: boolean; +}): string => { + const transforms: string[] = []; + if (attrs.rotation != null && attrs.rotation !== 0) { + const angleRad = (attrs.rotation * Math.PI) / 180; + const cosA = Math.cos(angleRad); + const sinA = Math.sin(angleRad); + const newTopLeftX = (attrs.width / 2) * (1 - cosA) + (attrs.height / 2) * sinA; + const newTopLeftY = (attrs.width / 2) * sinA + (attrs.height / 2) * (1 - cosA); + transforms.push(`translate(${-newTopLeftX}px, ${-newTopLeftY}px)`); + transforms.push(`rotate(${attrs.rotation}deg)`); + } + if (attrs.flipH) { + transforms.push('scaleX(-1)'); + } + if (attrs.flipV) { + transforms.push('scaleY(-1)'); + } + return transforms.join(' '); +}; + +export const applyImageGeometryTransform = ( + target: HTMLElement, + attrs: { + width: number; + height: number; + rotation?: number; + flipH?: boolean; + flipV?: boolean; + }, +): void => { + const transform = buildImageGeometryTransform(attrs); + if (!transform) { + return; + } + target.style.transform = transform; + target.style.transformOrigin = 'center'; +}; + +export const renderImageFragment = ({ + doc, + fragment, + context, + resolvedItem, + applyResolvedFragmentFrame, + applyFragmentFrame, + applyFragmentWrapperZIndex, + applySdtDataset, + applyContainerSdtDataset, + buildImageHyperlinkAnchor, + createErrorPlaceholder, +}: RenderImageFragmentOptions): HTMLElement => { + try { + if (resolvedItem?.block?.kind !== 'image') { + throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ImageBlock; + + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment, DOM_CLASS_NAMES.IMAGE_FRAGMENT); + applyStyles(fragmentEl, fragmentStyles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + } else { + applyFragmentFrame(fragmentEl, fragment, context.section); + fragmentEl.style.height = `${fragment.height}px`; + applyFragmentWrapperZIndex(fragmentEl, fragment); + } + applySdtDataset(fragmentEl, block.attrs?.sdt); + applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); + + if (block.id) { + fragmentEl.setAttribute('data-sd-block-id', block.id); + } + + const imgPmStart = resolvedItem?.pmStart; + if (imgPmStart != null) { + fragmentEl.dataset.pmStart = String(imgPmStart); + } + const imgPmEnd = resolvedItem?.pmEnd; + if (imgPmEnd != null) { + fragmentEl.dataset.pmEnd = String(imgPmEnd); + } + + const imgMetadata = resolvedItem?.metadata; + if (imgMetadata && !block.attrs?.vmlWatermark) { + fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); + } + + applyImageGeometryTransform(fragmentEl, { + width: block.width ?? fragment.width, + height: block.height ?? fragment.height, + rotation: block.rotation, + flipH: block.flipH, + flipV: block.flipV, + }); + + const imageChild = createBlockImageContent({ + doc, + block, + clipContainer: fragmentEl, + buildImageHyperlinkAnchor, + }); + fragmentEl.appendChild(imageChild); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Image fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a3bdd2dd6d..98f0f7b2d4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -8,8 +8,6 @@ import type { FlowMode, Fragment, GradientFill, - ImageBlock, - ImageDrawing, ImageFragment, ImageHyperlink, Line, @@ -62,7 +60,6 @@ import { } from './styles.js'; import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; -import { applyImageClipPath } from './utils/image-clip-path.js'; import { computeSdtBoundaries } from './sdt/boundaries.js'; import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js'; @@ -82,7 +79,12 @@ import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; import { renderLine as renderRunLine } from './runs/render-line.js'; import type { RunRenderContext } from './runs/types.js'; -import { createBlockImageContent } from './images/image-block.js'; +import { + createDrawingImageElement, + createShapeGroupImageElement, + createShapeTextImageElement, +} from './images/drawing-image.js'; +import { renderImageFragment as renderImageFragmentElement } from './images/image-fragment.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; @@ -2526,120 +2528,19 @@ export class DomPainter { context: FragmentRenderContext, resolvedItem?: ResolvedImageItem, ): HTMLElement { - try { - // Pre-extracted block from the resolved item. - if (resolvedItem?.block?.kind !== 'image') { - throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`); - } - const block = resolvedItem.block as ImageBlock; - - if (!this.doc) { - throw new Error('DomPainter: document is not available'); - } - - const fragmentEl = this.doc.createElement('div'); - fragmentEl.classList.add(CLASS_NAMES.fragment, DOM_CLASS_NAMES.IMAGE_FRAGMENT); - applyStyles(fragmentEl, fragmentStyles); - if (resolvedItem) { - this.applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); - } else { - this.applyFragmentFrame(fragmentEl, fragment, context.section); - fragmentEl.style.height = `${fragment.height}px`; - this.applyFragmentWrapperZIndex(fragmentEl, fragment); - } - applySdtDataset(fragmentEl, block.attrs?.sdt); - applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); - - // Add block ID for PM transaction targeting - if (block.id) { - fragmentEl.setAttribute('data-sd-block-id', block.id); - } - - // Add PM position markers for transaction targeting - const imgPmStart = resolvedItem?.pmStart; - if (imgPmStart != null) { - fragmentEl.dataset.pmStart = String(imgPmStart); - } - const imgPmEnd = resolvedItem?.pmEnd; - if (imgPmEnd != null) { - fragmentEl.dataset.pmEnd = String(imgPmEnd); - } - - // Add metadata for interactive image resizing (skip watermarks - they should not be interactive) - const imgMetadata = resolvedItem?.metadata; - if (imgMetadata && !block.attrs?.vmlWatermark) { - fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); - } - - // behindDoc images are supported via z-index; suppress noisy debug logs - - // Keep srcRect crop/zoom transforms on the image element. Apply geometry transforms - // on the fragment wrapper so rotation/flip do not overwrite clip-path scaling. - this.applyImageGeometryTransform(fragmentEl, { - width: block.width ?? fragment.width, - height: block.height ?? fragment.height, - rotation: block.rotation, - flipH: block.flipH, - flipV: block.flipV, - }); - - const imageChild = createBlockImageContent({ - doc: this.doc, - block, - clipContainer: fragmentEl, - buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), - }); - fragmentEl.appendChild(imageChild); - - return fragmentEl; - } catch (error) { - console.error('[DomPainter] Image fragment rendering failed:', { fragment, error }); - return this.createErrorPlaceholder(fragment.blockId, error); - } - } - - private buildImageGeometryTransform(attrs: { - width: number; - height: number; - rotation?: number; - flipH?: boolean; - flipV?: boolean; - }): string { - const transforms: string[] = []; - if (attrs.rotation != null && attrs.rotation !== 0) { - const angleRad = (attrs.rotation * Math.PI) / 180; - const cosA = Math.cos(angleRad); - const sinA = Math.sin(angleRad); - const newTopLeftX = (attrs.width / 2) * (1 - cosA) + (attrs.height / 2) * sinA; - const newTopLeftY = (attrs.width / 2) * sinA + (attrs.height / 2) * (1 - cosA); - transforms.push(`translate(${-newTopLeftX}px, ${-newTopLeftY}px)`); - transforms.push(`rotate(${attrs.rotation}deg)`); - } - if (attrs.flipH) { - transforms.push('scaleX(-1)'); - } - if (attrs.flipV) { - transforms.push('scaleY(-1)'); - } - return transforms.join(' '); - } - - private applyImageGeometryTransform( - target: HTMLElement, - attrs: { - width: number; - height: number; - rotation?: number; - flipH?: boolean; - flipV?: boolean; - }, - ): void { - const transform = this.buildImageGeometryTransform(attrs); - if (!transform) { - return; - } - target.style.transform = transform; - target.style.transformOrigin = 'center'; + return renderImageFragmentElement({ + doc: this.doc, + fragment, + context, + resolvedItem, + applyResolvedFragmentFrame: this.applyResolvedFragmentFrame.bind(this), + applyFragmentFrame: this.applyFragmentFrame.bind(this), + applyFragmentWrapperZIndex: this.applyFragmentWrapperZIndex.bind(this), + applySdtDataset, + applyContainerSdtDataset, + buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + createErrorPlaceholder: this.createErrorPlaceholder.bind(this), + }); } /** @@ -2730,7 +2631,7 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } if (block.drawingKind === 'image') { - return this.createDrawingImageElement(block); + return createDrawingImageElement(this.doc, block, this.buildImageHyperlinkAnchor.bind(this)); } if (block.drawingKind === 'vectorShape') { return this.createVectorShapeElement(block, fragment.geometry, false, 1, 1, context); @@ -2744,16 +2645,6 @@ export class DomPainter { return this.createDrawingPlaceholder(); } - private createDrawingImageElement(block: DrawingBlock): HTMLElement { - const drawing = block as ImageDrawing; - return createBlockImageContent({ - doc: this.doc!, - block: drawing, - className: 'superdoc-drawing-image', - buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), - }); - } - private createVectorShapeElement( block: VectorShapeDrawingWithEffects, geometry?: DrawingGeometry, @@ -3152,20 +3043,7 @@ export class DomPainter { currentParagraph.style.minHeight = '1em'; } } else if (part.kind === 'image' && part.src) { - // SD-2804: image part produced by the textbox importer for an - // inline w:drawing inside a textbox run. Render as alongside - // sibling text spans so layout matches Word's inline flow. Match - // body inline images' baseline default (`vertical-align: bottom`) - // so an image and adjacent text line up the same way inside a - // textbox as outside. - const img = this.doc!.createElement('img'); - img.src = part.src; - img.alt = part.alt ?? ''; - if (typeof part.width === 'number') img.style.width = `${part.width}px`; - if (typeof part.height === 'number') img.style.height = `${part.height}px`; - img.style.display = 'inline-block'; - img.style.verticalAlign = 'bottom'; - currentParagraph.appendChild(img); + currentParagraph.appendChild(createShapeTextImageElement(this.doc!, part)); } else { const span = this.doc!.createElement('span'); span.textContent = this.resolveShapeTextPartText(part, context); @@ -3633,19 +3511,7 @@ export class DomPainter { return this.createVectorShapeElement(vectorChild, childGeometry, false, groupScaleX, groupScaleY, context); } if (child.shapeType === 'image' && 'src' in child.attrs) { - // After this check, child should be ShapeGroupImageChild - 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 createShapeGroupImageElement(this.doc!, child); } return this.createDrawingPlaceholder(); } @@ -3749,7 +3615,7 @@ export class DomPainter { */ const renderDrawingContentForTableCell = (block: DrawingBlock): HTMLElement => { if (block.drawingKind === 'image') { - return this.createDrawingImageElement(block); + return createDrawingImageElement(this.doc!, block, this.buildImageHyperlinkAnchor.bind(this)); } if (block.drawingKind === 'shapeGroup') { return this.createShapeGroupElement(block, context); From 945ba8fc9f31805efe64fa02e1a4fdb0606b7f29 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:11:44 -0300 Subject: [PATCH 07/14] fix(layout-resolved): avoid image hyperlink hash collisions --- .../src/versionSignature.test.ts | 17 +++++++++++++++++ .../layout-resolved/src/versionSignature.ts | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index da9724094b..9169f3463a 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -110,6 +110,23 @@ describe('deriveBlockVersion - table image content', () => { expect(linked).not.toBe(unlinked); }); + + it('does not collide when image hyperlink URL and tooltip contain separators', () => { + const first = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/a', tooltip: 'b:c' }, + }), + ); + const second = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/a:b', tooltip: 'c' }, + }), + ); + + expect(second).not.toBe(first); + }); }); describe('deriveBlockVersion - inline image runs', () => { diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index a47d3e35f8..aa5f99f7a7 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -80,7 +80,7 @@ const resolveBlockClipPath = (block: unknown): string => { const imageHyperlinkVersion = (hyperlink: ImageBlock['hyperlink'] | undefined): string => { if (!hyperlink) return ''; - return [hyperlink.url ?? '', hyperlink.tooltip ?? ''].join(':'); + return JSON.stringify([hyperlink.url ?? '', hyperlink.tooltip ?? '']); }; const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { From 5b6e5ba6106d4bbbb73bf662a8a7806d1cccb5a9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:12:27 -0300 Subject: [PATCH 08/14] fix(painters/dom): keep table images block display --- .../painters/dom/src/images/drawing-image.ts | 1 + .../painters/dom/src/images/image-block.ts | 4 ++- .../dom/src/table/renderTableCell.test.ts | 25 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 4 +++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts index 4c6e008313..3e12345ed9 100644 --- a/packages/layout-engine/painters/dom/src/images/drawing-image.ts +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -25,6 +25,7 @@ export const createDrawingImageElement = ( doc, block: drawing, className: 'superdoc-drawing-image', + imageDisplay: 'block', buildImageHyperlinkAnchor, }); }; diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index 70d56d0e43..04cf537128 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -15,6 +15,7 @@ export type CreateBlockImageContentOptions = { block: BlockImageSource; className?: string; clipContainer?: HTMLElement; + imageDisplay?: 'block' | 'inline-block'; hyperlinkDisplay?: 'block' | 'inline-block'; buildImageHyperlinkAnchor?: BuildImageHyperlinkAnchor; }; @@ -47,6 +48,7 @@ export const createBlockImageContent = ({ block, className, clipContainer, + imageDisplay, hyperlinkDisplay = 'block', buildImageHyperlinkAnchor, }: CreateBlockImageContentOptions): HTMLElement => { @@ -65,7 +67,7 @@ export const createBlockImageContent = ({ img.style.objectPosition = 'left top'; } applyImageClipPath(img, resolveBlockImageClipPath(block), clipContainer ? { clipContainer } : undefined); - img.style.display = block.display === 'inline' ? 'inline-block' : 'block'; + img.style.display = imageDisplay ?? (block.display === 'inline' ? 'inline-block' : 'block'); const filters = buildImageFilters(block); if (filters.length > 0) { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index bfd6433223..b50b3f65c8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -241,6 +241,31 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.height).toBe('40px'); }); + it('forces flowing image blocks to block display inside table cells', () => { + const imageBlock: ImageBlock = { + kind: 'image', + id: 'img-inline-display', + src: 'data:image/png;base64,AAA', + display: 'inline', + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [{ kind: 'image' as const, width: 50, height: 40 }], + width: 80, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-inline-display-image', blocks: [imageBlock], attrs: {} }, + }); + + const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; + expect(imgEl?.style.display).toBe('block'); + }); + it('applies top-level clipPath to flowing image blocks inside table cells', () => { const imageBlock = { kind: 'image', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 716e83f2f1..4fc7a4485d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -864,6 +864,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen block, className: 'superdoc-table-image', clipContainer: imageWrapper, + imageDisplay: 'block', buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }), ); @@ -916,6 +917,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen block: block as ImageDrawing, className: 'superdoc-drawing-image', clipContainer: drawingInner, + imageDisplay: 'block', buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }), ); @@ -1096,6 +1098,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen block: anchoredBlock, className: 'superdoc-table-image', clipContainer: imageWrapper, + imageDisplay: 'block', buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }), ); @@ -1128,6 +1131,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen block: anchoredBlock as ImageDrawing, className: 'superdoc-drawing-image', clipContainer: drawingInner, + imageDisplay: 'block', buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }), ); From e0dc9a5cc5affd9e6e6ea51c0e55bd86dbda13eb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:12:59 -0300 Subject: [PATCH 09/14] test(painters/dom): cover unified drawing image rendering --- .../dom/src/images/image-block.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/images/image-block.test.ts b/packages/layout-engine/painters/dom/src/images/image-block.test.ts index 3f34493af6..30f63e7da7 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.test.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest'; +import type { DrawingBlock } from '@superdoc/contracts'; +import { createDrawingImageElement } from './drawing-image.js'; +import { buildImageHyperlinkAnchor } from './hyperlink.js'; import { resolveBlockImageClipPath } from './image-block.js'; describe('resolveBlockImageClipPath', () => { @@ -19,3 +22,46 @@ describe('resolveBlockImageClipPath', () => { expect(resolveBlockImageClipPath({ clipPath: 'url(#clip)' })).toBe(''); }); }); + +describe('createDrawingImageElement', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('drawing-image'); + + it('applies unified image filters to drawing images', () => { + const doc = createDoc(); + const drawing = { + kind: 'drawing', + drawingKind: 'image', + id: 'drawing-image-filtered', + src: 'data:image/png;base64,AAA', + grayscale: true, + gain: 2, + } as DrawingBlock; + + const imgEl = createDrawingImageElement(doc, drawing, (imageEl) => imageEl) as HTMLImageElement; + + expect(imgEl.style.display).toBe('block'); + expect(imgEl.style.filter).toContain('grayscale(100%)'); + expect(imgEl.style.filter).toContain('contrast(2)'); + }); + + it('wraps drawing images with unified hyperlink anchors', () => { + const doc = createDoc(); + const drawing = { + kind: 'drawing', + drawingKind: 'image', + id: 'drawing-image-linked', + src: 'data:image/png;base64,AAA', + hyperlink: { url: 'https://example.com/drawing-image', tooltip: 'Open drawing image' }, + } as DrawingBlock; + + const anchor = createDrawingImageElement(doc, drawing, (imageEl, hyperlink, display) => + buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display), + ) as HTMLAnchorElement; + + expect(anchor.tagName).toBe('A'); + expect(anchor.classList.contains('superdoc-link')).toBe(true); + expect(anchor.href).toBe('https://example.com/drawing-image'); + expect(anchor.style.display).toBe('block'); + expect(anchor.querySelector('img.superdoc-drawing-image')).toBeTruthy(); + }); +}); From a96c451405cb36950b084002e85236df3b1f4e19 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:13:48 -0300 Subject: [PATCH 10/14] refactor(painters/dom): share image clip path reader --- .../painters/dom/src/images/image-block.ts | 13 +------------ .../painters/dom/src/runs/image-run.ts | 15 ++------------- .../painters/dom/src/utils/image-clip-path.ts | 11 +++++++++++ 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index 04cf537128..dd5e350dcd 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -1,6 +1,6 @@ import type { ImageBlock, ImageDrawing, ImageHyperlink } from '@superdoc/contracts'; import { buildImageFilters } from '../runs/image-run.js'; -import { applyImageClipPath } from '../utils/image-clip-path.js'; +import { applyImageClipPath, readImageClipPathValue } from '../utils/image-clip-path.js'; type BlockImageSource = ImageBlock | ImageDrawing; @@ -20,17 +20,6 @@ export type CreateBlockImageContentOptions = { buildImageHyperlinkAnchor?: BuildImageHyperlinkAnchor; }; -const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; - -export const readImageClipPathValue = (value: unknown): string => { - if (typeof value !== 'string') return ''; - const normalized = value.trim(); - if (normalized.length === 0) return ''; - const lower = normalized.toLowerCase(); - if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; - return normalized; -}; - const resolveClipPathFromAttrs = (attrs: unknown): string => { if (!attrs || typeof attrs !== 'object') return ''; const record = attrs as Record; diff --git a/packages/layout-engine/painters/dom/src/runs/image-run.ts b/packages/layout-engine/painters/dom/src/runs/image-run.ts index 245ff4c884..5869b51435 100644 --- a/packages/layout-engine/painters/dom/src/runs/image-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/image-run.ts @@ -1,7 +1,7 @@ import type { ImageBlock, ImageRun } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; import { assertPmPositions } from '../pm-position-validation.js'; -import { applyImageClipPath } from '../utils/image-clip-path.js'; +import { applyImageClipPath, readImageClipPathValue } from '../utils/image-clip-path.js'; import type { RunRenderContext } from './types.js'; import { applyRunDataAttributes } from './hash.js'; import { sanitizeUrl } from './links.js'; @@ -112,17 +112,6 @@ export const buildImageFilters = (source: ImageFilterSource): string[] => { return filters; }; -const CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; - -const readClipPathValue = (value: unknown): string => { - if (typeof value !== 'string') return ''; - const normalized = value.trim(); - if (normalized.length === 0) return ''; - const lower = normalized.toLowerCase(); - if (!CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; - return normalized; -}; - /** * Renders an ImageRun as an inline element. * @@ -341,7 +330,7 @@ export const renderImageRun = (run: ImageRun, context: RunRenderContext): HTMLEl applyRunDataAttributes(img, run.dataAttrs); } - const runClipPath = readClipPathValue((run as { clipPath?: unknown }).clipPath); + const runClipPath = readImageClipPathValue((run as { clipPath?: unknown }).clipPath); if (runClipPath) { img.style.clipPath = runClipPath; img.style.display = 'block'; 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 index f8468cfcc3..59949205ee 100644 --- a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts +++ b/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts @@ -1,5 +1,16 @@ import { parseInsetClipPathForScale } from '@superdoc/contracts'; +const SUPPORTED_IMAGE_CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +export const readImageClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!SUPPORTED_IMAGE_CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + /** * Resolves a clip-path value to a trimmed non-empty string, or undefined if invalid. */ From c15112835f39c02c5aa64dbbcd8848e20ca8051f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:14:31 -0300 Subject: [PATCH 11/14] refactor(painters/dom): share inline style applier --- .../painters/dom/src/images/image-fragment.ts | 9 +-------- packages/layout-engine/painters/dom/src/renderer.ts | 9 +-------- .../layout-engine/painters/dom/src/utils/apply-styles.ts | 7 +++++++ 3 files changed, 9 insertions(+), 16 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/utils/apply-styles.ts diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts index df75c1f9ad..7dbd78710c 100644 --- a/packages/layout-engine/painters/dom/src/images/image-fragment.ts +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -2,6 +2,7 @@ import type { ImageBlock, ImageFragment, ImageHyperlink, ResolvedImageItem, SdtM import { DOM_CLASS_NAMES } from '../constants.js'; import type { FragmentRenderContext } from '../renderer.js'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { applyStyles } from '../utils/apply-styles.js'; import { createBlockImageContent } from './image-block.js'; type BuildImageHyperlinkAnchor = ( @@ -29,14 +30,6 @@ type RenderImageFragmentOptions = { createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; }; -const applyStyles = (el: HTMLElement, styles: Partial): void => { - Object.entries(styles).forEach(([key, value]) => { - if (value != null && value !== '' && key in el.style) { - (el.style as unknown as Record)[key] = String(value); - } - }); -}; - export const buildImageGeometryTransform = (attrs: { width: number; height: number; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 98f0f7b2d4..d0aeeba23f 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -86,6 +86,7 @@ import { } from './images/drawing-image.js'; import { renderImageFragment as renderImageFragmentElement } from './images/image-fragment.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; +import { applyStyles } from './utils/apply-styles.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; export type { @@ -3951,11 +3952,3 @@ const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => blockId.startsWith('endnote-') || blockId.startsWith('__sd_semantic_footnote-') || blockId.startsWith('__sd_semantic_endnote-')); - -const applyStyles = (el: HTMLElement, styles: Partial): void => { - Object.entries(styles).forEach(([key, value]) => { - if (value != null && value !== '' && key in el.style) { - (el.style as unknown as Record)[key] = String(value); - } - }); -}; diff --git a/packages/layout-engine/painters/dom/src/utils/apply-styles.ts b/packages/layout-engine/painters/dom/src/utils/apply-styles.ts new file mode 100644 index 0000000000..6acf7a8a14 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/utils/apply-styles.ts @@ -0,0 +1,7 @@ +export const applyStyles = (el: HTMLElement, styles: Partial): void => { + Object.entries(styles).forEach(([key, value]) => { + if (value != null && value !== '' && key in el.style) { + (el.style as unknown as Record)[key] = String(value); + } + }); +}; From b609a31828fabe441e44901a34353a27e3358594 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:14:57 -0300 Subject: [PATCH 12/14] docs(painters/dom): preserve image transform rationale --- .../layout-engine/painters/dom/src/images/image-fragment.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts index 7dbd78710c..e3c6552de2 100644 --- a/packages/layout-engine/painters/dom/src/images/image-fragment.ts +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -128,6 +128,9 @@ export const renderImageFragment = ({ fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); } + // AIDEV-NOTE: Keep srcRect crop/zoom transforms on the image element via + // applyImageClipPath, and geometry transforms on the fragment wrapper. + // Putting both on the same element overwrites clip-path scaling. applyImageGeometryTransform(fragmentEl, { width: block.width ?? fragment.width, height: block.height ?? fragment.height, From 92d7ced39108acd0d3d298e8ef8110ed0f7ba12a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:15:36 -0300 Subject: [PATCH 13/14] refactor(painters/dom): share image hyperlink anchor type --- .../painters/dom/src/images/drawing-image.ts | 8 +------- .../layout-engine/painters/dom/src/images/image-block.ts | 9 ++------- .../painters/dom/src/images/image-fragment.ts | 9 ++------- packages/layout-engine/painters/dom/src/images/types.ts | 7 +++++++ 4 files changed, 12 insertions(+), 21 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/images/types.ts diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts index 3e12345ed9..823bd4d065 100644 --- a/packages/layout-engine/painters/dom/src/images/drawing-image.ts +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -1,19 +1,13 @@ import type { DrawingBlock, ImageDrawing, - ImageHyperlink, PositionedDrawingGeometry, ShapeGroupChild, TextPart, } from '@superdoc/contracts'; import { applyImageClipPath } from '../utils/image-clip-path.js'; import { createBlockImageContent } from './image-block.js'; - -type BuildImageHyperlinkAnchor = ( - imageEl: HTMLElement, - hyperlink: ImageHyperlink | undefined, - display: 'block' | 'inline-block', -) => HTMLElement; +import type { BuildImageHyperlinkAnchor } from './types.js'; export const createDrawingImageElement = ( doc: Document, diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index dd5e350dcd..b2bb08c988 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -1,15 +1,10 @@ -import type { ImageBlock, ImageDrawing, ImageHyperlink } from '@superdoc/contracts'; +import type { ImageBlock, ImageDrawing } from '@superdoc/contracts'; import { buildImageFilters } from '../runs/image-run.js'; import { applyImageClipPath, readImageClipPathValue } from '../utils/image-clip-path.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; type BlockImageSource = ImageBlock | ImageDrawing; -type BuildImageHyperlinkAnchor = ( - imageEl: HTMLElement, - hyperlink: ImageHyperlink | undefined, - display: 'block' | 'inline-block', -) => HTMLElement; - export type CreateBlockImageContentOptions = { doc: Document; block: BlockImageSource; diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts index e3c6552de2..015c28fd7a 100644 --- a/packages/layout-engine/painters/dom/src/images/image-fragment.ts +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -1,15 +1,10 @@ -import type { ImageBlock, ImageFragment, ImageHyperlink, ResolvedImageItem, SdtMetadata } from '@superdoc/contracts'; +import type { ImageBlock, ImageFragment, ResolvedImageItem, SdtMetadata } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; import type { FragmentRenderContext } from '../renderer.js'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { applyStyles } from '../utils/apply-styles.js'; import { createBlockImageContent } from './image-block.js'; - -type BuildImageHyperlinkAnchor = ( - imageEl: HTMLElement, - hyperlink: ImageHyperlink | undefined, - display: 'block' | 'inline-block', -) => HTMLElement; +import type { BuildImageHyperlinkAnchor } from './types.js'; type RenderImageFragmentOptions = { doc: Document | null; diff --git a/packages/layout-engine/painters/dom/src/images/types.ts b/packages/layout-engine/painters/dom/src/images/types.ts new file mode 100644 index 0000000000..87ecda51b4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/types.ts @@ -0,0 +1,7 @@ +import type { ImageHyperlink } from '@superdoc/contracts'; + +export type BuildImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +) => HTMLElement; From 939ad72269cd7c369984787cea34b9bd78dec84f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:40:45 -0300 Subject: [PATCH 14/14] refactor(painters/dom): co-locate image utils under images/ Move image-clip-path and image-selectors (plus their tests) from utils/ into the images/ directory alongside image-block, drawing-image, hyperlink, and image-fragment. Update import paths in callers. No behavior change. --- packages/layout-engine/painters/dom/src/images/drawing-image.ts | 2 +- packages/layout-engine/painters/dom/src/images/image-block.ts | 2 +- .../painters/dom/src/{utils => images}/image-clip-path.test.ts | 0 .../painters/dom/src/{utils => images}/image-clip-path.ts | 0 .../painters/dom/src/{utils => images}/image-selectors.test.ts | 0 .../painters/dom/src/{utils => images}/image-selectors.ts | 0 packages/layout-engine/painters/dom/src/index.ts | 2 +- packages/layout-engine/painters/dom/src/runs/image-run.ts | 2 +- 8 files changed, 4 insertions(+), 4 deletions(-) rename packages/layout-engine/painters/dom/src/{utils => images}/image-clip-path.test.ts (100%) rename packages/layout-engine/painters/dom/src/{utils => images}/image-clip-path.ts (100%) rename packages/layout-engine/painters/dom/src/{utils => images}/image-selectors.test.ts (100%) rename packages/layout-engine/painters/dom/src/{utils => images}/image-selectors.ts (100%) diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts index 823bd4d065..70f72c7429 100644 --- a/packages/layout-engine/painters/dom/src/images/drawing-image.ts +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -5,7 +5,7 @@ import type { ShapeGroupChild, TextPart, } from '@superdoc/contracts'; -import { applyImageClipPath } from '../utils/image-clip-path.js'; +import { applyImageClipPath } from './image-clip-path.js'; import { createBlockImageContent } from './image-block.js'; import type { BuildImageHyperlinkAnchor } from './types.js'; diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts index b2bb08c988..0541294831 100644 --- a/packages/layout-engine/painters/dom/src/images/image-block.ts +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -1,6 +1,6 @@ import type { ImageBlock, ImageDrawing } from '@superdoc/contracts'; import { buildImageFilters } from '../runs/image-run.js'; -import { applyImageClipPath, readImageClipPathValue } from '../utils/image-clip-path.js'; +import { applyImageClipPath, readImageClipPathValue } from './image-clip-path.js'; import type { BuildImageHyperlinkAnchor } from './types.js'; type BlockImageSource = ImageBlock | ImageDrawing; diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts b/packages/layout-engine/painters/dom/src/images/image-clip-path.test.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts rename to packages/layout-engine/painters/dom/src/images/image-clip-path.test.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts b/packages/layout-engine/painters/dom/src/images/image-clip-path.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-clip-path.ts rename to packages/layout-engine/painters/dom/src/images/image-clip-path.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-selectors.test.ts b/packages/layout-engine/painters/dom/src/images/image-selectors.test.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-selectors.test.ts rename to packages/layout-engine/painters/dom/src/images/image-selectors.test.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-selectors.ts b/packages/layout-engine/painters/dom/src/images/image-selectors.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-selectors.ts rename to packages/layout-engine/painters/dom/src/images/image-selectors.ts diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 3207a7e360..e649ce5f41 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -47,7 +47,7 @@ export type { RenderedLineInfo } from './runs/index.js'; export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './runs/index.js'; export { applySquareWrapExclusionsToLines } from './utils/anchor-helpers'; -export { buildImagePmSelector, buildInlineImagePmSelector } from './utils/image-selectors.js'; +export { buildImagePmSelector, buildInlineImagePmSelector } from './images/image-selectors.js'; // Re-export PM position validation utilities export { diff --git a/packages/layout-engine/painters/dom/src/runs/image-run.ts b/packages/layout-engine/painters/dom/src/runs/image-run.ts index 5869b51435..e767dee78b 100644 --- a/packages/layout-engine/painters/dom/src/runs/image-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/image-run.ts @@ -1,7 +1,7 @@ import type { ImageBlock, ImageRun } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; import { assertPmPositions } from '../pm-position-validation.js'; -import { applyImageClipPath, readImageClipPathValue } from '../utils/image-clip-path.js'; +import { applyImageClipPath, readImageClipPathValue } from '../images/image-clip-path.js'; import type { RunRenderContext } from './types.js'; import { applyRunDataAttributes } from './hash.js'; import { sanitizeUrl } from './links.js';