From 0a463ac3d1be6ea3f34e962341553f68ec5a32a4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 16:50:38 -0300 Subject: [PATCH 01/37] refactor(painters/dom): consolidate SDT container helpers into sdt/ module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move sdt-helpers from utils/ into a dedicated sdt/container.ts module and rename applySdtContainerStyling → applySdtContainerChrome. Introduce shouldRenderSdtContainerChrome, getSdtContainerKeyForBlock, and getSdtSiblingBoundaries so renderer.ts and renderTableCell.ts can share the same boundary/suppression logic instead of inlining metadata comparisons. Table cells now receive an ancestorTableSdtKey rather than the raw tableSdt object, making the duplicate-suppression contract explicit and key-based. --- .../src/paragraph/renderParagraphContent.ts | 4 +- .../src/paragraph/renderParagraphFragment.ts | 8 +- .../painters/dom/src/renderer.ts | 2 +- .../painters/dom/src/sdt/container.test.ts | 110 +++++++ .../painters/dom/src/sdt/container.ts | 190 ++++++++++++ .../layout-engine/painters/dom/src/styles.ts | 2 +- .../dom/src/table/renderTableCell.test.ts | 116 +++++++- .../painters/dom/src/table/renderTableCell.ts | 54 +--- .../dom/src/table/renderTableFragment.ts | 10 +- .../painters/dom/src/table/renderTableRow.ts | 8 +- .../painters/dom/src/utils/sdt-helpers.ts | 281 ------------------ 11 files changed, 440 insertions(+), 345 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/sdt/container.test.ts create mode 100644 packages/layout-engine/painters/dom/src/sdt/container.ts delete mode 100644 packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 0cbf5cfa2d..491387c9fe 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -14,7 +14,7 @@ import { getParagraphInlineDirection, } from '@superdoc/contracts'; import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { applySdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; import { applyParagraphLineIndentation, @@ -137,7 +137,7 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re const applySdtChrome = shouldApplySdtContainerStyling?.(block.attrs?.sdt, block.attrs?.containerSdt) ?? true; if (applySdtChrome) { - applySdtContainerStyling(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); } renderParagraphDropCap({ diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 5ee43f8b25..3b89dcce38 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -9,7 +9,7 @@ import type { import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; -import type { SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; import type { BetweenBorderInfo } from './borders/index.js'; import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; @@ -75,11 +75,7 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): const isTocEntry = block.attrs?.isTocEntry; const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; - const hasSdtContainer = - block.attrs?.sdt?.type === 'documentSection' || - block.attrs?.sdt?.type === 'structuredContent' || - block.attrs?.containerSdt?.type === 'documentSection' || - block.attrs?.containerSdt?.type === 'structuredContent'; + const hasSdtContainer = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt); const paraIndentForOverflow = block.attrs?.indent; const hasNegativeIndent = (paraIndentForOverflow?.left ?? 0) < 0 || (paraIndentForOverflow?.right ?? 0) < 0; const styles = isTocEntry diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index b264267f30..7056b5fd52 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -79,7 +79,7 @@ import { 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 { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; +import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; import { deriveParagraphBlockVersion, hashParagraphBlockForTableVersion } from './paragraph/block-version.js'; import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts new file mode 100644 index 0000000000..371cea22ef --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import type { SdtMetadata } from '@superdoc/contracts'; +import { + applySdtContainerChrome, + getSdtContainerKey, + getSdtSiblingBoundaries, + shouldRenderSdtContainerChrome, +} from './container.js'; + +describe('SDT container chrome', () => { + it('renders block structuredContent chrome', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'block-sdt', + alias: 'Signer', + }; + + applySdtContainerChrome(doc, el, sdt); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(el.dataset.sdtContainerStart).toBe('true'); + expect(el.dataset.sdtContainerEnd).toBe('true'); + expect(el.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Signer'); + }); + + it('does not render block chrome for inline structuredContent', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome(doc, el, { + type: 'structuredContent', + scope: 'inline', + id: 'inline-sdt', + alias: 'Inline', + }); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(el.dataset.sdtContainerStart).toBeUndefined(); + }); + + it('renders documentSection chrome', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome(doc, el, { + type: 'documentSection', + id: 'section-1', + title: 'Locked Section', + }); + + expect(el.classList.contains('superdoc-document-section')).toBe(true); + expect(el.querySelector('.superdoc-document-section__tooltip')?.textContent).toBe('Locked Section'); + }); + + it('uses containerSdt as a fallback', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome(doc, el, null, { + type: 'structuredContent', + scope: 'block', + id: 'container-sdt', + alias: 'Container', + }); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(el.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Container'); + }); + + it('suppresses same-key ancestor chrome', () => { + const childSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'shared-sdt', + alias: 'Child', + }; + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'shared-sdt', + alias: 'Ancestor', + }; + + expect( + shouldRenderSdtContainerChrome(childSdt, null, { + ancestorContainerKey: getSdtContainerKey(ancestorSdt), + }), + ).toBe(false); + + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + applySdtContainerChrome(doc, el, childSdt, null, undefined, { + ancestorContainerKey: getSdtContainerKey(ancestorSdt), + }); + expect(el.classList.contains('superdoc-structured-content-block')).toBe(false); + }); + + it('computes stable sibling start and end boundaries', () => { + expect(getSdtSiblingBoundaries(['a', 'a', 'b', null, 'b'])).toEqual([ + { isStart: true, isEnd: false }, + { isStart: false, isEnd: true }, + { isStart: true, isEnd: true }, + undefined, + { isStart: true, isEnd: true }, + ]); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts new file mode 100644 index 0000000000..732192cea5 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -0,0 +1,190 @@ +import type { FlowBlock, SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; + +type SdtBlockCandidate = Pick & { + attrs?: { + sdt?: SdtMetadata | null; + containerSdt?: SdtMetadata | null; + } | null; +}; + +export type SdtContainerConfig = { + className: string; + labelText: string; + labelClassName: string; + isStart: boolean; + isEnd: boolean; +} | null; + +export type SdtBoundaryOptions = { + isStart?: boolean; + isEnd?: boolean; + widthOverride?: number; + paddingBottomOverride?: number; + showLabel?: boolean; +}; + +export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { + type: 'structuredContent'; + scope: 'inline' | 'block'; + alias?: string | null; + lockMode?: StructuredContentLockMode; +} { + return ( + sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'structuredContent' + ); +} + +export function isDocumentSectionMetadata( + sdt: SdtMetadata | null | undefined, +): sdt is { type: 'documentSection'; title?: string | null } { + return ( + sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'documentSection' + ); +} + +export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtContainerConfig { + if (isDocumentSectionMetadata(sdt)) { + return { + className: 'superdoc-document-section', + labelText: sdt.title ?? 'Document section', + labelClassName: 'superdoc-document-section__tooltip', + isStart: true, + isEnd: true, + }; + } + + if (isStructuredContentMetadata(sdt) && sdt.scope === 'block') { + return { + className: 'superdoc-structured-content-block', + labelText: sdt.alias ?? 'Structured content', + labelClassName: 'superdoc-structured-content__label superdoc-structured-content-block__label', + isStart: true, + isEnd: true, + }; + } + + return null; +} + +export function getSdtContainerMetadata( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, +): SdtMetadata | null { + if (getSdtContainerConfig(sdt)) return sdt ?? null; + if (getSdtContainerConfig(containerSdt)) return containerSdt ?? null; + return null; +} + +export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (!metadata.id) return null; + return `structuredContent:${metadata.id}`; + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (!sectionId) return null; + return `documentSection:${sectionId}`; + } + + return null; +} + +export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { + if (!block || (block.kind !== 'paragraph' && block.kind !== 'table')) return null; + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); +} + +export function shouldRenderSdtContainerChrome( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, + options?: { + ancestorContainerKey?: string | null; + containerKey?: string | null; + }, +): boolean { + const config = getSdtContainerConfig(sdt) ?? getSdtContainerConfig(containerSdt); + if (!config) return false; + + const containerKey = options?.containerKey ?? getSdtContainerKey(sdt, containerSdt); + return !(containerKey && options?.ancestorContainerKey && containerKey === options.ancestorContainerKey); +} + +export function getSdtSiblingBoundaries( + containerKeys: readonly (string | null)[], +): Array { + return containerKeys.map((key, index): SdtBoundaryOptions | undefined => { + if (!key) return undefined; + const prev = index > 0 ? containerKeys[index - 1] : null; + const next = index < containerKeys.length - 1 ? containerKeys[index + 1] : null; + return { isStart: key !== prev, isEnd: key !== next }; + }); +} + +export function applySdtContainerChrome( + doc: Document, + container: HTMLElement, + sdt: SdtMetadata | null | undefined, + containerSdt?: SdtMetadata | null | undefined, + boundaryOptions?: SdtBoundaryOptions, + options?: { ancestorContainerKey?: string | null; containerKey?: string | null }, +): void { + if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return; + + let config = getSdtContainerConfig(sdt); + if (!config && containerSdt) { + config = getSdtContainerConfig(containerSdt); + } + if (!config) return; + + const isStart = boundaryOptions?.isStart ?? config.isStart; + const isEnd = boundaryOptions?.isEnd ?? config.isEnd; + + container.classList.add(config.className); + container.dataset.sdtContainerStart = String(isStart); + container.dataset.sdtContainerEnd = String(isEnd); + container.style.overflow = 'visible'; + + if (isStructuredContentMetadata(sdt)) { + container.dataset.lockMode = sdt.lockMode || 'unlocked'; + } else if (isStructuredContentMetadata(containerSdt)) { + container.dataset.lockMode = containerSdt.lockMode || 'unlocked'; + } + + if (boundaryOptions?.widthOverride != null) { + container.style.width = `${boundaryOptions.widthOverride}px`; + } + + if (boundaryOptions?.paddingBottomOverride != null && boundaryOptions.paddingBottomOverride > 0) { + container.style.paddingBottom = `${boundaryOptions.paddingBottomOverride}px`; + } + + const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; + + if (shouldShowLabel) { + const labelEl = doc.createElement('div'); + labelEl.className = config.labelClassName; + const labelText = doc.createElement('span'); + labelText.textContent = config.labelText; + labelEl.appendChild(labelText); + container.appendChild(labelEl); + } +} + +export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { + if (!boundary) { + return element.dataset.sdtContainerStart !== undefined; + } + const startAttr = element.dataset.sdtContainerStart; + const endAttr = element.dataset.sdtContainerEnd; + const expectedStart = String(boundary.isStart ?? true); + const expectedEnd = String(boundary.isEnd ?? true); + if (startAttr === undefined || endAttr === undefined) { + return true; + } + return startAttr !== expectedStart || endAttr !== expectedEnd; +} diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index cd916debff..bd98f42ebb 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -417,7 +417,7 @@ const FORMATTING_MARKS_STYLES = ` * * **Implementation Note:** * These styles are injected once per document via ensureSdtContainerStyles() to avoid - * duplication. The DOM painter applies corresponding classes via applySdtContainerStyling(). + * duplication. The DOM painter applies corresponding classes via applySdtContainerChrome(). */ const SDT_CONTAINER_STYLES = ` /* Document Section - Block-level container with gray border and hover tooltip */ 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 9430547ba5..574ef7a839 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -6,7 +6,9 @@ import type { ParagraphMeasure, TableCell, TableCellMeasure, + TableBlock, TableMeasure, + SdtMetadata, ImageBlock, DrawingBlock, DrawingMeasure, @@ -3692,20 +3694,26 @@ describe('renderTableCell', () => { expect(cellElement.style.overflow).toBe('hidden'); }); - it('should not apply SDT container styling when block SDT matches tableSdt', () => { - const tableSdt = { + it('should not apply SDT container styling when block SDT key matches ancestor table SDT key', () => { + const tableSdt: SdtMetadata = { type: 'structuredContent' as const, scope: 'block' as const, id: 'table-sdt', alias: 'Table Container', }; + const blockSdt: SdtMetadata = { + type: 'structuredContent' as const, + scope: 'block' as const, + id: 'table-sdt', + alias: 'Cell Container', + }; const para: ParagraphBlock = { kind: 'paragraph', id: 'para-same-sdt', runs: [{ text: 'Content in table SDT', fontFamily: 'Arial', fontSize: 16 }], attrs: { - sdt: tableSdt, // Same reference as tableSdt + sdt: blockSdt, }, }; @@ -3745,12 +3753,11 @@ describe('renderTableCell', () => { ...createBaseDeps(), cellMeasure, cell, - tableSdt, // Pass the same SDT as the table level + ancestorTableSdtKey: 'structuredContent:table-sdt', }); - // Cell should keep overflow:hidden because block SDT matches tableSdt - // (no duplicate container styling needed) expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); }); it('should keep overflow:hidden for inline scope structuredContent (not a block container)', () => { @@ -3808,6 +3815,103 @@ describe('renderTableCell', () => { // Inline SDTs don't get container styling, so overflow stays hidden expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should set overflow:visible and render chrome when cell contains nested table SDT', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-sdt-table', + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'nested-table-sdt', + alias: 'Nested Table', + }, + }, + rows: [ + { + id: 'nested-row', + cells: [ + { + id: 'nested-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + const cellMeasure: TableCellMeasure = { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + const cell: TableCell = { + id: 'cell-nested-table-sdt', + blocks: [nestedTable], + attrs: {}, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + }); + + expect(cellElement.style.overflow).toBe('visible'); + const tableChrome = cellElement.querySelector('[data-block-id="nested-sdt-table"]') as HTMLElement; + expect(tableChrome?.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); }); }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c8453ea764..1a93281d23 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -21,7 +21,12 @@ 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 { getSdtContainerConfig, getSdtContainerKey, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { + getSdtContainerKeyForBlock, + getSdtSiblingBoundaries, + shouldRenderSdtContainerChrome, + type SdtBoundaryOptions, +} from '../sdt/container.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; @@ -504,8 +509,8 @@ type TableCellRenderDependencies = { context: FragmentRenderContext; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Table-level SDT metadata for suppressing duplicate container styling in cells */ - tableSdt?: SdtMetadata | null; + /** Table-level SDT container key for suppressing duplicate container styling in cells */ + ancestorTableSdtKey?: string | null; /** Table indent in pixels (applied to table fragment positioning) */ tableIndent?: number; /** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */ @@ -596,7 +601,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, context, applySdtDataset, - tableSdt, + ancestorTableSdtKey, tableIndent, isRtl, cellWidth, @@ -640,46 +645,17 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // Support multi-block cells with backward compatibility const cellBlocks = cell?.blocks ?? (cell?.paragraph ? [cell.paragraph] : []); const blockMeasures = cellMeasure?.blocks ?? (cellMeasure?.paragraph ? [cellMeasure.paragraph] : []); - const sdtContainerKeys = cellBlocks.map((block) => { - if (block.kind !== 'paragraph') { - return null; - } - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - }); - - const sdtBoundaries = sdtContainerKeys.map((key, index): SdtBoundaryOptions | undefined => { - if (!key) return undefined; - const prev = index > 0 ? sdtContainerKeys[index - 1] : null; - const next = index < sdtContainerKeys.length - 1 ? sdtContainerKeys[index + 1] : null; - return { isStart: key !== prev, isEnd: key !== next }; - }); - /** - * Determines if SDT container styling should be applied to a block. - * - * We skip styling when the block's SDT matches the table's SDT to prevent - * duplicate visual containers - the table already has the SDT container styling, - * so individual paragraphs inside it shouldn't also show container borders. - * - * @param sdt - The block's direct SDT metadata - * @param containerSdt - The block's inherited container SDT metadata - * @returns True if container styling should be applied - */ - const tableSdtKey = tableSdt ? getSdtContainerKey(tableSdt, null) : null; + const sdtContainerKeys = cellBlocks.map((block) => getSdtContainerKeyForBlock(block)); + const sdtBoundaries = getSdtSiblingBoundaries(sdtContainerKeys); const shouldApplySdtContainerStyling = ( sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null, blockKey?: string | null, ): boolean => { - const resolvedKey = blockKey ?? getSdtContainerKey(sdt, containerSdt); - // Skip if this SDT is the same as the table's SDT (already styled at table level) - if (tableSdtKey && resolvedKey && tableSdtKey === resolvedKey) { - return false; - } - if (tableSdt && (sdt === tableSdt || containerSdt === tableSdt)) { - return false; - } - return Boolean(getSdtContainerConfig(sdt) || getSdtContainerConfig(containerSdt)); + return shouldRenderSdtContainerChrome(sdt, containerSdt, { + ancestorContainerKey: ancestorTableSdtKey, + containerKey: blockKey, + }); }; // Check if any block in the cell has SDT container styling diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index d8e76cabb8..2ced281a13 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -13,7 +13,7 @@ import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; import type { FragmentRenderContext } from '../renderer.js'; import { renderTableRow } from './renderTableRow.js'; -import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { applySdtContainerChrome, getSdtContainerKey, type SdtBoundaryOptions } from '../sdt/container.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; import { getTableCellGridBounds } from './grid-geometry.js'; @@ -84,7 +84,7 @@ export type TableRenderDependencies = { * * **SDT Container Styling:** * If the table block has SDT metadata (`block.attrs?.sdt`), applies appropriate - * container styling via `applySdtContainerStyling()`: + * container styling via `applySdtContainerChrome()`: * - Document sections: Gray border with hover tooltip * - Structured content blocks: Blue border with label * Uses type-safe helper functions to avoid unsafe type assertions. @@ -202,7 +202,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const contentTop = tableBorderWidths?.top ?? 0; // Apply SDT container styling (document sections, structured content blocks) - applySdtContainerStyling(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); // Add table-specific class for resize overlay targeting and click mapping container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); @@ -385,7 +385,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - tableSdt: block.attrs?.sdt ?? null, + ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -547,7 +547,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - tableSdt: block.attrs?.sdt ?? null, + ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 764f5873d2..a6cdb18f26 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -173,8 +173,8 @@ type TableRowRenderDependencies = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Table-level SDT metadata for suppressing duplicate container styling in cells */ - tableSdt?: SdtMetadata | null; + /** Table-level SDT container key for suppressing duplicate container styling in cells */ + ancestorTableSdtKey?: string | null; /** * If true, this row is the first body row of a continuation fragment. * MS Word draws borders at split points to visually close the table on each page, @@ -254,7 +254,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { captureLineSnapshot, renderDrawingContent, applySdtDataset, - tableSdt, + ancestorTableSdtKey, continuesFromPrev, continuesOnNext, partialRow, @@ -426,7 +426,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderDrawingContent, context, applySdtDataset, - tableSdt, + ancestorTableSdtKey, fromLine, toLine, tableIndent, diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts deleted file mode 100644 index 7ded23f441..0000000000 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * SDT Helper Utilities - * - * Provides type guards and helper functions for working with SDT (Structured Document Tag) metadata - * in the DOM painter. These utilities ensure type-safe access to SDT properties and reduce code - * duplication across rendering logic. - */ - -import type { SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; - -/** - * Type guard for StructuredContentMetadata. - */ -export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { - type: 'structuredContent'; - scope: 'inline' | 'block'; - alias?: string | null; - lockMode?: StructuredContentLockMode; -} { - return ( - sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'structuredContent' - ); -} - -/** - * Type guard for DocumentSectionMetadata. - */ -export function isDocumentSectionMetadata( - sdt: SdtMetadata | null | undefined, -): sdt is { type: 'documentSection'; title?: string | null } { - return ( - sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'documentSection' - ); -} - -/** - * SDT container styling configuration returned by applySdtContainerStyling. - */ -export type SdtContainerConfig = { - /** CSS class name to add to the container element */ - className: string; - /** Label/tooltip text to display */ - labelText: string; - /** Label element class name */ - labelClassName: string; - /** Whether this is the start of the SDT container (for multi-fragment SDTs) */ - isStart: boolean; - /** Whether this is the end of the SDT container (for multi-fragment SDTs) */ - isEnd: boolean; -} | null; - -/** - * Determines SDT container styling configuration based on metadata. - * - * Analyzes the SDT metadata and returns configuration for applying visual styling - * to block-level SDT containers (document sections and structured content blocks). - * This function centralizes the logic for determining container appearance, - * eliminating duplication between paragraph and table rendering. - * - * **Supported SDT Types:** - * - `documentSection`: Gray bordered container with hover tooltip showing title - * - `structuredContent` (block scope): Blue bordered container with label showing alias - * - `structuredContent` (inline scope): Returns null (not a block container) - * - Other types: Returns null (no container styling) - * - * **Container Continuation:** - * For SDTs that span multiple fragments (pages), the `isStart` and `isEnd` flags - * control border radius and border visibility: - * - Start fragment: Top borders and top border radius - * - Middle fragments: No top/bottom borders or radius - * - End fragment: Bottom borders and bottom border radius - * - * @param sdt - The SDT metadata from block.attrs?.sdt - * @returns Configuration object with styling details, or null if no container styling needed - * - * @example - * ```typescript - * const config = getSdtContainerConfig(block.attrs?.sdt); - * if (config) { - * container.classList.add(config.className); - * container.dataset.sdtContainerStart = String(config.isStart); - * container.dataset.sdtContainerEnd = String(config.isEnd); - * // Create label element... - * } - * ``` - */ -export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtContainerConfig { - if (isDocumentSectionMetadata(sdt)) { - return { - className: 'superdoc-document-section', - labelText: sdt.title ?? 'Document section', - labelClassName: 'superdoc-document-section__tooltip', - isStart: true, - isEnd: true, - }; - } - - if (isStructuredContentMetadata(sdt) && sdt.scope === 'block') { - return { - className: 'superdoc-structured-content-block', - labelText: sdt.alias ?? 'Structured content', - labelClassName: 'superdoc-structured-content__label superdoc-structured-content-block__label', - isStart: true, - isEnd: true, - }; - } - - return null; -} - -/** - * Returns the SDT metadata for container styling, preferring `sdt` over `containerSdt`. - */ -export function getSdtContainerMetadata( - sdt?: SdtMetadata | null, - containerSdt?: SdtMetadata | null, -): SdtMetadata | null { - if (getSdtContainerConfig(sdt)) return sdt ?? null; - if (getSdtContainerConfig(containerSdt)) return containerSdt ?? null; - return null; -} - -/** - * Returns a stable key for grouping consecutive fragments in the same SDT container. - */ -export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (!metadata.id) { - return null; - } - return `structuredContent:${metadata.id}`; - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) { - return null; - } - return `documentSection:${sectionId}`; - } - - return null; -} - -/** - * Options for SDT container boundary overrides. - * - * When multiple consecutive fragments share the same SDT container metadata, - * use these options to control which fragments show start/end styling. - */ -export type SdtBoundaryOptions = { - /** Override isStart - true for first fragment in SDT group */ - isStart?: boolean; - /** Override isEnd - true for last fragment in SDT group */ - isEnd?: boolean; - /** Optional width override for the SDT container element */ - widthOverride?: number; - /** Optional padding bottom override for filling gaps between fragments */ - paddingBottomOverride?: number; - /** Whether to show the label (overrides isStart check if provided) */ - showLabel?: boolean; -}; - -/** - * Applies SDT container styling to a DOM element. - * - * This helper function encapsulates all logic for applying block-level SDT container - * styling, including CSS classes, data attributes, overflow settings, and label/tooltip - * elements. It eliminates code duplication between paragraph fragment rendering and - * table fragment rendering. - * - * **Container SDT Fallback:** - * If the primary `sdt` parameter is null/undefined or doesn't match a container type, - * the function will check the `containerSdt` parameter as a fallback. This supports - * paragraphs inside document sections where the paragraph itself doesn't have `sdt` - * but inherits container styling from its parent section. - * - * **Visual Effects Applied:** - * - Container CSS class for border and background styling - * - Data attributes for continuation detection (`data-sdt-container-start/end`) - * - Overflow visible to allow labels to appear above content - * - Label/tooltip element created and appended to container when isStart=true - * - Padding bottom applied if paddingBottomOverride is provided (for filling gaps) - * - * **Label Element Structure:** - * ```html - *
- * Section Title - *
- * ``` - * - * **Non-Destructive:** - * This function only adds classes and elements; it does not remove existing styling. - * It's safe to call multiple times or alongside other styling logic. - * - * @param doc - Document object for creating DOM elements - * @param container - The container element to style (typically a fragment div) - * @param sdt - The primary SDT metadata from block.attrs?.sdt - * @param containerSdt - Optional fallback SDT metadata from block.attrs?.containerSdt - * @param boundaryOptions - Optional overrides for start/end styling in multi-fragment containers - * - * @example - * ```typescript - * const container = doc.createElement('div'); - * container.classList.add(CLASS_NAMES.fragment); - * applySdtContainerStyling(doc, container, block.attrs?.sdt, block.attrs?.containerSdt); - * // Container now has SDT styling if applicable - * ``` - */ -export function applySdtContainerStyling( - doc: Document, - container: HTMLElement, - sdt: SdtMetadata | null | undefined, - containerSdt?: SdtMetadata | null | undefined, - boundaryOptions?: SdtBoundaryOptions, -): void { - let config = getSdtContainerConfig(sdt); - if (!config && containerSdt) { - config = getSdtContainerConfig(containerSdt); - } - if (!config) return; - - const isStart = boundaryOptions?.isStart ?? config.isStart; - const isEnd = boundaryOptions?.isEnd ?? config.isEnd; - - container.classList.add(config.className); - container.dataset.sdtContainerStart = String(isStart); - container.dataset.sdtContainerEnd = String(isEnd); - container.style.overflow = 'visible'; // Allow label to show above - - if (isStructuredContentMetadata(sdt)) { - container.dataset.lockMode = sdt.lockMode || 'unlocked'; - } else if (isStructuredContentMetadata(containerSdt)) { - container.dataset.lockMode = containerSdt.lockMode || 'unlocked'; - } - - if (boundaryOptions?.widthOverride != null) { - container.style.width = `${boundaryOptions.widthOverride}px`; - } - - if (boundaryOptions?.paddingBottomOverride != null && boundaryOptions.paddingBottomOverride > 0) { - container.style.paddingBottom = `${boundaryOptions.paddingBottomOverride}px`; - } - - const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; - - if (shouldShowLabel) { - const labelEl = doc.createElement('div'); - labelEl.className = config.labelClassName; - const labelText = doc.createElement('span'); - labelText.textContent = config.labelText; - labelEl.appendChild(labelText); - container.appendChild(labelEl); - } -} - -/** - * Checks whether a fragment element needs rebuilding due to SDT boundary changes. - * - * Handles two cases: - * 1. Element was in an SDT but no longer is (stale attributes need removal) - * 2. Element's start/end boundary flags don't match expected values - */ -export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { - if (!boundary) { - // Rebuild if element has stale SDT container attributes that should be removed - return element.dataset.sdtContainerStart !== undefined; - } - const startAttr = element.dataset.sdtContainerStart; - const endAttr = element.dataset.sdtContainerEnd; - const expectedStart = String(boundary.isStart ?? true); - const expectedEnd = String(boundary.isEnd ?? true); - if (startAttr === undefined || endAttr === undefined) { - return true; - } - return startAttr !== expectedStart || endAttr !== expectedEnd; -} From 32849625f44fdeae2e7c0b4104f7f113eecdaa15 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 16:56:03 -0300 Subject: [PATCH 02/37] fix(painters/dom): continue nested table SDT chrome --- .../dom/src/table/renderTableCell.test.ts | 119 ++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 8 ++ 2 files changed, 127 insertions(+) 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 574ef7a839..07d701670b 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3913,6 +3913,125 @@ describe('renderTableCell', () => { expect(tableChrome?.classList.contains('superdoc-structured-content-block')).toBe(true); expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); }); + + it('should continue SDT boundaries across adjacent paragraph and nested table blocks', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'shared-block-sdt', + alias: 'Shared Block', + }; + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'sdt-paragraph-before-table', + runs: [{ text: 'Before', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: sharedSdt }, + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-shared-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-shared-sdt-table', + attrs: { sdt: sharedSdt }, + rows: [ + { + id: 'nested-shared-row', + cells: [ + { + id: 'nested-shared-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, nestedMeasure], + width: 120, + height: 44, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-shared-sdt-paragraph-table', + blocks: [paragraph, nestedTable], + attrs: {}, + }, + }); + + const chromeElements = cellElement.querySelectorAll('.superdoc-structured-content-block'); + const paragraphChrome = chromeElements[0]; + const tableChrome = cellElement.querySelector('[data-block-id="nested-shared-sdt-table"]') as HTMLElement; + expect(chromeElements).toHaveLength(2); + expect(paragraphChrome?.dataset.sdtContainerStart).toBe('true'); + expect(paragraphChrome?.dataset.sdtContainerEnd).toBe('false'); + expect(tableChrome?.dataset.sdtContainerStart).toBe('false'); + expect(tableChrome?.dataset.sdtContainerEnd).toBe('true'); + expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 1a93281d23..12289d2e2f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -227,6 +227,8 @@ type EmbeddedTableRenderParams = { toRow?: number; /** Partial row info for mid-row splits within the embedded table */ partialRow?: PartialRowInfo; + /** Optional SDT boundary overrides for container styling */ + sdtBoundary?: SdtBoundaryOptions; }; /** @@ -271,6 +273,7 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, + sdtBoundary, } = params; const effectiveFromRow = paramFromRow ?? 0; @@ -320,6 +323,7 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => applyFragmentFrame, applySdtDataset, applyStyles: applyInlineStyles, + sdtBoundary, }); }; @@ -343,6 +347,7 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot?: EmbeddedTableRenderParams['captureLineSnapshot']; renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; + sdtBoundary?: SdtBoundaryOptions; }): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number } { const { doc, @@ -357,6 +362,7 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot, renderDrawingContent, applySdtDataset, + sdtBoundary, } = params; // Compute per-row segment counts (recursive, matching getCellLines/getEmbeddedRowLines). @@ -453,6 +459,7 @@ function renderPartialEmbeddedTable(params: { fromRow: embeddedFromRow, toRow: embeddedToRow, partialRow: partialRowInfo, + sdtBoundary, }); tableWrapper.appendChild(tableEl); @@ -749,6 +756,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot, renderDrawingContent, applySdtDataset, + sdtBoundary: sdtBoundaries[i], }); cumulativeLineCount = result.nextCumulativeLineCount; if (result.element) { From 85e77a278e49b8993ac00732340db5649f3e4a94 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 16:57:55 -0300 Subject: [PATCH 03/37] fix(painters/dom): suppress idless table SDT chrome --- .../painters/dom/src/sdt/container.ts | 18 ++++++- .../dom/src/table/renderTableCell.test.ts | 53 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 4 ++ .../dom/src/table/renderTableFragment.ts | 2 + .../painters/dom/src/table/renderTableRow.ts | 4 ++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 732192cea5..5092b629d6 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -104,6 +104,7 @@ export function shouldRenderSdtContainerChrome( containerSdt?: SdtMetadata | null, options?: { ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; containerKey?: string | null; }, ): boolean { @@ -111,7 +112,16 @@ export function shouldRenderSdtContainerChrome( if (!config) return false; const containerKey = options?.containerKey ?? getSdtContainerKey(sdt, containerSdt); - return !(containerKey && options?.ancestorContainerKey && containerKey === options.ancestorContainerKey); + if (containerKey && options?.ancestorContainerKey && containerKey === options.ancestorContainerKey) { + return false; + } + + const ancestorContainerSdt = options?.ancestorContainerSdt; + if (ancestorContainerSdt && (sdt === ancestorContainerSdt || containerSdt === ancestorContainerSdt)) { + return false; + } + + return true; } export function getSdtSiblingBoundaries( @@ -131,7 +141,11 @@ export function applySdtContainerChrome( sdt: SdtMetadata | null | undefined, containerSdt?: SdtMetadata | null | undefined, boundaryOptions?: SdtBoundaryOptions, - options?: { ancestorContainerKey?: string | null; containerKey?: string | null }, + options?: { + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + containerKey?: string | null; + }, ): void { if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return; 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 07d701670b..a2bc1bd3b0 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3760,6 +3760,59 @@ describe('renderTableCell', () => { expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); }); + it('should not apply SDT container styling when id-less block SDT matches ancestor table SDT metadata', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent' as const, + scope: 'block' as const, + alias: 'Table Container', + }; + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-same-idless-sdt', + runs: [{ text: 'Content in id-less table SDT', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + sdt: sharedSdt, + }, + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 28, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [measure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-table-idless-sdt', + blocks: [para], + attrs: {}, + }, + ancestorTableSdt: sharedSdt, + }); + + expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); + }); + it('should keep overflow:hidden for inline scope structuredContent (not a block container)', () => { const para: 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 12289d2e2f..5c455ae4d9 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -518,6 +518,8 @@ type TableCellRenderDependencies = { applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** Table-level SDT container key for suppressing duplicate container styling in cells */ ancestorTableSdtKey?: string | null; + /** Table-level SDT metadata for suppressing duplicate container styling in cells */ + ancestorTableSdt?: SdtMetadata | null; /** Table indent in pixels (applied to table fragment positioning) */ tableIndent?: number; /** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */ @@ -609,6 +611,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen context, applySdtDataset, ancestorTableSdtKey, + ancestorTableSdt, tableIndent, isRtl, cellWidth, @@ -661,6 +664,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen ): boolean => { return shouldRenderSdtContainerChrome(sdt, containerSdt, { ancestorContainerKey: ancestorTableSdtKey, + ancestorContainerSdt: ancestorTableSdt, containerKey: blockKey, }); }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 2ced281a13..8e5fbfd3a2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -386,6 +386,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderDrawingContent, applySdtDataset, ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), + ancestorTableSdt: block.attrs?.sdt ?? null, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -548,6 +549,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderDrawingContent, applySdtDataset, ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), + ancestorTableSdt: block.attrs?.sdt ?? null, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index a6cdb18f26..3b33dcf023 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -175,6 +175,8 @@ type TableRowRenderDependencies = { applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** Table-level SDT container key for suppressing duplicate container styling in cells */ ancestorTableSdtKey?: string | null; + /** Table-level SDT metadata for suppressing duplicate container styling in cells */ + ancestorTableSdt?: SdtMetadata | null; /** * If true, this row is the first body row of a continuation fragment. * MS Word draws borders at split points to visually close the table on each page, @@ -255,6 +257,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderDrawingContent, applySdtDataset, ancestorTableSdtKey, + ancestorTableSdt, continuesFromPrev, continuesOnNext, partialRow, @@ -427,6 +430,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { context, applySdtDataset, ancestorTableSdtKey, + ancestorTableSdt, fromLine, toLine, tableIndent, From c1d92bdd34458e1398f9deeeda33dcef60513964 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:02:25 -0300 Subject: [PATCH 04/37] fix(painters/dom): inherit table container SDT chrome --- .../dom/src/table/renderTableFragment.test.ts | 94 +++++++++++++++++++ .../dom/src/table/renderTableFragment.ts | 12 ++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index 9e3ac55131..fd33ba444d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -12,6 +12,7 @@ import type { TableColumnBoundary, TableRowBoundary, ParagraphBlock, + SdtMetadata, } from '@superdoc/contracts'; import type { FragmentRenderContext } from '../renderer.js'; @@ -169,6 +170,99 @@ describe('renderTableFragment', () => { expect(element.style.borderRightWidth).toBe('3px'); }); + it('suppresses child chrome when table containerSdt shares id-less metadata', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Shared Container', + }; + const block: TableBlock = { + kind: 'table', + id: 'container-sdt-table' as BlockId, + attrs: { + containerSdt: sharedSdt, + }, + rows: [ + { + id: 'container-sdt-row' as BlockId, + cells: [ + { + id: 'container-sdt-cell' as BlockId, + blocks: [ + { + kind: 'paragraph', + id: 'container-sdt-para' as BlockId, + runs: [{ text: 'Shared', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + containerSdt: sharedSdt, + }, + }, + ], + }, + ], + }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + width: 100, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + ], + height: 20, + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 20, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: (el, styles) => Object.assign(el.style, styles), + }); + + const chromeElements = [ + ...(element.classList.contains('superdoc-structured-content-block') ? [element] : []), + ...Array.from(element.querySelectorAll('.superdoc-structured-content-block')), + ]; + expect(chromeElements).toHaveLength(1); + }); + describe('merged-cell border ownership', () => { it('renders the outer right border for a merged header cell in collapsed mode', () => { const block: TableBlock = { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 8e5fbfd3a2..cfbb917796 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -13,7 +13,12 @@ import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; import type { FragmentRenderContext } from '../renderer.js'; import { renderTableRow } from './renderTableRow.js'; -import { applySdtContainerChrome, getSdtContainerKey, type SdtBoundaryOptions } from '../sdt/container.js'; +import { + applySdtContainerChrome, + getSdtContainerKey, + getSdtContainerMetadata, + type SdtBoundaryOptions, +} from '../sdt/container.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; import { getTableCellGridBounds } from './grid-geometry.js'; @@ -203,6 +208,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // Apply SDT container styling (document sections, structured content blocks) applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); // Add table-specific class for resize overlay targeting and click mapping container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); @@ -386,7 +392,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderDrawingContent, applySdtDataset, ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), - ancestorTableSdt: block.attrs?.sdt ?? null, + ancestorTableSdt: tableContainerSdt, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -549,7 +555,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderDrawingContent, applySdtDataset, ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), - ancestorTableSdt: block.attrs?.sdt ?? null, + ancestorTableSdt: tableContainerSdt, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) From 78f8f040f6b775ab4b051cd95b1a0723e11708e4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:08:08 -0300 Subject: [PATCH 05/37] fix(painters/dom): keep nested SDT chrome active --- .../painters/dom/src/sdt/container.test.ts | 19 +++++++++++++++++++ .../painters/dom/src/sdt/container.ts | 6 +++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts index 371cea22ef..b194cb816a 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -98,6 +98,25 @@ describe('SDT container chrome', () => { expect(el.classList.contains('superdoc-structured-content-block')).toBe(false); }); + it('does not suppress distinct primary chrome when fallback container metadata matches ancestor', () => { + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Ancestor', + }; + const childSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Child', + }; + + expect( + shouldRenderSdtContainerChrome(childSdt, ancestorSdt, { + ancestorContainerSdt: ancestorSdt, + }), + ).toBe(true); + }); + it('computes stable sibling start and end boundaries', () => { expect(getSdtSiblingBoundaries(['a', 'a', 'b', null, 'b'])).toEqual([ { isStart: true, isEnd: false }, diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 5092b629d6..b0237dd838 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -108,8 +108,8 @@ export function shouldRenderSdtContainerChrome( containerKey?: string | null; }, ): boolean { - const config = getSdtContainerConfig(sdt) ?? getSdtContainerConfig(containerSdt); - if (!config) return false; + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; const containerKey = options?.containerKey ?? getSdtContainerKey(sdt, containerSdt); if (containerKey && options?.ancestorContainerKey && containerKey === options.ancestorContainerKey) { @@ -117,7 +117,7 @@ export function shouldRenderSdtContainerChrome( } const ancestorContainerSdt = options?.ancestorContainerSdt; - if (ancestorContainerSdt && (sdt === ancestorContainerSdt || containerSdt === ancestorContainerSdt)) { + if (ancestorContainerSdt && metadata === ancestorContainerSdt) { return false; } From b1ace961da2e6a0048abffe9f8b2550cda4bbca5 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:18:14 -0300 Subject: [PATCH 06/37] fix(painters/dom): suppress nested table SDT chrome --- .../dom/src/table/renderTableCell.test.ts | 94 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 16 ++++ .../dom/src/table/renderTableFragment.ts | 11 ++- 3 files changed, 120 insertions(+), 1 deletion(-) 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 a2bc1bd3b0..fd85ae420a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3967,6 +3967,100 @@ describe('renderTableCell', () => { expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); }); + it('should not apply nested table chrome when its SDT key matches the ancestor table SDT key', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'ancestor-table-sdt', + alias: 'Ancestor Table', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-ancestor-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-ancestor-sdt-table', + attrs: { sdt: sharedSdt }, + rows: [ + { + id: 'nested-ancestor-row', + cells: [ + { + id: 'nested-ancestor-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-ancestor-sdt-table', + blocks: [nestedTable], + attrs: {}, + }, + ancestorTableSdtKey: 'structuredContent:ancestor-table-sdt', + ancestorTableSdt: sharedSdt, + }); + + const tableElement = cellElement.querySelector('[data-block-id="nested-ancestor-sdt-table"]') as HTMLElement; + expect(cellElement.style.overflow).toBe('hidden'); + expect(tableElement?.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(tableElement?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + it('should continue SDT boundaries across adjacent paragraph and nested table blocks', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 5c455ae4d9..c137126382 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -229,6 +229,10 @@ type EmbeddedTableRenderParams = { partialRow?: PartialRowInfo; /** Optional SDT boundary overrides for container styling */ sdtBoundary?: SdtBoundaryOptions; + /** Ancestor SDT key used to suppress duplicate container chrome in nested tables */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdt?: SdtMetadata | null; }; /** @@ -274,6 +278,8 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => toRow: paramToRow, partialRow: paramPartialRow, sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, } = params; const effectiveFromRow = paramFromRow ?? 0; @@ -324,6 +330,8 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => applySdtDataset, applyStyles: applyInlineStyles, sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, }); }; @@ -348,6 +356,8 @@ function renderPartialEmbeddedTable(params: { renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; sdtBoundary?: SdtBoundaryOptions; + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; }): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number } { const { doc, @@ -363,6 +373,8 @@ function renderPartialEmbeddedTable(params: { renderDrawingContent, applySdtDataset, sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, } = params; // Compute per-row segment counts (recursive, matching getCellLines/getEmbeddedRowLines). @@ -460,6 +472,8 @@ function renderPartialEmbeddedTable(params: { toRow: embeddedToRow, partialRow: partialRowInfo, sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, }); tableWrapper.appendChild(tableEl); @@ -761,6 +775,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, applySdtDataset, sdtBoundary: sdtBoundaries[i], + ancestorContainerKey: ancestorTableSdtKey, + ancestorContainerSdt: ancestorTableSdt, }); cumulativeLineCount = result.nextCumulativeLineCount; if (result.element) { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index cfbb917796..78b8cd5494 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -46,6 +46,10 @@ export type TableRenderDependencies = { effectiveColumnWidths: number[]; /** Optional SDT boundary overrides for container styling */ sdtBoundary?: SdtBoundaryOptions; + /** Ancestor SDT key used to suppress duplicate container chrome in nested tables */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdt?: SdtMetadata | null; /** Function to render a line of paragraph content */ renderLine: ( block: ParagraphBlock, @@ -148,6 +152,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement effectiveColumnWidths, context, sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, renderLine, captureLineSnapshot, renderDrawingContent, @@ -207,7 +213,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const contentTop = tableBorderWidths?.top ?? 0; // Apply SDT container styling (document sections, structured content blocks) - applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { + ancestorContainerKey, + ancestorContainerSdt, + }); const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); // Add table-specific class for resize overlay targeting and click mapping From ad6d6cc03c4caaa394d254cbe43c06a6e5dc46c3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:25:48 -0300 Subject: [PATCH 07/37] fix(painters/dom): continue partial nested SDT chrome --- .../dom/src/table/renderTableCell.test.ts | 138 ++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 10 +- 2 files changed, 147 insertions(+), 1 deletion(-) 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 fd85ae420a..21c201f9b9 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4179,6 +4179,144 @@ describe('renderTableCell', () => { expect(tableChrome?.dataset.sdtContainerEnd).toBe('true'); expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); }); + + it('should continue SDT boundaries across partial nested table renders', () => { + const nestedTableSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'partial-nested-table-sdt', + alias: 'Partial Nested Table', + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'partial-nested-table', + attrs: { sdt: nestedTableSdt }, + rows: [ + { + id: 'partial-nested-row-1', + cells: [ + { + id: 'partial-nested-cell-1', + blocks: [ + { + kind: 'paragraph', + id: 'partial-nested-para-1', + runs: [{ text: 'One', fontFamily: 'Arial', fontSize: 16 }], + }, + ], + }, + ], + }, + { + id: 'partial-nested-row-2', + cells: [ + { + id: 'partial-nested-cell-2', + blocks: [ + { + kind: 'paragraph', + id: 'partial-nested-para-2', + runs: [{ text: 'Two', fontFamily: 'Arial', fontSize: 16 }], + }, + ], + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 3, + width: 30, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 3, + width: 30, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 40, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-partial-nested-table-sdt', + blocks: [nestedTable], + attrs: {}, + }, + fromLine: 1, + toLine: 2, + }); + + const tableChrome = cellElement.querySelector('[data-block-id="partial-nested-table"]') as HTMLElement; + expect(tableChrome?.dataset.sdtContainerStart).toBe('false'); + expect(tableChrome?.dataset.sdtContainerEnd).toBe('true'); + expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c137126382..416d7ecec7 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -450,6 +450,14 @@ function renderPartialEmbeddedTable(params: { } const visibleHeight = computeVisibleHeight(tableMeasure.rows, embeddedFromRow, embeddedToRow, partialRowInfo); + const effectiveSdtBoundary = sdtBoundary + ? { + ...sdtBoundary, + isStart: (sdtBoundary.isStart ?? true) && localFrom === 0, + isEnd: (sdtBoundary.isEnd ?? true) && localTo >= totalTableSegments, + showLabel: sdtBoundary.showLabel === undefined ? undefined : sdtBoundary.showLabel && localFrom === 0, + } + : undefined; const tableWrapper = doc.createElement('div'); tableWrapper.style.position = 'relative'; @@ -471,7 +479,7 @@ function renderPartialEmbeddedTable(params: { fromRow: embeddedFromRow, toRow: embeddedToRow, partialRow: partialRowInfo, - sdtBoundary, + sdtBoundary: effectiveSdtBoundary, ancestorContainerKey, ancestorContainerSdt, }); From 2bd9f2a54c79f652ea2a7d37a07e968c63de0a29 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:31:40 -0300 Subject: [PATCH 08/37] fix(painters/dom): preserve nested table SDT ancestor --- .../dom/src/table/renderTableCell.test.ts | 91 +++++++++++++++++++ .../dom/src/table/renderTableFragment.ts | 11 ++- 2 files changed, 98 insertions(+), 4 deletions(-) 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 21c201f9b9..eb7926b231 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4061,6 +4061,97 @@ describe('renderTableCell', () => { expect(tableElement?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); }); + it('should preserve ancestor SDT suppression for paragraphs inside nested tables without table SDT', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-table-sdt', + alias: 'Outer Table', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-inherited-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: sharedSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-table-with-inherited-sdt-child', + rows: [ + { + id: 'nested-inherited-row', + cells: [ + { + id: 'nested-inherited-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-table-inherited-sdt-child', + blocks: [nestedTable], + attrs: {}, + }, + ancestorTableSdtKey: 'structuredContent:outer-table-sdt', + ancestorTableSdt: sharedSdt, + }); + + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + it('should continue SDT boundaries across adjacent paragraph and nested table blocks', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 78b8cd5494..b545fb42c0 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -218,6 +218,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement ancestorContainerSdt, }); const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); + const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + const rowAncestorContainerKey = tableContainerSdt ? tableContainerKey : ancestorContainerKey; + const rowAncestorContainerSdt = tableContainerSdt ?? ancestorContainerSdt; // Add table-specific class for resize overlay targeting and click mapping container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); @@ -400,8 +403,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), - ancestorTableSdt: tableContainerSdt, + ancestorTableSdtKey: rowAncestorContainerKey, + ancestorTableSdt: rowAncestorContainerSdt, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -563,8 +566,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorTableSdtKey: getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt), - ancestorTableSdt: tableContainerSdt, + ancestorTableSdtKey: rowAncestorContainerKey, + ancestorTableSdt: rowAncestorContainerSdt, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) From ae03e40d3eb8beeebe243cfe455c71f210876743 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:37:52 -0300 Subject: [PATCH 09/37] fix(painters/dom): allow nested SDT label overflow --- .../dom/src/table/renderTableCell.test.ts | 98 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 74 ++++++++++++-- 2 files changed, 165 insertions(+), 7 deletions(-) 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 eb7926b231..2a25d14f06 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4152,6 +4152,104 @@ describe('renderTableCell', () => { expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); }); + it('should allow overflow when a suppressed nested table contains distinct descendant SDT chrome', () => { + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-table-sdt', + alias: 'Outer Table', + }; + const descendantSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'descendant-sdt', + alias: 'Descendant', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-distinct-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: descendantSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-suppressed-table-with-distinct-child', + attrs: { sdt: ancestorSdt }, + rows: [ + { + id: 'nested-distinct-row', + cells: [ + { + id: 'nested-distinct-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-distinct-sdt-child', + blocks: [nestedTable], + attrs: {}, + }, + ancestorTableSdtKey: 'structuredContent:outer-table-sdt', + ancestorTableSdt: ancestorSdt, + }); + + expect(cellElement.style.overflow).toBe('visible'); + expect(cellElement.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Descendant'); + }); + it('should continue SDT boundaries across adjacent paragraph and nested table blocks', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 416d7ecec7..bb8429e6fe 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -22,6 +22,7 @@ import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { applyImageClipPath } from '../utils/image-clip-path.js'; import { + getSdtContainerMetadata, getSdtContainerKeyForBlock, getSdtSiblingBoundaries, shouldRenderSdtContainerChrome, @@ -683,20 +684,79 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null, blockKey?: string | null, + ancestorContainerKey?: string | null, + ancestorContainerSdt?: SdtMetadata | null, ): boolean => { + const effectiveAncestorKey = ancestorContainerKey === undefined ? ancestorTableSdtKey : ancestorContainerKey; + const effectiveAncestorSdt = ancestorContainerSdt === undefined ? ancestorTableSdt : ancestorContainerSdt; return shouldRenderSdtContainerChrome(sdt, containerSdt, { - ancestorContainerKey: ancestorTableSdtKey, - ancestorContainerSdt: ancestorTableSdt, + ancestorContainerKey: effectiveAncestorKey, + ancestorContainerSdt: effectiveAncestorSdt, containerKey: blockKey, }); }; - // Check if any block in the cell has SDT container styling - const hasSdtContainer = cellBlocks.some((block, index) => { + const hasRenderedSdtContainer = ( + block: (typeof cellBlocks)[number] | undefined, + measure: (typeof blockMeasures)[number] | undefined, + ancestorContainerKey?: string | null, + ancestorContainerSdt?: SdtMetadata | null, + ): boolean => { + if (!block) return false; + const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - const blockKey = sdtContainerKeys[index] ?? null; - return shouldApplySdtContainerStyling(attrs?.sdt, attrs?.containerSdt, blockKey); - }); + const blockKey = getSdtContainerKeyForBlock(block); + if ( + shouldApplySdtContainerStyling( + attrs?.sdt, + attrs?.containerSdt, + blockKey, + ancestorContainerKey, + ancestorContainerSdt, + ) + ) { + return true; + } + + if (block.kind !== 'table' || measure?.kind !== 'table') { + return false; + } + + const tableContainerSdt = getSdtContainerMetadata(attrs?.sdt, attrs?.containerSdt); + const nextAncestorKey = tableContainerSdt ? blockKey : ancestorContainerKey; + const nextAncestorSdt = tableContainerSdt ?? ancestorContainerSdt; + + for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex += 1) { + const row = block.rows[rowIndex]; + const rowMeasure = measure.rows[rowIndex]; + if (!rowMeasure) continue; + + for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) { + const nestedCell = row.cells[cellIndex]; + const nestedMeasure = rowMeasure.cells[cellIndex]; + const nestedBlocks = nestedCell.blocks ?? (nestedCell.paragraph ? [nestedCell.paragraph] : []); + const nestedMeasures = nestedMeasure?.blocks ?? (nestedMeasure?.paragraph ? [nestedMeasure.paragraph] : []); + for (let blockIndex = 0; blockIndex < nestedBlocks.length; blockIndex += 1) { + if ( + hasRenderedSdtContainer( + nestedBlocks[blockIndex], + nestedMeasures[blockIndex], + nextAncestorKey, + nextAncestorSdt, + ) + ) { + return true; + } + } + } + } + + return false; + }; + + const hasSdtContainer = cellBlocks.some((block, index) => + hasRenderedSdtContainer(block, blockMeasures[index], ancestorTableSdtKey, ancestorTableSdt), + ); // SDT containers display labels that extend above the content boundary. // Change overflow to 'visible' so these labels aren't clipped by the cell. From 685ceb47ef00926dd145ab915097cd5de76eaa05 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:45:05 -0300 Subject: [PATCH 10/37] fix(painters/dom): scope nested SDT overflow to rendered content --- .../dom/src/table/renderTableCell.test.ts | 107 ++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 80 +++---------- 2 files changed, 120 insertions(+), 67 deletions(-) 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 2a25d14f06..32d2fa9866 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4250,6 +4250,113 @@ describe('renderTableCell', () => { expect(cellElement.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Descendant'); }); + it('should keep overflow hidden when descendant SDT chrome is outside the rendered nested table range', () => { + const descendantSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'unrendered-descendant-sdt', + alias: 'Unrendered', + }; + const firstParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'visible-nested-para', + runs: [{ text: 'Visible', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const secondParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'unrendered-nested-sdt-para', + runs: [{ text: 'Hidden', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: descendantSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'partial-nested-table-with-unrendered-sdt', + rows: [ + { + id: 'visible-nested-row', + cells: [{ id: 'visible-nested-cell', blocks: [firstParagraph], attrs: {} }], + }, + { + id: 'unrendered-nested-row', + cells: [{ id: 'unrendered-nested-cell', blocks: [secondParagraph], attrs: {} }], + }, + ], + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 7, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [paragraphMeasure], + }, + ], + }, + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [paragraphMeasure], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 40, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-partial-nested-unrendered-sdt', + blocks: [nestedTable], + attrs: {}, + }, + fromLine: 0, + toLine: 1, + }); + + expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + it('should continue SDT boundaries across adjacent paragraph and nested table blocks', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index bb8429e6fe..a4b4de42fb 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -22,7 +22,6 @@ import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { applyImageClipPath } from '../utils/image-clip-path.js'; import { - getSdtContainerMetadata, getSdtContainerKeyForBlock, getSdtSiblingBoundaries, shouldRenderSdtContainerChrome, @@ -684,79 +683,19 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null, blockKey?: string | null, - ancestorContainerKey?: string | null, - ancestorContainerSdt?: SdtMetadata | null, ): boolean => { - const effectiveAncestorKey = ancestorContainerKey === undefined ? ancestorTableSdtKey : ancestorContainerKey; - const effectiveAncestorSdt = ancestorContainerSdt === undefined ? ancestorTableSdt : ancestorContainerSdt; return shouldRenderSdtContainerChrome(sdt, containerSdt, { - ancestorContainerKey: effectiveAncestorKey, - ancestorContainerSdt: effectiveAncestorSdt, + ancestorContainerKey: ancestorTableSdtKey, + ancestorContainerSdt: ancestorTableSdt, containerKey: blockKey, }); }; - const hasRenderedSdtContainer = ( - block: (typeof cellBlocks)[number] | undefined, - measure: (typeof blockMeasures)[number] | undefined, - ancestorContainerKey?: string | null, - ancestorContainerSdt?: SdtMetadata | null, - ): boolean => { - if (!block) return false; - + const hasSdtContainer = cellBlocks.some((block, index) => { const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - const blockKey = getSdtContainerKeyForBlock(block); - if ( - shouldApplySdtContainerStyling( - attrs?.sdt, - attrs?.containerSdt, - blockKey, - ancestorContainerKey, - ancestorContainerSdt, - ) - ) { - return true; - } - - if (block.kind !== 'table' || measure?.kind !== 'table') { - return false; - } - - const tableContainerSdt = getSdtContainerMetadata(attrs?.sdt, attrs?.containerSdt); - const nextAncestorKey = tableContainerSdt ? blockKey : ancestorContainerKey; - const nextAncestorSdt = tableContainerSdt ?? ancestorContainerSdt; - - for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex += 1) { - const row = block.rows[rowIndex]; - const rowMeasure = measure.rows[rowIndex]; - if (!rowMeasure) continue; - - for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) { - const nestedCell = row.cells[cellIndex]; - const nestedMeasure = rowMeasure.cells[cellIndex]; - const nestedBlocks = nestedCell.blocks ?? (nestedCell.paragraph ? [nestedCell.paragraph] : []); - const nestedMeasures = nestedMeasure?.blocks ?? (nestedMeasure?.paragraph ? [nestedMeasure.paragraph] : []); - for (let blockIndex = 0; blockIndex < nestedBlocks.length; blockIndex += 1) { - if ( - hasRenderedSdtContainer( - nestedBlocks[blockIndex], - nestedMeasures[blockIndex], - nextAncestorKey, - nextAncestorSdt, - ) - ) { - return true; - } - } - } - } - - return false; - }; - - const hasSdtContainer = cellBlocks.some((block, index) => - hasRenderedSdtContainer(block, blockMeasures[index], ancestorTableSdtKey, ancestorTableSdt), - ); + const blockKey = sdtContainerKeys[index] ?? null; + return shouldApplySdtContainerStyling(attrs?.sdt, attrs?.containerSdt, blockKey); + }); // SDT containers display labels that extend above the content boundary. // Change overflow to 'visible' so these labels aren't clipped by the cell. @@ -1202,6 +1141,13 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot(candidateLine, { ...context, section: 'body' }, { inTableParagraph: false, wrapperEl }); } } + + if ( + cellEl.style.overflow !== 'visible' && + content.querySelector('.superdoc-document-section, .superdoc-structured-content-block') + ) { + cellEl.style.overflow = 'visible'; + } } return { cellElement: cellEl }; From e44f2ecf138c196728e37cb19107d7220e560681 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:45:42 -0300 Subject: [PATCH 11/37] fix(painters/dom): use rendered SDT lock mode --- .../painters/dom/src/sdt/container.test.ts | 27 +++++++++++++++++++ .../painters/dom/src/sdt/container.ts | 12 +++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts index b194cb816a..7df4793b93 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -70,6 +70,33 @@ describe('SDT container chrome', () => { expect(el.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Container'); }); + it('uses the rendered container metadata for lock mode', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome( + doc, + el, + { + type: 'structuredContent', + scope: 'inline', + id: 'inline-sdt', + alias: 'Inline', + lockMode: 'contentLocked', + }, + { + type: 'structuredContent', + scope: 'block', + id: 'container-sdt', + alias: 'Container', + lockMode: 'sdtLocked', + }, + ); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(el.dataset.lockMode).toBe('sdtLocked'); + }); + it('suppresses same-key ancestor chrome', () => { const childSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index b0237dd838..b6255bab50 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -149,10 +149,8 @@ export function applySdtContainerChrome( ): void { if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return; - let config = getSdtContainerConfig(sdt); - if (!config && containerSdt) { - config = getSdtContainerConfig(containerSdt); - } + const metadata = getSdtContainerMetadata(sdt, containerSdt); + const config = getSdtContainerConfig(metadata); if (!config) return; const isStart = boundaryOptions?.isStart ?? config.isStart; @@ -163,10 +161,8 @@ export function applySdtContainerChrome( container.dataset.sdtContainerEnd = String(isEnd); container.style.overflow = 'visible'; - if (isStructuredContentMetadata(sdt)) { - container.dataset.lockMode = sdt.lockMode || 'unlocked'; - } else if (isStructuredContentMetadata(containerSdt)) { - container.dataset.lockMode = containerSdt.lockMode || 'unlocked'; + if (isStructuredContentMetadata(metadata)) { + container.dataset.lockMode = metadata.lockMode || 'unlocked'; } if (boundaryOptions?.widthOverride != null) { From 944f988ff559711e979d46aa73f343a40083bf04 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:46:23 -0300 Subject: [PATCH 12/37] fix(painters/dom): preserve ancestor SDT key --- .../dom/src/table/renderTableFragment.test.ts | 101 ++++++++++++++++++ .../dom/src/table/renderTableFragment.ts | 4 +- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index fd33ba444d..b2cbdad377 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -263,6 +263,107 @@ describe('renderTableFragment', () => { expect(chromeElements).toHaveLength(1); }); + it('preserves keyed ancestor suppression through an id-less table SDT', () => { + const idlessTableSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Idless Table', + }; + const outerSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-sdt', + alias: 'Outer', + }; + const block: TableBlock = { + kind: 'table', + id: 'idless-nested-table' as BlockId, + attrs: { + sdt: idlessTableSdt, + }, + rows: [ + { + id: 'idless-nested-row' as BlockId, + cells: [ + { + id: 'idless-nested-cell' as BlockId, + blocks: [ + { + kind: 'paragraph', + id: 'idless-nested-para' as BlockId, + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + containerSdt: outerSdt, + }, + }, + ], + }, + ], + }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + width: 100, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + ], + height: 20, + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 20, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + ancestorContainerKey: 'structuredContent:outer-sdt', + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: (el, styles) => Object.assign(el.style, styles), + }); + + const chromeElements = [ + ...(element.classList.contains('superdoc-structured-content-block') ? [element] : []), + ...Array.from(element.querySelectorAll('.superdoc-structured-content-block')), + ]; + expect(chromeElements).toHaveLength(1); + expect(chromeElements[0].querySelector('.superdoc-structured-content__label')?.textContent).toBe('Idless Table'); + }); + describe('merged-cell border ownership', () => { it('renders the outer right border for a merged header cell in collapsed mode', () => { const block: TableBlock = { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index b545fb42c0..e1a0fa014c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -219,7 +219,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement }); const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); - const rowAncestorContainerKey = tableContainerSdt ? tableContainerKey : ancestorContainerKey; + const rowAncestorContainerKey = tableContainerSdt + ? (tableContainerKey ?? ancestorContainerKey) + : ancestorContainerKey; const rowAncestorContainerSdt = tableContainerSdt ?? ancestorContainerSdt; // Add table-specific class for resize overlay targeting and click mapping From 28c5950ae2c10991ff9a41ee2ba449e92e43850e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:47:57 -0300 Subject: [PATCH 13/37] fix(painters/dom): merge idless SDT siblings --- .../painters/dom/src/sdt/container.test.ts | 28 +++++++++++++ .../painters/dom/src/sdt/container.ts | 40 ++++++++++++++++--- .../dom/src/table/renderTableFragment.ts | 5 ++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts index 7df4793b93..6d25c8ada4 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -3,6 +3,7 @@ import type { SdtMetadata } from '@superdoc/contracts'; import { applySdtContainerChrome, getSdtContainerKey, + getSdtContainerKeyForBlock, getSdtSiblingBoundaries, shouldRenderSdtContainerChrome, } from './container.js'; @@ -153,4 +154,31 @@ describe('SDT container chrome', () => { { isStart: true, isEnd: true }, ]); }); + + it('computes merged boundaries for shared id-less sibling metadata', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Shared', + }; + + expect(getSdtSiblingBoundaries([getSdtContainerKey(sharedSdt), getSdtContainerKey(sharedSdt)])).toEqual([ + { isStart: true, isEnd: false }, + { isStart: false, isEnd: true }, + ]); + }); + + it('gets container keys for image and drawing blocks', () => { + const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'media-sdt', + alias: 'Media', + }; + + expect(getSdtContainerKeyForBlock({ kind: 'image', attrs: { sdt } })).toBe('structuredContent:media-sdt'); + expect(getSdtContainerKeyForBlock({ kind: 'drawing', attrs: { containerSdt: sdt } })).toBe( + 'structuredContent:media-sdt', + ); + }); }); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index b6255bab50..6b5db77ffa 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -23,6 +23,21 @@ export type SdtBoundaryOptions = { showLabel?: boolean; }; +const idlessSdtContainerKeys = new WeakMap(); +let nextIdlessSdtContainerKey = 0; + +function getIdlessSdtContainerKey(metadata: SdtMetadata): string { + const existingKey = idlessSdtContainerKeys.get(metadata); + if (existingKey) return existingKey; + + // AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same + // SdtMetadata object across sibling blocks in one container. Do not replace + // this with alias/title matching; separate controls can share display text. + const key = `idlessSdt:${++nextIdlessSdtContainerKey}`; + idlessSdtContainerKeys.set(metadata, key); + return key; +} + export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { type: 'structuredContent'; scope: 'inline' | 'block'; @@ -81,21 +96,36 @@ export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtM if (metadata.type === 'structuredContent') { if (metadata.scope !== 'block') return null; - if (!metadata.id) return null; - return `structuredContent:${metadata.id}`; + if (metadata.id) return `structuredContent:${metadata.id}`; + return getIdlessSdtContainerKey(metadata); } if (metadata.type === 'documentSection') { const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) return null; - return `documentSection:${sectionId}`; + if (sectionId) return `documentSection:${sectionId}`; + return getIdlessSdtContainerKey(metadata); } return null; } +export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; + + if (metadata.type === 'structuredContent') { + return metadata.scope === 'block' && Boolean(metadata.id); + } + + if (metadata.type === 'documentSection') { + return Boolean(metadata.id ?? metadata.sdBlockId); + } + + return false; +} + export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { - if (!block || (block.kind !== 'paragraph' && block.kind !== 'table')) return null; + if (!block) return null; return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); } diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index e1a0fa014c..7653f8b8ff 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -17,6 +17,7 @@ import { applySdtContainerChrome, getSdtContainerKey, getSdtContainerMetadata, + hasExplicitSdtContainerKey, type SdtBoundaryOptions, } from '../sdt/container.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; @@ -220,7 +221,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); const rowAncestorContainerKey = tableContainerSdt - ? (tableContainerKey ?? ancestorContainerKey) + ? hasExplicitSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt) + ? tableContainerKey + : ancestorContainerKey : ancestorContainerKey; const rowAncestorContainerSdt = tableContainerSdt ?? ancestorContainerSdt; From 308a3ed0a6f0b5cda5852718250221891bcbaa8c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:56:50 -0300 Subject: [PATCH 14/37] test(painters/dom): cover SDT chrome gaps --- .../painters/dom/src/sdt/container.test.ts | 16 ++++ .../dom/src/table/renderTableCell.test.ts | 89 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts index 6d25c8ada4..08d57980d8 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -65,10 +65,12 @@ describe('SDT container chrome', () => { scope: 'block', id: 'container-sdt', alias: 'Container', + lockMode: 'contentLocked', }); expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); expect(el.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Container'); + expect(el.dataset.lockMode).toBe('contentLocked'); }); it('uses the rendered container metadata for lock mode', () => { @@ -145,6 +147,20 @@ describe('SDT container chrome', () => { ).toBe(true); }); + it('suppresses pure id-less container metadata by reference', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Shared', + }; + + expect( + shouldRenderSdtContainerChrome(null, sharedSdt, { + ancestorContainerSdt: sharedSdt, + }), + ).toBe(false); + }); + it('computes stable sibling start and end boundaries', () => { expect(getSdtSiblingBoundaries(['a', 'a', 'b', null, 'b'])).toEqual([ { isStart: true, isEnd: false }, 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 32d2fa9866..07ecb9006c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3967,6 +3967,95 @@ describe('renderTableCell', () => { expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); }); + it('should set overflow:visible when only rendered nested descendants have SDT chrome', () => { + const descendantSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'nested-descendant-sdt', + alias: 'Nested Descendant', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-descendant-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: descendantSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-table-with-descendant-sdt', + rows: [ + { + id: 'nested-descendant-row', + cells: [ + { + id: 'nested-descendant-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-descendant-sdt', + blocks: [nestedTable], + attrs: {}, + }, + }); + + expect(cellElement.style.overflow).toBe('visible'); + expect(cellElement.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Descendant'); + }); + it('should not apply nested table chrome when its SDT key matches the ancestor table SDT key', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', From 8e1627a5fc1bdf98004ab635d913848974d17bb3 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:19:19 -0300 Subject: [PATCH 15/37] refactor(painters/dom): share SDT container keys --- packages/layout-engine/contracts/src/index.ts | 7 ++ .../contracts/src/sdt-container.test.ts | 42 ++++++++ .../contracts/src/sdt-container.ts | 78 +++++++++++++++ .../layout-resolved/src/resolveLayout.test.ts | 8 +- .../layout-resolved/src/resolveLayout.ts | 8 +- .../layout-resolved/src/sdtContainerKey.ts | 40 -------- .../src/paragraph/renderParagraphContent.ts | 19 +++- .../painters/dom/src/sdt/container.ts | 91 +++--------------- .../dom/src/table/renderTableCell.test.ts | 16 ++-- .../painters/dom/src/table/renderTableCell.ts | 95 +++++++++++-------- .../dom/src/table/renderTableFragment.ts | 29 ++++-- .../painters/dom/src/table/renderTableRow.ts | 20 ++-- 12 files changed, 257 insertions(+), 196 deletions(-) create mode 100644 packages/layout-engine/contracts/src/sdt-container.test.ts create mode 100644 packages/layout-engine/contracts/src/sdt-container.ts delete mode 100644 packages/layout-engine/layout-resolved/src/sdtContainerKey.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 165b379d95..755e0919ba 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -101,6 +101,13 @@ export type { import type { LayoutSourceIdentity } from './layout-identity.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; +export { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, + isSdtContainerMetadata, +} from './sdt-container.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; diff --git a/packages/layout-engine/contracts/src/sdt-container.test.ts b/packages/layout-engine/contracts/src/sdt-container.test.ts new file mode 100644 index 0000000000..d0f8986479 --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { SdtMetadata } from './index.js'; +import { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, +} from './sdt-container.js'; + +describe('SDT container key helpers', () => { + it('uses the first renderable container metadata', () => { + const containerSdt: SdtMetadata = { type: 'documentSection', id: 'section-1' }; + + expect(getSdtContainerMetadata({ type: 'structuredContent', scope: 'inline', id: 'inline-1' }, containerSdt)).toBe( + containerSdt, + ); + }); + + it('derives explicit keys for block content controls and document sections', () => { + expect(getSdtContainerKey({ type: 'structuredContent', scope: 'block', id: 'sdt-1' })).toBe( + 'structuredContent:sdt-1', + ); + expect(getSdtContainerKey({ type: 'documentSection', sdBlockId: 'section-block-1' })).toBe( + 'documentSection:section-block-1', + ); + }); + + it('derives stable object keys for id-less containers', () => { + const sharedSdt: SdtMetadata = { type: 'structuredContent', scope: 'block', alias: 'Shared' }; + const firstKey = getSdtContainerKey(sharedSdt); + + expect(firstKey).toMatch(/^idlessSdt:/); + expect(getSdtContainerKey(sharedSdt)).toBe(firstKey); + expect(hasExplicitSdtContainerKey(sharedSdt)).toBe(false); + }); + + it('derives keys from any block-like object with SDT attrs', () => { + const sdt: SdtMetadata = { type: 'structuredContent', scope: 'block', id: 'media-sdt' }; + + expect(getSdtContainerKeyForBlock({ attrs: { sdt } })).toBe('structuredContent:media-sdt'); + }); +}); diff --git a/packages/layout-engine/contracts/src/sdt-container.ts b/packages/layout-engine/contracts/src/sdt-container.ts new file mode 100644 index 0000000000..8f650c197a --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.ts @@ -0,0 +1,78 @@ +import type { SdtMetadata } from './index.js'; + +type SdtBlockCandidate = { + attrs?: { + sdt?: SdtMetadata | null; + containerSdt?: SdtMetadata | null; + } | null; +}; + +const idlessSdtContainerKeys = new WeakMap(); +let nextIdlessSdtContainerKey = 0; + +function getIdlessSdtContainerKey(metadata: SdtMetadata): string { + const existingKey = idlessSdtContainerKeys.get(metadata); + if (existingKey) return existingKey; + + // AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same + // SdtMetadata object across sibling blocks in one container. Do not replace + // this with alias/title matching; separate controls can share display text. + const key = `idlessSdt:${++nextIdlessSdtContainerKey}`; + idlessSdtContainerKeys.set(metadata, key); + return key; +} + +export function isSdtContainerMetadata(sdt: SdtMetadata | null | undefined): boolean { + if (!sdt) return false; + if (sdt.type === 'documentSection') return true; + if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; + return false; +} + +export function getSdtContainerMetadata( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, +): SdtMetadata | null { + if (isSdtContainerMetadata(sdt)) return sdt ?? null; + if (isSdtContainerMetadata(containerSdt)) return containerSdt ?? null; + return null; +} + +export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (metadata.id) return `structuredContent:${metadata.id}`; + return getIdlessSdtContainerKey(metadata); + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (sectionId) return `documentSection:${sectionId}`; + return getIdlessSdtContainerKey(metadata); + } + + return null; +} + +export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; + + if (metadata.type === 'structuredContent') { + return metadata.scope === 'block' && Boolean(metadata.id); + } + + if (metadata.type === 'documentSection') { + return Boolean(metadata.id ?? metadata.sdBlockId); + } + + return false; +} + +export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { + if (!block) return null; + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); +} diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index d817fd0d5f..0b5272d4e9 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2793,7 +2793,7 @@ describe('resolveLayout', () => { expect(drItem.sdtContainerKey).toBeUndefined(); }); - it('returns null (omits key) for structuredContent block scope with no id', () => { + it('sets an object-stable key for structuredContent block scope with no id', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2815,10 +2815,10 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); - it('returns null (omits key) for documentSection with no id or sdBlockId', () => { + it('sets an object-stable key for documentSection with no id or sdBlockId', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2840,7 +2840,7 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 900d38ae13..6e1a8dd4d5 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -22,12 +22,12 @@ import type { ParagraphMeasure, LayoutStoryLocator, } from '@superdoc/contracts'; +import { getSdtContainerKey } from '@superdoc/contracts'; import { resolveParagraphContent } from './resolveParagraph.js'; import { resolveTableItem } from './resolveTable.js'; import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; -import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; import { deriveBlockVersion, @@ -162,17 +162,17 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map listItem.id === fragment.itemId); - return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + return getSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); } if (fragment.kind === 'table' && block.kind === 'table') { - return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); } // image, drawing — no SDT container keys diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts deleted file mode 100644 index 4cee08673f..0000000000 --- a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SdtMetadata } from '@superdoc/contracts'; - -/** - * Returns a stable key for grouping consecutive fragments in the same SDT container. - * - * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts` - * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package. - * Only the key derivation is needed; DOM styling helpers are not. - */ -export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (!metadata.id) return null; - return `structuredContent:${metadata.id}`; - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) return null; - return `documentSection:${sectionId}`; - } - - return null; -} - -function isSdtContainer(sdt?: SdtMetadata | null): boolean { - if (!sdt) return false; - if (sdt.type === 'documentSection') return true; - if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; - return false; -} - -function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null { - if (isSdtContainer(sdt)) return sdt ?? null; - if (isSdtContainer(containerSdt)) return containerSdt ?? null; - return null; -} diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 491387c9fe..06f5ec615c 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -14,7 +14,7 @@ import { getParagraphInlineDirection, } from '@superdoc/contracts'; import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import { applySdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; +import { applySdtContainerChrome, shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; import { applyParagraphLineIndentation, @@ -78,7 +78,9 @@ export type RenderParagraphContentParams = { betweenInfo?: BetweenBorderInfo; sdtBoundary?: SdtBoundaryOptions; spacingPolicy?: ParagraphSpacingPolicy; - shouldApplySdtContainerStyling?: (sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null) => boolean; + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + onSdtContainerChrome?: () => void; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; renderLine: ParagraphRenderLine; @@ -115,7 +117,9 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re betweenInfo, sdtBoundary, spacingPolicy, - shouldApplySdtContainerStyling, + ancestorContainerKey, + ancestorContainerSdt, + onSdtContainerChrome, applySdtDataset, applyContainerSdtDataset, renderDropCap, @@ -135,9 +139,14 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re applySdtDataset(frameEl, block.attrs?.sdt); applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); - const applySdtChrome = shouldApplySdtContainerStyling?.(block.attrs?.sdt, block.attrs?.containerSdt) ?? true; + const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { + ancestorContainerKey, + ancestorContainerSdt, + }); if (applySdtChrome) { - applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) { + onSdtContainerChrome?.(); + } } renderParagraphDropCap({ diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 6b5db77ffa..1637565009 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -1,11 +1,11 @@ -import type { FlowBlock, SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; - -type SdtBlockCandidate = Pick & { - attrs?: { - sdt?: SdtMetadata | null; - containerSdt?: SdtMetadata | null; - } | null; -}; +import type { SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; +export { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, +} from '@superdoc/contracts'; +import { getSdtContainerKey, getSdtContainerMetadata } from '@superdoc/contracts'; export type SdtContainerConfig = { className: string; @@ -23,21 +23,6 @@ export type SdtBoundaryOptions = { showLabel?: boolean; }; -const idlessSdtContainerKeys = new WeakMap(); -let nextIdlessSdtContainerKey = 0; - -function getIdlessSdtContainerKey(metadata: SdtMetadata): string { - const existingKey = idlessSdtContainerKeys.get(metadata); - if (existingKey) return existingKey; - - // AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same - // SdtMetadata object across sibling blocks in one container. Do not replace - // this with alias/title matching; separate controls can share display text. - const key = `idlessSdt:${++nextIdlessSdtContainerKey}`; - idlessSdtContainerKeys.set(metadata, key); - return key; -} - export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { type: 'structuredContent'; scope: 'inline' | 'block'; @@ -81,67 +66,18 @@ export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtC return null; } -export function getSdtContainerMetadata( - sdt?: SdtMetadata | null, - containerSdt?: SdtMetadata | null, -): SdtMetadata | null { - if (getSdtContainerConfig(sdt)) return sdt ?? null; - if (getSdtContainerConfig(containerSdt)) return containerSdt ?? null; - return null; -} - -export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (metadata.id) return `structuredContent:${metadata.id}`; - return getIdlessSdtContainerKey(metadata); - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (sectionId) return `documentSection:${sectionId}`; - return getIdlessSdtContainerKey(metadata); - } - - return null; -} - -export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return false; - - if (metadata.type === 'structuredContent') { - return metadata.scope === 'block' && Boolean(metadata.id); - } - - if (metadata.type === 'documentSection') { - return Boolean(metadata.id ?? metadata.sdBlockId); - } - - return false; -} - -export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { - if (!block) return null; - return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); -} - export function shouldRenderSdtContainerChrome( sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null, options?: { ancestorContainerKey?: string | null; ancestorContainerSdt?: SdtMetadata | null; - containerKey?: string | null; }, ): boolean { const metadata = getSdtContainerMetadata(sdt, containerSdt); if (!metadata) return false; - const containerKey = options?.containerKey ?? getSdtContainerKey(sdt, containerSdt); + const containerKey = getSdtContainerKey(sdt, containerSdt); if (containerKey && options?.ancestorContainerKey && containerKey === options.ancestorContainerKey) { return false; } @@ -174,14 +110,13 @@ export function applySdtContainerChrome( options?: { ancestorContainerKey?: string | null; ancestorContainerSdt?: SdtMetadata | null; - containerKey?: string | null; }, -): void { - if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return; +): boolean { + if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return false; const metadata = getSdtContainerMetadata(sdt, containerSdt); const config = getSdtContainerConfig(metadata); - if (!config) return; + if (!config) return false; const isStart = boundaryOptions?.isStart ?? config.isStart; const isEnd = boundaryOptions?.isEnd ?? config.isEnd; @@ -213,6 +148,8 @@ export function applySdtContainerChrome( labelEl.appendChild(labelText); container.appendChild(labelEl); } + + return true; } export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { 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 07ecb9006c..80801d81e2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -3753,7 +3753,7 @@ describe('renderTableCell', () => { ...createBaseDeps(), cellMeasure, cell, - ancestorTableSdtKey: 'structuredContent:table-sdt', + ancestorContainerKey: 'structuredContent:table-sdt', }); expect(cellElement.style.overflow).toBe('hidden'); @@ -3806,7 +3806,7 @@ describe('renderTableCell', () => { blocks: [para], attrs: {}, }, - ancestorTableSdt: sharedSdt, + ancestorContainerSdt: sharedSdt, }); expect(cellElement.style.overflow).toBe('hidden'); @@ -4140,8 +4140,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorTableSdtKey: 'structuredContent:ancestor-table-sdt', - ancestorTableSdt: sharedSdt, + ancestorContainerKey: 'structuredContent:ancestor-table-sdt', + ancestorContainerSdt: sharedSdt, }); const tableElement = cellElement.querySelector('[data-block-id="nested-ancestor-sdt-table"]') as HTMLElement; @@ -4233,8 +4233,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorTableSdtKey: 'structuredContent:outer-table-sdt', - ancestorTableSdt: sharedSdt, + ancestorContainerKey: 'structuredContent:outer-table-sdt', + ancestorContainerSdt: sharedSdt, }); expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); @@ -4331,8 +4331,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorTableSdtKey: 'structuredContent:outer-table-sdt', - ancestorTableSdt: ancestorSdt, + ancestorContainerKey: 'structuredContent:outer-table-sdt', + ancestorContainerSdt: ancestorSdt, }); expect(cellElement.style.overflow).toBe('visible'); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index a4b4de42fb..d5a3b98aa8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -233,6 +233,8 @@ type EmbeddedTableRenderParams = { ancestorContainerKey?: string | null; /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ ancestorContainerSdt?: SdtMetadata | null; + /** Receives notification when this embedded table or its descendants render SDT chrome */ + onSdtContainerChrome?: () => void; }; /** @@ -263,7 +265,9 @@ type EmbeddedTableRenderParams = { * cellContent.appendChild(tableEl); * ``` */ -const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => { +const renderEmbeddedTable = ( + params: EmbeddedTableRenderParams, +): { element: HTMLElement; hasSdtContainerChrome: boolean } => { const { doc, table, @@ -280,6 +284,7 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + onSdtContainerChrome, } = params; const effectiveFromRow = paramFromRow ?? 0; @@ -315,7 +320,8 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => el.dataset.blockId = frag.blockId; }; - return renderTableFragmentElement({ + let hasSdtContainerChrome = false; + const tableEl = renderTableFragmentElement({ doc, fragment, context, @@ -332,7 +338,13 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + onSdtContainerChrome: () => { + hasSdtContainerChrome = true; + onSdtContainerChrome?.(); + }, }); + + return { element: tableEl, hasSdtContainerChrome }; }; /** @@ -358,7 +370,8 @@ function renderPartialEmbeddedTable(params: { sdtBoundary?: SdtBoundaryOptions; ancestorContainerKey?: string | null; ancestorContainerSdt?: SdtMetadata | null; -}): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number } { + onSdtContainerChrome?: () => void; +}): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number; hasSdtContainerChrome: boolean } { const { doc, block, @@ -375,6 +388,7 @@ function renderPartialEmbeddedTable(params: { sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + onSdtContainerChrome, } = params; // Compute per-row segment counts (recursive, matching getCellLines/getEmbeddedRowLines). @@ -387,7 +401,7 @@ function renderPartialEmbeddedTable(params: { // Skip entirely if no segments are in the visible range if (tableEndSegment <= globalFromLine || tableStartSegment >= globalToLine) { - return { element: null, height: 0, nextCumulativeLineCount }; + return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } // Map global line range to local segment range within this embedded table @@ -446,7 +460,7 @@ function renderPartialEmbeddedTable(params: { } if (embeddedFromRow === -1) { - return { element: null, height: 0, nextCumulativeLineCount }; + return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } const visibleHeight = computeVisibleHeight(tableMeasure.rows, embeddedFromRow, embeddedToRow, partialRowInfo); @@ -466,7 +480,7 @@ function renderPartialEmbeddedTable(params: { tableWrapper.style.flexShrink = '0'; tableWrapper.style.boxSizing = 'border-box'; - const tableEl = renderEmbeddedTable({ + const tableResult = renderEmbeddedTable({ doc, table: block, measure: tableMeasure, @@ -482,10 +496,16 @@ function renderPartialEmbeddedTable(params: { sdtBoundary: effectiveSdtBoundary, ancestorContainerKey, ancestorContainerSdt, + onSdtContainerChrome, }); - tableWrapper.appendChild(tableEl); + tableWrapper.appendChild(tableResult.element); - return { element: tableWrapper, height: visibleHeight, nextCumulativeLineCount }; + return { + element: tableWrapper, + height: visibleHeight, + nextCumulativeLineCount, + hasSdtContainerChrome: tableResult.hasSdtContainerChrome, + }; } /** @@ -538,10 +558,12 @@ type TableCellRenderDependencies = { context: FragmentRenderContext; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Table-level SDT container key for suppressing duplicate container styling in cells */ - ancestorTableSdtKey?: string | null; - /** Table-level SDT metadata for suppressing duplicate container styling in cells */ - ancestorTableSdt?: SdtMetadata | null; + /** Ancestor SDT container key for suppressing duplicate container styling in cells */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdt?: SdtMetadata | null; + /** Receives notification when this cell or descendants render SDT container chrome */ + onSdtContainerChrome?: () => void; /** Table indent in pixels (applied to table fragment positioning) */ tableIndent?: number; /** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */ @@ -632,8 +654,9 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, context, applySdtDataset, - ancestorTableSdtKey, - ancestorTableSdt, + ancestorContainerKey, + ancestorContainerSdt, + onSdtContainerChrome, tableIndent, isRtl, cellWidth, @@ -679,28 +702,20 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const blockMeasures = cellMeasure?.blocks ?? (cellMeasure?.paragraph ? [cellMeasure.paragraph] : []); const sdtContainerKeys = cellBlocks.map((block) => getSdtContainerKeyForBlock(block)); const sdtBoundaries = getSdtSiblingBoundaries(sdtContainerKeys); - const shouldApplySdtContainerStyling = ( - sdt?: SdtMetadata | null, - containerSdt?: SdtMetadata | null, - blockKey?: string | null, - ): boolean => { - return shouldRenderSdtContainerChrome(sdt, containerSdt, { - ancestorContainerKey: ancestorTableSdtKey, - ancestorContainerSdt: ancestorTableSdt, - containerKey: blockKey, - }); - }; - const hasSdtContainer = cellBlocks.some((block, index) => { + const hasSdtContainer = cellBlocks.some((block) => { const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - const blockKey = sdtContainerKeys[index] ?? null; - return shouldApplySdtContainerStyling(attrs?.sdt, attrs?.containerSdt, blockKey); + return shouldRenderSdtContainerChrome(attrs?.sdt, attrs?.containerSdt, { + ancestorContainerKey, + ancestorContainerSdt, + }); }); // SDT containers display labels that extend above the content boundary. // Change overflow to 'visible' so these labels aren't clipped by the cell. if (hasSdtContainer) { cellEl.style.overflow = 'visible'; + onSdtContainerChrome?.(); } if (cellBlocks.length > 0 && blockMeasures.length > 0) { // Content is a child of the cell, positioned relative to it @@ -782,14 +797,18 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, applySdtDataset, sdtBoundary: sdtBoundaries[i], - ancestorContainerKey: ancestorTableSdtKey, - ancestorContainerSdt: ancestorTableSdt, + ancestorContainerKey, + ancestorContainerSdt, + onSdtContainerChrome, }); cumulativeLineCount = result.nextCumulativeLineCount; if (result.element) { content.appendChild(result.element); flowCursorY += result.height; } + if (result.hasSdtContainerChrome) { + cellEl.style.overflow = 'visible'; + } continue; } @@ -953,7 +972,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paraWrapper.style.left = '0'; paraWrapper.style.width = '100%'; const sdtBoundary = sdtBoundaries[i]; - const blockKey = sdtContainerKeys[i] ?? null; content.appendChild(paraWrapper); const result = renderParagraphContent({ @@ -972,8 +990,12 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paddingTop, }, sdtBoundary, - shouldApplySdtContainerStyling: (sdt, containerSdt) => - shouldApplySdtContainerStyling(sdt, containerSdt, blockKey), + ancestorContainerKey, + ancestorContainerSdt, + onSdtContainerChrome: () => { + cellEl.style.overflow = 'visible'; + onSdtContainerChrome?.(); + }, applySdtDataset, renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => renderLine(block, line, { ...context, section: 'body' }, lineIndex, isLastLine, resolvedListTextStartPx), @@ -1141,13 +1163,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot(candidateLine, { ...context, section: 'body' }, { inTableParagraph: false, wrapperEl }); } } - - if ( - cellEl.style.overflow !== 'visible' && - content.querySelector('.superdoc-document-section, .superdoc-structured-content-block') - ) { - cellEl.style.overflow = 'visible'; - } } return { cellElement: cellEl }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 7653f8b8ff..b669dc14f1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -51,6 +51,8 @@ export type TableRenderDependencies = { ancestorContainerKey?: string | null; /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ ancestorContainerSdt?: SdtMetadata | null; + /** Receives notification when this table fragment or descendants render SDT container chrome */ + onSdtContainerChrome?: () => void; /** Function to render a line of paragraph content */ renderLine: ( block: ParagraphBlock, @@ -155,6 +157,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + onSdtContainerChrome, renderLine, captureLineSnapshot, renderDrawingContent, @@ -214,18 +217,22 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const contentTop = tableBorderWidths?.top ?? 0; // Apply SDT container styling (document sections, structured content blocks) - applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { - ancestorContainerKey, - ancestorContainerSdt, - }); + if ( + applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { + ancestorContainerKey, + ancestorContainerSdt, + }) + ) { + onSdtContainerChrome?.(); + } const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); - const rowAncestorContainerKey = tableContainerSdt + const nextAncestorContainerKey = tableContainerSdt ? hasExplicitSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt) ? tableContainerKey : ancestorContainerKey : ancestorContainerKey; - const rowAncestorContainerSdt = tableContainerSdt ?? ancestorContainerSdt; + const nextAncestorContainerSdt = tableContainerSdt ?? ancestorContainerSdt; // Add table-specific class for resize overlay targeting and click mapping container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); @@ -408,8 +415,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorTableSdtKey: rowAncestorContainerKey, - ancestorTableSdt: rowAncestorContainerSdt, + ancestorContainerKey: nextAncestorContainerKey, + ancestorContainerSdt: nextAncestorContainerSdt, + onSdtContainerChrome, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -571,8 +579,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorTableSdtKey: rowAncestorContainerKey, - ancestorTableSdt: rowAncestorContainerSdt, + ancestorContainerKey: nextAncestorContainerKey, + ancestorContainerSdt: nextAncestorContainerSdt, + onSdtContainerChrome, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 3b33dcf023..18d9d7116d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -173,10 +173,12 @@ type TableRowRenderDependencies = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Table-level SDT container key for suppressing duplicate container styling in cells */ - ancestorTableSdtKey?: string | null; - /** Table-level SDT metadata for suppressing duplicate container styling in cells */ - ancestorTableSdt?: SdtMetadata | null; + /** Ancestor SDT container key for suppressing duplicate container styling in cells */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdt?: SdtMetadata | null; + /** Receives notification when cells render SDT container chrome */ + onSdtContainerChrome?: () => void; /** * If true, this row is the first body row of a continuation fragment. * MS Word draws borders at split points to visually close the table on each page, @@ -256,8 +258,9 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorTableSdtKey, - ancestorTableSdt, + ancestorContainerKey, + ancestorContainerSdt, + onSdtContainerChrome, continuesFromPrev, continuesOnNext, partialRow, @@ -429,8 +432,9 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderDrawingContent, context, applySdtDataset, - ancestorTableSdtKey, - ancestorTableSdt, + ancestorContainerKey, + ancestorContainerSdt, + onSdtContainerChrome, fromLine, toLine, tableIndent, From 913f86fd54ed46656c3e749fdaf41dfb7f4b8daf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:19:42 -0300 Subject: [PATCH 16/37] fix(painters/dom): drop unused table block local --- packages/layout-engine/painters/dom/src/table/renderTableCell.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index d5a3b98aa8..f230ad490a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -750,7 +750,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const blockLineCounts: number[] = []; for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) { const bm = blockMeasures[i]; - const blk = cellBlocks[i]; if (bm.kind === 'paragraph') { blockLineCounts.push((bm as ParagraphMeasure).lines?.length || 0); } else if (bm.kind === 'table') { From 160108b0fadb6a32a4578c82ae5ba2e903eb0fbb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:31:28 -0300 Subject: [PATCH 17/37] fix(painters/dom): skip media for SDT boundaries --- .../dom/src/table/renderTableCell.test.ts | 66 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 4 +- 2 files changed, 69 insertions(+), 1 deletion(-) 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 80801d81e2..96e2ec16ba 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4565,6 +4565,72 @@ describe('renderTableCell', () => { expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); }); + it('should not let media-only blocks consume SDT container start boundaries', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'media-before-paragraph-sdt', + alias: 'Media Container', + }; + const imageBlock: ImageBlock = { + kind: 'image', + id: 'sdt-image-before-paragraph', + src: 'data:image/png;base64,AAA', + attrs: { sdt: sharedSdt }, + }; + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'sdt-paragraph-after-image', + runs: [{ text: 'After image', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: sharedSdt }, + }; + const imageMeasure = { + kind: 'image' as const, + width: 40, + height: 20, + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 11, + width: 70, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [imageMeasure, paragraphMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-media-before-sdt-paragraph', + blocks: [imageBlock, paragraph], + attrs: {}, + }, + }); + + const paragraphChrome = cellElement.querySelector('.superdoc-structured-content-block'); + expect(paragraphChrome?.dataset.sdtContainerStart).toBe('true'); + expect(paragraphChrome?.dataset.sdtContainerEnd).toBe('true'); + expect(paragraphChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe( + 'Media Container', + ); + }); + it('should continue SDT boundaries across partial nested table renders', () => { const nestedTableSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index f230ad490a..01c910b4c3 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -700,7 +700,9 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // Support multi-block cells with backward compatibility const cellBlocks = cell?.blocks ?? (cell?.paragraph ? [cell.paragraph] : []); const blockMeasures = cellMeasure?.blocks ?? (cellMeasure?.paragraph ? [cellMeasure.paragraph] : []); - const sdtContainerKeys = cellBlocks.map((block) => getSdtContainerKeyForBlock(block)); + const sdtContainerKeys = cellBlocks.map((block) => + block.kind === 'paragraph' || block.kind === 'table' ? getSdtContainerKeyForBlock(block) : null, + ); const sdtBoundaries = getSdtSiblingBoundaries(sdtContainerKeys); const hasSdtContainer = cellBlocks.some((block) => { From 5c6753aa7661f5ada1bb98bc68d4b4387a56e2b2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:37:32 -0300 Subject: [PATCH 18/37] fix(painters/dom): preserve SDT ancestor chain --- .../src/paragraph/renderParagraphContent.ts | 13 ++- .../painters/dom/src/sdt/container.ts | 24 ++--- .../dom/src/table/renderTableCell.test.ts | 93 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 27 ++++++ .../dom/src/table/renderTableFragment.ts | 29 ++++-- .../painters/dom/src/table/renderTableRow.ts | 9 ++ 6 files changed, 177 insertions(+), 18 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 06f5ec615c..71ca0b8ba2 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -14,7 +14,12 @@ import { getParagraphInlineDirection, } from '@superdoc/contracts'; import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import { applySdtContainerChrome, shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; +import { + applySdtContainerChrome, + shouldRenderSdtContainerChrome, + type SdtAncestorOptions, + type SdtBoundaryOptions, +} from '../sdt/container.js'; import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; import { applyParagraphLineIndentation, @@ -80,6 +85,8 @@ export type RenderParagraphContentParams = { spacingPolicy?: ParagraphSpacingPolicy; ancestorContainerKey?: string | null; ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; @@ -119,6 +126,8 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re spacingPolicy, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, applySdtDataset, applyContainerSdtDataset, @@ -142,6 +151,8 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, }); if (applySdtChrome) { if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) { diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 1637565009..e601b909e1 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -23,6 +23,13 @@ export type SdtBoundaryOptions = { showLabel?: boolean; }; +export type SdtAncestorOptions = { + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: readonly (string | null | undefined)[]; + ancestorContainerSdts?: readonly (SdtMetadata | null | undefined)[]; +}; + export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { type: 'structuredContent'; scope: 'inline' | 'block'; @@ -69,21 +76,19 @@ export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtC export function shouldRenderSdtContainerChrome( sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null, - options?: { - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; - }, + options?: SdtAncestorOptions, ): boolean { const metadata = getSdtContainerMetadata(sdt, containerSdt); if (!metadata) return false; const containerKey = getSdtContainerKey(sdt, containerSdt); - if (containerKey && options?.ancestorContainerKey && containerKey === options.ancestorContainerKey) { + const ancestorKeys = [options?.ancestorContainerKey, ...(options?.ancestorContainerKeys ?? [])]; + if (containerKey && ancestorKeys.includes(containerKey)) { return false; } - const ancestorContainerSdt = options?.ancestorContainerSdt; - if (ancestorContainerSdt && metadata === ancestorContainerSdt) { + const ancestorSdts = [options?.ancestorContainerSdt, ...(options?.ancestorContainerSdts ?? [])]; + if (ancestorSdts.includes(metadata)) { return false; } @@ -107,10 +112,7 @@ export function applySdtContainerChrome( sdt: SdtMetadata | null | undefined, containerSdt?: SdtMetadata | null | undefined, boundaryOptions?: SdtBoundaryOptions, - options?: { - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; - }, + options?: SdtAncestorOptions, ): boolean { if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return false; 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 96e2ec16ba..acd8ab587c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4339,6 +4339,99 @@ describe('renderTableCell', () => { expect(cellElement.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Descendant'); }); + it('should preserve outer ancestor suppression when a nested table has distinct SDT chrome', () => { + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-table-sdt', + alias: 'Outer Table', + }; + const nestedTableSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'nested-table-sdt', + alias: 'Nested Table', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-inherited-outer-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { containerSdt: ancestorSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-distinct-table-with-outer-child', + attrs: { sdt: nestedTableSdt }, + rows: [ + { + id: 'nested-outer-child-row', + cells: [{ id: 'nested-outer-child-cell', blocks: [nestedParagraph], attrs: {} }], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-distinct-table-with-outer-child', + blocks: [nestedTable], + attrs: {}, + }, + ancestorContainerKey: 'structuredContent:outer-table-sdt', + ancestorContainerSdt: ancestorSdt, + }); + + const labels = cellElement.querySelectorAll('.superdoc-structured-content__label'); + expect(labels).toHaveLength(1); + expect(labels[0]?.textContent).toBe('Nested Table'); + }); + it('should keep overflow hidden when descendant SDT chrome is outside the rendered nested table range', () => { const descendantSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 01c910b4c3..1ed4de63a3 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -25,6 +25,7 @@ import { getSdtContainerKeyForBlock, getSdtSiblingBoundaries, shouldRenderSdtContainerChrome, + type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; import { applyCellBorders } from './border-utils.js'; @@ -233,6 +234,10 @@ type EmbeddedTableRenderParams = { ancestorContainerKey?: string | null; /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys used to suppress duplicate container chrome in nested tables */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; /** Receives notification when this embedded table or its descendants render SDT chrome */ onSdtContainerChrome?: () => void; }; @@ -284,6 +289,8 @@ const renderEmbeddedTable = ( sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, } = params; @@ -338,6 +345,8 @@ const renderEmbeddedTable = ( sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome: () => { hasSdtContainerChrome = true; onSdtContainerChrome?.(); @@ -370,6 +379,8 @@ function renderPartialEmbeddedTable(params: { sdtBoundary?: SdtBoundaryOptions; ancestorContainerKey?: string | null; ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; }): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number; hasSdtContainerChrome: boolean } { const { @@ -388,6 +399,8 @@ function renderPartialEmbeddedTable(params: { sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, } = params; @@ -496,6 +509,8 @@ function renderPartialEmbeddedTable(params: { sdtBoundary: effectiveSdtBoundary, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, }); tableWrapper.appendChild(tableResult.element); @@ -562,6 +577,10 @@ type TableCellRenderDependencies = { ancestorContainerKey?: string | null; /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys for suppressing duplicate container styling in cells */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; /** Receives notification when this cell or descendants render SDT container chrome */ onSdtContainerChrome?: () => void; /** Table indent in pixels (applied to table fragment positioning) */ @@ -656,6 +675,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen applySdtDataset, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, tableIndent, isRtl, @@ -710,6 +731,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen return shouldRenderSdtContainerChrome(attrs?.sdt, attrs?.containerSdt, { ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, }); }); @@ -800,6 +823,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen sdtBoundary: sdtBoundaries[i], ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, }); cumulativeLineCount = result.nextCumulativeLineCount; @@ -993,6 +1018,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome: () => { cellEl.style.overflow = 'visible'; onSdtContainerChrome?.(); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index b669dc14f1..d4e8fd9a2b 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -18,6 +18,7 @@ import { getSdtContainerKey, getSdtContainerMetadata, hasExplicitSdtContainerKey, + type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; @@ -51,6 +52,10 @@ export type TableRenderDependencies = { ancestorContainerKey?: string | null; /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys used to suppress duplicate container chrome in nested tables */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; /** Receives notification when this table fragment or descendants render SDT container chrome */ onSdtContainerChrome?: () => void; /** Function to render a line of paragraph content */ @@ -157,6 +162,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement sdtBoundary, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, renderLine, captureLineSnapshot, @@ -221,18 +228,24 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, }) ) { onSdtContainerChrome?.(); } const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); - const nextAncestorContainerKey = tableContainerSdt - ? hasExplicitSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt) - ? tableContainerKey - : ancestorContainerKey - : ancestorContainerKey; - const nextAncestorContainerSdt = tableContainerSdt ?? ancestorContainerSdt; + const nextAncestorContainerKeys = [ + ...(ancestorContainerKeys ?? []), + ancestorContainerKey, + hasExplicitSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt) ? tableContainerKey : null, + ].filter((key): key is string => Boolean(key)); + const nextAncestorContainerSdts = [...(ancestorContainerSdts ?? []), ancestorContainerSdt, tableContainerSdt].filter( + (sdt): sdt is SdtMetadata => Boolean(sdt), + ); + const nextAncestorContainerKey = nextAncestorContainerKeys[nextAncestorContainerKeys.length - 1] ?? null; + const nextAncestorContainerSdt = nextAncestorContainerSdts[nextAncestorContainerSdts.length - 1] ?? null; // Add table-specific class for resize overlay targeting and click mapping container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); @@ -417,6 +430,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement applySdtDataset, ancestorContainerKey: nextAncestorContainerKey, ancestorContainerSdt: nextAncestorContainerSdt, + ancestorContainerKeys: nextAncestorContainerKeys, + ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, @@ -581,6 +596,8 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement applySdtDataset, ancestorContainerKey: nextAncestorContainerKey, ancestorContainerSdt: nextAncestorContainerSdt, + ancestorContainerKeys: nextAncestorContainerKeys, + ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 18d9d7116d..15701ffb05 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -19,6 +19,7 @@ import { } from './border-utils.js'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; import type { FragmentRenderContext } from '../renderer.js'; +import type { SdtAncestorOptions } from '../sdt/container.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableRow = TableBlock['rows'][number]; @@ -177,6 +178,10 @@ type TableRowRenderDependencies = { ancestorContainerKey?: string | null; /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys for suppressing duplicate container styling in cells */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; /** Receives notification when cells render SDT container chrome */ onSdtContainerChrome?: () => void; /** @@ -260,6 +265,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { applySdtDataset, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, continuesFromPrev, continuesOnNext, @@ -434,6 +441,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { applySdtDataset, ancestorContainerKey, ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, onSdtContainerChrome, fromLine, toLine, From cdad4c1c51d99b8646164568202c5c80b3ccce63 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:41:16 -0300 Subject: [PATCH 19/37] fix(painters/dom): defer SDT overflow to rendered chrome --- .../dom/src/table/renderTableCell.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 17 ----- 2 files changed, 64 insertions(+), 17 deletions(-) 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 acd8ab587c..da35a56049 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4432,6 +4432,70 @@ describe('renderTableCell', () => { expect(labels[0]?.textContent).toBe('Nested Table'); }); + it('should keep overflow hidden when top-level SDT chrome is outside the rendered cell range', () => { + const hiddenSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'hidden-top-level-sdt', + alias: 'Hidden', + }; + const firstParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'visible-top-level-para', + runs: [{ text: 'Visible', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const secondParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'hidden-top-level-sdt-para', + runs: [{ text: 'Hidden', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: hiddenSdt }, + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 7, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + let chromeNotifications = 0; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, paragraphMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-partial-top-level-hidden-sdt', + blocks: [firstParagraph, secondParagraph], + attrs: {}, + }, + fromLine: 0, + toLine: 1, + onSdtContainerChrome: () => { + chromeNotifications += 1; + }, + }); + + expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + expect(chromeNotifications).toBe(0); + }); + it('should keep overflow hidden when descendant SDT chrome is outside the rendered nested table range', () => { const descendantSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 1ed4de63a3..c579136c53 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -24,7 +24,6 @@ import { applyImageClipPath } from '../utils/image-clip-path.js'; import { getSdtContainerKeyForBlock, getSdtSiblingBoundaries, - shouldRenderSdtContainerChrome, type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; @@ -726,22 +725,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen ); const sdtBoundaries = getSdtSiblingBoundaries(sdtContainerKeys); - const hasSdtContainer = cellBlocks.some((block) => { - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return shouldRenderSdtContainerChrome(attrs?.sdt, attrs?.containerSdt, { - ancestorContainerKey, - ancestorContainerSdt, - ancestorContainerKeys, - ancestorContainerSdts, - }); - }); - - // SDT containers display labels that extend above the content boundary. - // Change overflow to 'visible' so these labels aren't clipped by the cell. - if (hasSdtContainer) { - cellEl.style.overflow = 'visible'; - onSdtContainerChrome?.(); - } if (cellBlocks.length > 0 && blockMeasures.length > 0) { // Content is a child of the cell, positioned relative to it // Cell's overflow:hidden handles clipping, no explicit width needed From 8f2b80cc91ccdd6403518b74ba5b0096867b5639 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:45:39 -0300 Subject: [PATCH 20/37] fix(painters/dom): continue split SDT paragraph chrome --- .../dom/src/table/renderTableCell.test.ts | 76 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 11 ++- 2 files changed, 86 insertions(+), 1 deletion(-) 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 da35a56049..1c46042b39 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4788,6 +4788,82 @@ describe('renderTableCell', () => { ); }); + it('should continue SDT paragraph boundaries across split table-cell fragments', () => { + const paragraphSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'split-paragraph-sdt', + alias: 'Split Paragraph', + }; + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'split-sdt-paragraph', + runs: [{ text: 'Split paragraph', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: paragraphSdt }, + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 6, + toRun: 0, + toChar: 15, + width: 80, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + const cellMeasure: TableCellMeasure = { + blocks: [measure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + const cell: TableCell = { + id: 'cell-split-sdt-paragraph', + blocks: [paragraph], + attrs: {}, + }; + + const firstFragment = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + fromLine: 0, + toLine: 1, + }).cellElement.querySelector('.superdoc-structured-content-block'); + const continuationFragment = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + fromLine: 1, + toLine: 2, + }).cellElement.querySelector('.superdoc-structured-content-block'); + + expect(firstFragment?.dataset.sdtContainerStart).toBe('true'); + expect(firstFragment?.dataset.sdtContainerEnd).toBe('false'); + expect(firstFragment?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Split Paragraph'); + expect(continuationFragment?.dataset.sdtContainerStart).toBe('false'); + expect(continuationFragment?.dataset.sdtContainerEnd).toBe('true'); + expect(continuationFragment?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + it('should continue SDT boundaries across partial nested table renders', () => { const nestedTableSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c579136c53..8be7e9b621 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -980,7 +980,16 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paraWrapper.style.position = 'relative'; paraWrapper.style.left = '0'; paraWrapper.style.width = '100%'; - const sdtBoundary = sdtBoundaries[i]; + const baseSdtBoundary = sdtBoundaries[i]; + const sdtBoundary = baseSdtBoundary + ? { + ...baseSdtBoundary, + isStart: (baseSdtBoundary.isStart ?? true) && localStartLine === 0, + isEnd: (baseSdtBoundary.isEnd ?? true) && localEndLine >= blockLineCount, + showLabel: + baseSdtBoundary.showLabel === undefined ? undefined : baseSdtBoundary.showLabel && localStartLine === 0, + } + : undefined; content.appendChild(paraWrapper); const result = renderParagraphContent({ From 738fcd3e7ce3231f62ed12a02cc3055b1e7ae157 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 16:06:43 -0300 Subject: [PATCH 21/37] refactor(painters/dom): extract SDT helpers from renderer Move SDT boundary computation, dataset application, snapshot collection, and inline wrapper helpers out of renderer.ts into dedicated modules under sdt/. Renames runs/sdt.ts to sdt/inline.ts and re-exports remain stable. No behavior change. --- .../painters/dom/src/renderer.ts | 406 ++---------------- .../painters/dom/src/runs/index.ts | 2 +- .../painters/dom/src/sdt/boundaries.ts | 81 ++++ .../painters/dom/src/sdt/dataset.ts | 110 +++++ .../dom/src/{runs/sdt.ts => sdt/inline.ts} | 14 +- .../painters/dom/src/sdt/snapshot.ts | 84 ++++ 6 files changed, 331 insertions(+), 366 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/sdt/boundaries.ts create mode 100644 packages/layout-engine/painters/dom/src/sdt/dataset.ts rename packages/layout-engine/painters/dom/src/{runs/sdt.ts => sdt/inline.ts} (81%) create mode 100644 packages/layout-engine/painters/dom/src/sdt/snapshot.ts diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 7056b5fd52..22f59c1b6d 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -20,7 +20,6 @@ import type { ParagraphBlock, PositionedDrawingGeometry, Run, - SdtMetadata, ShapeGroupChild, ShapeGroupDrawing, ShapeTextContent, @@ -31,7 +30,6 @@ import type { TableCellAttrs, TableFragment, TableMeasure, - TextRun, VectorShapeDrawing, VectorShapeStyle, ResolvedLayout, @@ -59,7 +57,6 @@ 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 { - BROWSER_DEFAULT_FONT_SIZE, CLASS_NAMES, containerStyles, containerStylesHorizontal, @@ -79,7 +76,26 @@ import { 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, + getSdtMetadataId, + getSdtMetadataLockMode, + getSdtMetadataVersion, +} from './sdt/dataset.js'; +import { + createInlineSdtWrapper, + expandSdtWrapperPmRange, + resolveRunSdtId, + syncInlineSdtWrapperTypography, +} from './sdt/inline.js'; +import { + collectSdtSnapshotEntitiesFromDomRoot, + type PaintSnapshotStructuredContentBlockEntity, + 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'; @@ -90,6 +106,11 @@ import { buildImageFilters } from './runs/image-run.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; +export type { + PaintSnapshotStructuredContentBlockEntity, + PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; + type LineEnd = { type?: string; width?: string; @@ -303,24 +324,6 @@ export type PaintSnapshotAnnotationEntity = { layoutSourceIdentity?: LayoutSourceIdentity; }; -export type PaintSnapshotStructuredContentBlockEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; - layoutSourceIdentity?: LayoutSourceIdentity; -}; - -export type PaintSnapshotStructuredContentInlineEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; - layoutSourceIdentity?: LayoutSourceIdentity; -}; - export type PaintSnapshotImageEntity = { element: HTMLElement; pageIndex: number; @@ -572,45 +575,14 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap ); } - const blockSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`), - ); - for (const element of blockSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentBlocks.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - layoutSourceIdentity: readNearestLayoutSourceIdentity(element), - }) as PaintSnapshotStructuredContentBlockEntity, - ); - } - - const inlineSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}[data-sdt-id]`), - ); - for (const element of inlineSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentInlines.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - layoutSourceIdentity: readNearestLayoutSourceIdentity(element), - }) as PaintSnapshotStructuredContentInlineEntity, - ); - } + const sdtEntities = collectSdtSnapshotEntitiesFromDomRoot(rootEl, { + resolvePageIndex: resolveSnapshotPageIndex, + readDatasetNumber: readSnapshotDatasetNumber, + readLayoutSourceIdentity: readNearestLayoutSourceIdentity, + compactObject: compactSnapshotObject, + }); + entities.structuredContentBlocks.push(...sdtEntities.structuredContentBlocks); + entities.structuredContentInlines.push(...sdtEntities.structuredContentInlines); const inlineImageElements = Array.from( rootEl.querySelectorAll( @@ -2569,8 +2541,8 @@ export class DomPainter { applyResolvedFragmentFrame: (el, item, paraFragment) => this.applyResolvedFragmentFrame(el, item, paraFragment, context.section, context.story), applyFragmentFrame: (el, paraFragment) => this.applyFragmentFrame(el, paraFragment, context.section, context.story), - applySdtDataset: this.applySdtDataset.bind(this), - applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + applySdtDataset, + applyContainerSdtDataset, renderLine: ({ block, line, @@ -2660,8 +2632,8 @@ export class DomPainter { fragmentEl.style.height = `${fragment.height}px`; this.applyFragmentWrapperZIndex(fragmentEl, fragment); } - this.applySdtDataset(fragmentEl, block.attrs?.sdt); - this.applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); + applySdtDataset(fragmentEl, block.attrs?.sdt); + applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); // Add block ID for PM transaction targeting if (block.id) { @@ -3964,8 +3936,8 @@ export class DomPainter { }, renderDrawingContent: renderDrawingContentForTableCell, applyFragmentFrame: applyFragmentFrameWithSection, - applySdtDataset: this.applySdtDataset.bind(this), - applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + applySdtDataset, + applyContainerSdtDataset, applyStyles, }); @@ -4023,23 +3995,24 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - return { + const runContext: RunRenderContext = { doc: this.doc, layoutEpoch: this.layoutEpoch, showFormattingMarks: this.showFormattingMarks, pendingTooltips: this.pendingTooltips, getNextLinkId: () => `superdoc-link-${++this.linkIdCounter}`, - applySdtDataset: this.applySdtDataset.bind(this), + applySdtDataset, buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind( this, ) as RunRenderContext['buildImageHyperlinkAnchor'], resolveTrackedChangesConfig, applyTrackedChangeDecorations, - resolveRunSdtId: this.resolveRunSdtId.bind(this), - createInlineSdtWrapper: this.createInlineSdtWrapper.bind(this), - syncInlineSdtWrapperTypography: this.syncInlineSdtWrapperTypography.bind(this), - expandSdtWrapperPmRange: this.expandSdtWrapperPmRange.bind(this), + resolveRunSdtId, + createInlineSdtWrapper: (sdt) => createInlineSdtWrapper(sdt, runContext), + syncInlineSdtWrapperTypography, + expandSdtWrapperPmRange, }; + return runContext; } /** @@ -4239,279 +4212,8 @@ export class DomPainter { } return 0; } - - /** - * All dataset keys used for SDT metadata. - * Shared between applySdtDataset and clearSdtDataset to ensure consistency. - */ - private static readonly SDT_DATASET_KEYS = [ - 'sdtType', - 'sdtId', - 'sdtFieldId', - 'sdtFieldType', - 'sdtFieldVariant', - 'sdtFieldVisibility', - 'sdtFieldHidden', - 'sdtFieldLocked', - 'sdtScope', - 'sdtTag', - 'sdtAlias', - 'lockMode', - 'sdtSectionTitle', - 'sdtSectionType', - 'sdtSectionLocked', - 'sdtDocpartGallery', - 'sdtDocpartId', - 'sdtDocpartInstruction', - ] as const; - - /** - * Helper to set a string dataset attribute if the value is truthy. - */ - private setDatasetString(el: HTMLElement, key: string, value: string | null | undefined): void { - if (value) { - el.dataset[key] = value; - } - } - - /** - * Helper to set a boolean dataset attribute if the value is not null/undefined. - */ - private setDatasetBoolean(el: HTMLElement, key: string, value: boolean | null | undefined): void { - if (value != null) { - el.dataset[key] = String(value); - } - } - - /** - * Resolve the inline SDT id from a run, or null if the run is not inside an inline SDT. - */ - private resolveRunSdtId(run: Run): { sdtId: string; sdt: SdtMetadata } | null { - const sdt = (run as TextRun).sdt; - if (sdt?.type === 'structuredContent' && sdt?.scope === 'inline' && sdt?.id) { - return { sdtId: String(sdt.id), sdt }; - } - return null; - } - - /** - * Create an inline SDT wrapper `` with className, layoutEpoch, dataset, and label. - * Shared by both the geometry and run-based rendering paths. - * - * When the SDT's `appearance` is `'hidden'` (matching ECMA-376 - * ``), the wrapper is rendered - * transparently: chrome is suppressed via `data-appearance="hidden"` - * (see styles.ts) and the alias label is omitted entirely. Without the - * latter, the alias text leaks into the rendered DOM `textContent` - * (copy-paste includes it) and screen readers announce it. - */ - private createInlineSdtWrapper(sdt: SdtMetadata): HTMLElement { - const wrapper = this.doc!.createElement('span'); - wrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; - wrapper.dataset.layoutEpoch = String(this.layoutEpoch); - this.applySdtDataset(wrapper, sdt); - - const appearance = sdt.type === 'structuredContent' ? (sdt as { appearance?: string }).appearance : undefined; - if (appearance === 'hidden') { - wrapper.dataset.appearance = 'hidden'; - // No alias label and no chrome: see CSS rule keyed off - // `[data-appearance="hidden"]`. - return wrapper; - } - - const alias = (sdt as { alias?: string })?.alias || 'Inline content'; - const labelEl = this.doc!.createElement('span'); - labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; - labelEl.textContent = alias; - wrapper.appendChild(labelEl); - return wrapper; - } - - private syncInlineSdtWrapperTypography(wrapper: HTMLElement, runForSizing?: Run): void { - // The line container sets fontSize:0 (strut fix). Keep wrapper typography - // synced with the current run so border height tracks text-size edits. - const runFontSize = - runForSizing && 'fontSize' in runForSizing && typeof runForSizing.fontSize === 'number' - ? `${runForSizing.fontSize}px` - : BROWSER_DEFAULT_FONT_SIZE; - wrapper.style.fontSize = runFontSize; - wrapper.style.lineHeight = 'normal'; - } - - /** - * Expand the PM position range tracked on an SDT wrapper to include a new run's range. - */ - private expandSdtWrapperPmRange(wrapper: HTMLElement, pmStart?: number | null, pmEnd?: number | null): void { - if (pmStart != null) { - const cur = wrapper.dataset.pmStart; - if (!cur || pmStart < parseInt(cur, 10)) { - wrapper.dataset.pmStart = String(pmStart); - } - } - if (pmEnd != null) { - const cur = wrapper.dataset.pmEnd; - if (!cur || pmEnd > parseInt(cur, 10)) { - wrapper.dataset.pmEnd = String(pmEnd); - } - } - } - - /** - * Applies SDT (Structured Document Tag) metadata to an element's dataset as data-sdt-* attributes. - * Supports field annotations, structured content, document sections, and doc parts. - * Clears existing SDT metadata before applying new values. - * - * @param el - The HTML element to annotate - * @param metadata - The SDT metadata to render as data attributes - */ - private applySdtDataset(el: HTMLElement | null, metadata?: SdtMetadata | null): void { - if (!el?.dataset) return; - this.clearSdtDataset(el); - if (!metadata) return; - - el.dataset.sdtType = metadata.type; - - if ('id' in metadata && metadata.id != null) { - el.dataset.sdtId = String(metadata.id); - } - - if (metadata.type === 'fieldAnnotation') { - this.setDatasetString(el, 'sdtFieldId', metadata.fieldId); - this.setDatasetString(el, 'sdtFieldType', metadata.fieldType); - this.setDatasetString(el, 'sdtFieldVariant', metadata.variant); - this.setDatasetString(el, 'sdtFieldVisibility', metadata.visibility); - this.setDatasetBoolean(el, 'sdtFieldHidden', metadata.hidden); - this.setDatasetBoolean(el, 'sdtFieldLocked', metadata.isLocked); - } else if (metadata.type === 'structuredContent') { - this.setDatasetString(el, 'sdtScope', metadata.scope); - this.setDatasetString(el, 'sdtTag', metadata.tag); - this.setDatasetString(el, 'sdtAlias', metadata.alias); - // Always set lockMode (defaulting to 'unlocked') so CSS can target all SDTs uniformly. - this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); - } else if (metadata.type === 'documentSection') { - this.setDatasetString(el, 'sdtSectionTitle', metadata.title); - this.setDatasetString(el, 'sdtSectionType', metadata.sectionType); - this.setDatasetBoolean(el, 'sdtSectionLocked', metadata.isLocked); - } else if (metadata.type === 'docPartObject') { - this.setDatasetString(el, 'sdtDocpartGallery', metadata.gallery); - this.setDatasetString(el, 'sdtDocpartId', metadata.uniqueId); - this.setDatasetString(el, 'sdtDocpartInstruction', metadata.instruction); - } - } - - private clearSdtDataset(el: HTMLElement): void { - DomPainter.SDT_DATASET_KEYS.forEach((key) => { - delete el.dataset[key]; - }); - } - - /** - * Applies container SDT metadata to an element's dataset (data-sdt-container-* attributes). - * Used when a block has both primary SDT metadata (e.g., docPartObject) and container - * metadata (e.g., documentSection). The container metadata is rendered with a "Container" - * prefix to distinguish it from the primary SDT metadata. - * - * @param el - The HTML element to annotate - * @param metadata - The container SDT metadata (typically documentSection) - */ - private applyContainerSdtDataset(el: HTMLElement | null, metadata?: SdtMetadata | null): void { - if (!el?.dataset) return; - if (!metadata) return; - - el.dataset.sdtContainerType = metadata.type; - - if ('id' in metadata && metadata.id != null) { - el.dataset.sdtContainerId = String(metadata.id); - } - - if (metadata.type === 'documentSection') { - this.setDatasetString(el, 'sdtContainerSectionTitle', metadata.title); - this.setDatasetString(el, 'sdtContainerSectionType', metadata.sectionType); - this.setDatasetBoolean(el, 'sdtContainerSectionLocked', metadata.isLocked); - } - // Other container types can be added here if needed - } } -const computeSdtBoundaries = ( - resolvedItems: readonly ResolvedPaintItem[], - sdtLabelsRendered: Set, -): Map => { - const boundaries = new Map(); - const containerKeys: (string | null)[] = resolvedItems.map((item) => { - if (item && 'sdtContainerKey' in item) { - const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; - return key ?? null; - } - return null; - }); - - const fragmentOf = (idx: number): Fragment | null => { - const item = resolvedItems[idx]; - return item && item.kind === 'fragment' ? item.fragment : null; - }; - - let i = 0; - while (i < resolvedItems.length) { - const currentKey = containerKeys[i]; - const startFrag = fragmentOf(i); - if (!currentKey || !startFrag) { - i += 1; - continue; - } - - let groupRight = startFrag.x + startFrag.width; - let j = i; - - while (j + 1 < resolvedItems.length && containerKeys[j + 1] === currentKey) { - j += 1; - const nextFrag = fragmentOf(j); - if (!nextFrag) break; - const fragmentRight = nextFrag.x + nextFrag.width; - if (fragmentRight > groupRight) { - groupRight = fragmentRight; - } - } - - for (let k = i; k <= j; k += 1) { - const fragment = fragmentOf(k); - if (!fragment) continue; - const isStart = k === i; - const isEnd = k === j; - - let paddingBottomOverride: number | undefined; - if (!isEnd) { - const nextFragment = fragmentOf(k + 1); - const currentHeight = (resolvedItems[k] as { height?: number } | undefined)?.height ?? 0; - const currentBottom = fragment.y + currentHeight; - if (nextFragment) { - const gapToNext = nextFragment.y - currentBottom; - if (gapToNext > 0) { - paddingBottomOverride = gapToNext; - } - } - } - - const showLabel = isStart && !sdtLabelsRendered.has(currentKey); - if (showLabel) { - sdtLabelsRendered.add(currentKey); - } - - boundaries.set(k, { - isStart, - isEnd, - widthOverride: groupRight - fragment.x, - paddingBottomOverride, - showLabel, - }); - } - - i = j + 1; - } - - return boundaries; -}; - const fragmentKey = (fragment: Fragment): string => { switch (fragment.kind) { case 'para': @@ -4554,24 +4256,6 @@ const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => blockId.startsWith('__sd_semantic_footnote-') || blockId.startsWith('__sd_semantic_endnote-')); -const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - if ('id' in metadata && metadata.id != null) { - return String(metadata.id); - } - return ''; -}; - -const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; -}; - -const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); -}; - /** * Derives a version string for a flow block based on its content and styling properties. * diff --git a/packages/layout-engine/painters/dom/src/runs/index.ts b/packages/layout-engine/painters/dom/src/runs/index.ts index d5117686d4..6d3b5cb9a1 100644 --- a/packages/layout-engine/painters/dom/src/runs/index.ts +++ b/packages/layout-engine/painters/dom/src/runs/index.ts @@ -14,5 +14,5 @@ export { createInlineSdtWrapper, syncInlineSdtWrapperTypography, expandSdtWrapperPmRange, -} from './sdt.js'; +} from '../sdt/inline.js'; export type { RenderedLineInfo, RenderLineParams, RunRenderContext, TrackedChangesRenderConfig } from './types.js'; diff --git a/packages/layout-engine/painters/dom/src/sdt/boundaries.ts b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts new file mode 100644 index 0000000000..213967303b --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts @@ -0,0 +1,81 @@ +import type { Fragment, ResolvedPaintItem } from '@superdoc/contracts'; +import type { SdtBoundaryOptions } from './container.js'; + +export const computeSdtBoundaries = ( + resolvedItems: readonly ResolvedPaintItem[], + sdtLabelsRendered: Set, +): Map => { + const boundaries = new Map(); + const containerKeys: (string | null)[] = resolvedItems.map((item) => { + if (item && 'sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + return key ?? null; + } + return null; + }); + + const fragmentOf = (idx: number): Fragment | null => { + const item = resolvedItems[idx]; + return item && item.kind === 'fragment' ? item.fragment : null; + }; + + let i = 0; + while (i < resolvedItems.length) { + const currentKey = containerKeys[i]; + const startFrag = fragmentOf(i); + if (!currentKey || !startFrag) { + i += 1; + continue; + } + + let groupRight = startFrag.x + startFrag.width; + let j = i; + + while (j + 1 < resolvedItems.length && containerKeys[j + 1] === currentKey) { + j += 1; + const nextFrag = fragmentOf(j); + if (!nextFrag) break; + const fragmentRight = nextFrag.x + nextFrag.width; + if (fragmentRight > groupRight) { + groupRight = fragmentRight; + } + } + + for (let k = i; k <= j; k += 1) { + const fragment = fragmentOf(k); + if (!fragment) continue; + const isStart = k === i; + const isEnd = k === j; + + let paddingBottomOverride: number | undefined; + if (!isEnd) { + const nextFragment = fragmentOf(k + 1); + const currentHeight = (resolvedItems[k] as { height?: number } | undefined)?.height ?? 0; + const currentBottom = fragment.y + currentHeight; + if (nextFragment) { + const gapToNext = nextFragment.y - currentBottom; + if (gapToNext > 0) { + paddingBottomOverride = gapToNext; + } + } + } + + const showLabel = isStart && !sdtLabelsRendered.has(currentKey); + if (showLabel) { + sdtLabelsRendered.add(currentKey); + } + + boundaries.set(k, { + isStart, + isEnd, + widthOverride: groupRight - fragment.x, + paddingBottomOverride, + showLabel, + }); + } + + i = j + 1; + } + + return boundaries; +}; diff --git a/packages/layout-engine/painters/dom/src/sdt/dataset.ts b/packages/layout-engine/painters/dom/src/sdt/dataset.ts new file mode 100644 index 0000000000..ca4477d372 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/dataset.ts @@ -0,0 +1,110 @@ +import type { SdtMetadata } from '@superdoc/contracts'; + +const SDT_DATASET_KEYS = [ + 'sdtType', + 'sdtId', + 'sdtFieldId', + 'sdtFieldType', + 'sdtFieldVariant', + 'sdtFieldVisibility', + 'sdtFieldHidden', + 'sdtFieldLocked', + 'sdtScope', + 'sdtTag', + 'sdtAlias', + 'lockMode', + 'sdtSectionTitle', + 'sdtSectionType', + 'sdtSectionLocked', + 'sdtDocpartGallery', + 'sdtDocpartId', + 'sdtDocpartInstruction', +] as const; + +const setDatasetString = (el: HTMLElement, key: string, value: string | null | undefined): void => { + if (value) { + el.dataset[key] = value; + } +}; + +const setDatasetBoolean = (el: HTMLElement, key: string, value: boolean | null | undefined): void => { + if (value != null) { + el.dataset[key] = String(value); + } +}; + +export const clearSdtDataset = (el: HTMLElement): void => { + SDT_DATASET_KEYS.forEach((key) => { + delete el.dataset[key]; + }); +}; + +export const applySdtDataset = (el: HTMLElement | null, metadata?: SdtMetadata | null): void => { + if (!el?.dataset) return; + clearSdtDataset(el); + if (!metadata) return; + + el.dataset.sdtType = metadata.type; + + if ('id' in metadata && metadata.id != null) { + el.dataset.sdtId = String(metadata.id); + } + + if (metadata.type === 'fieldAnnotation') { + setDatasetString(el, 'sdtFieldId', metadata.fieldId); + setDatasetString(el, 'sdtFieldType', metadata.fieldType); + setDatasetString(el, 'sdtFieldVariant', metadata.variant); + setDatasetString(el, 'sdtFieldVisibility', metadata.visibility); + setDatasetBoolean(el, 'sdtFieldHidden', metadata.hidden); + setDatasetBoolean(el, 'sdtFieldLocked', metadata.isLocked); + } else if (metadata.type === 'structuredContent') { + setDatasetString(el, 'sdtScope', metadata.scope); + setDatasetString(el, 'sdtTag', metadata.tag); + setDatasetString(el, 'sdtAlias', metadata.alias); + // Always set lockMode so CSS can target all structured-content SDTs uniformly. + setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); + } else if (metadata.type === 'documentSection') { + setDatasetString(el, 'sdtSectionTitle', metadata.title); + setDatasetString(el, 'sdtSectionType', metadata.sectionType); + setDatasetBoolean(el, 'sdtSectionLocked', metadata.isLocked); + } else if (metadata.type === 'docPartObject') { + setDatasetString(el, 'sdtDocpartGallery', metadata.gallery); + setDatasetString(el, 'sdtDocpartId', metadata.uniqueId); + setDatasetString(el, 'sdtDocpartInstruction', metadata.instruction); + } +}; + +export const applyContainerSdtDataset = (el: HTMLElement | null, metadata?: SdtMetadata | null): void => { + if (!el?.dataset) return; + if (!metadata) return; + + el.dataset.sdtContainerType = metadata.type; + + if ('id' in metadata && metadata.id != null) { + el.dataset.sdtContainerId = String(metadata.id); + } + + if (metadata.type === 'documentSection') { + setDatasetString(el, 'sdtContainerSectionTitle', metadata.title); + setDatasetString(el, 'sdtContainerSectionType', metadata.sectionType); + setDatasetBoolean(el, 'sdtContainerSectionLocked', metadata.isLocked); + } +}; + +export const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + if ('id' in metadata && metadata.id != null) { + return String(metadata.id); + } + return ''; +}; + +export const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; +}; + +export const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); +}; diff --git a/packages/layout-engine/painters/dom/src/runs/sdt.ts b/packages/layout-engine/painters/dom/src/sdt/inline.ts similarity index 81% rename from packages/layout-engine/painters/dom/src/runs/sdt.ts rename to packages/layout-engine/painters/dom/src/sdt/inline.ts index 4db629b8fb..871a81fbbc 100644 --- a/packages/layout-engine/painters/dom/src/runs/sdt.ts +++ b/packages/layout-engine/painters/dom/src/sdt/inline.ts @@ -1,7 +1,7 @@ import type { Run, SdtMetadata, TextRun } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; -import { BROWSER_DEFAULT_FONT_SIZE } from './text-run.js'; -import type { RunRenderContext } from './types.js'; +import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js'; +import type { RunRenderContext } from '../runs/types.js'; export const resolveRunSdtId = (run: Run): { sdtId: string; sdt: SdtMetadata } | null => { const sdt = (run as TextRun).sdt; @@ -16,6 +16,13 @@ export const createInlineSdtWrapper = (sdt: SdtMetadata, context: RunRenderConte wrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; wrapper.dataset.layoutEpoch = String(context.layoutEpoch); context.applySdtDataset(wrapper, sdt); + + const appearance = sdt.type === 'structuredContent' ? (sdt as { appearance?: string }).appearance : undefined; + if (appearance === 'hidden') { + wrapper.dataset.appearance = 'hidden'; + return wrapper; + } + const alias = (sdt as { alias?: string })?.alias || 'Inline content'; const labelEl = context.doc.createElement('span'); labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; @@ -25,8 +32,7 @@ export const createInlineSdtWrapper = (sdt: SdtMetadata, context: RunRenderConte }; export const syncInlineSdtWrapperTypography = (wrapper: HTMLElement, runForSizing?: Run): void => { - // The line container sets fontSize:0 (strut fix). Keep wrapper typography - // synced with the current run so border height tracks text-size edits. + // The line container sets fontSize:0; keep wrapper chrome aligned with the run text size. const runFontSize = runForSizing && 'fontSize' in runForSizing && typeof runForSizing.fontSize === 'number' ? `${runForSizing.fontSize}px` diff --git a/packages/layout-engine/painters/dom/src/sdt/snapshot.ts b/packages/layout-engine/painters/dom/src/sdt/snapshot.ts new file mode 100644 index 0000000000..8399deb38f --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/snapshot.ts @@ -0,0 +1,84 @@ +import { DOM_CLASS_NAMES } from '../constants.js'; +import type { LayoutSourceIdentity } from '@superdoc/contracts'; + +export type PaintSnapshotStructuredContentBlockEntity = { + element: HTMLElement; + pageIndex: number; + sdtId: string; + pmStart?: number; + pmEnd?: number; + layoutSourceIdentity?: LayoutSourceIdentity; +}; + +export type PaintSnapshotStructuredContentInlineEntity = { + element: HTMLElement; + pageIndex: number; + sdtId: string; + pmStart?: number; + pmEnd?: number; + layoutSourceIdentity?: LayoutSourceIdentity; +}; + +export type SdtSnapshotEntities = { + structuredContentBlocks: PaintSnapshotStructuredContentBlockEntity[]; + structuredContentInlines: PaintSnapshotStructuredContentInlineEntity[]; +}; + +type CollectSdtSnapshotEntitiesOptions = { + resolvePageIndex: (element: HTMLElement) => number | null; + readDatasetNumber: (value: string | null | undefined) => number | null; + readLayoutSourceIdentity?: (element: HTMLElement) => LayoutSourceIdentity | undefined; + compactObject: >(input: T) => T; +}; + +export const collectSdtSnapshotEntitiesFromDomRoot = ( + rootEl: HTMLElement, + options: CollectSdtSnapshotEntitiesOptions, +): SdtSnapshotEntities => { + const entities: SdtSnapshotEntities = { + structuredContentBlocks: [], + structuredContentInlines: [], + }; + + const blockSdtElements = Array.from( + rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`), + ); + for (const element of blockSdtElements) { + const pageIndex = options.resolvePageIndex(element); + const sdtId = element.dataset.sdtId; + if (pageIndex == null || !sdtId) continue; + + entities.structuredContentBlocks.push( + options.compactObject({ + element, + pageIndex, + sdtId, + pmStart: options.readDatasetNumber(element.dataset.pmStart), + pmEnd: options.readDatasetNumber(element.dataset.pmEnd), + layoutSourceIdentity: options.readLayoutSourceIdentity?.(element), + }) as PaintSnapshotStructuredContentBlockEntity, + ); + } + + const inlineSdtElements = Array.from( + rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}[data-sdt-id]`), + ); + for (const element of inlineSdtElements) { + const pageIndex = options.resolvePageIndex(element); + const sdtId = element.dataset.sdtId; + if (pageIndex == null || !sdtId) continue; + + entities.structuredContentInlines.push( + options.compactObject({ + element, + pageIndex, + sdtId, + pmStart: options.readDatasetNumber(element.dataset.pmStart), + pmEnd: options.readDatasetNumber(element.dataset.pmEnd), + layoutSourceIdentity: options.readLayoutSourceIdentity?.(element), + }) as PaintSnapshotStructuredContentInlineEntity, + ); + } + + return entities; +}; From 22ce0ff5051d1d98c5001fa3273b098278c74a71 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:05:47 -0300 Subject: [PATCH 22/37] 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 22f59c1b6d..98023e5336 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -51,7 +51,6 @@ import { } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; 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'; @@ -102,7 +101,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'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; @@ -2658,22 +2658,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, { @@ -2684,13 +2668,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; @@ -2765,47 +2748,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( @@ -2887,23 +2831,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( @@ -4287,7 +4220,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') { @@ -4299,7 +4232,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { block.height ?? '', block.alt ?? '', block.title ?? '', - resolveBlockClipPath(block), + resolveBlockImageClipPath(block), imgSdtVersion, ].join('|'); } @@ -4314,7 +4247,7 @@ const deriveBlockVersion = (block: FlowBlock): string => { imageLike.width ?? '', imageLike.height ?? '', imageLike.alt ?? '', - resolveBlockClipPath(imageLike), + resolveBlockImageClipPath(imageLike), ].join('|'); } if (block.drawingKind === 'vectorShape') { @@ -4485,29 +4418,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 8a2059ce0bddd984702538c3fff94c9dd1458dae Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:12:47 -0300 Subject: [PATCH 23/37] 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 4385b9453d..9bfd58f85c 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 7d1f223147..22d507b03b 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -81,6 +81,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 // --------------------------------------------------------------------------- @@ -329,28 +359,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; @@ -482,6 +497,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 98023e5336..ecfc618d17 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -101,7 +101,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'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; @@ -4226,29 +4226,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 01c2e2fe209699692cf22f8360c444cffe7b5f61 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 12:04:51 -0300 Subject: [PATCH 24/37] docs(layout-engine): flag SdtMetadata identity invariant for pm-adapter --- packages/layout-engine/AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 08b4f72446..3d6a3a73de 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -57,6 +57,8 @@ reads. | Change style resolution | `style-engine/` | | Change text measurement | `measuring-dom/` | +AIDEV-NOTE: `pm-adapter` must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports. + ## Style Engine (`style-engine/`) Single source of truth for OOXML style cascade resolution. All property resolution flows through here. From 112234fd42c6c7bfbe4e30608cf8a09bd9fd42ec Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:24:17 -0300 Subject: [PATCH 25/37] 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 ecfc618d17..57deb4165f 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, VectorShapeDrawing, @@ -53,7 +50,6 @@ import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from 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, @@ -77,13 +73,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, @@ -96,12 +86,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'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; @@ -4189,220 +4178,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 6871e02567b79478712fe4a7d0f1ab388db9595e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:31:13 -0300 Subject: [PATCH 26/37] 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 9bfd58f85c..d7d69008f5 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 22d507b03b..abd56c6155 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -111,6 +111,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 // --------------------------------------------------------------------------- @@ -246,21 +270,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') { @@ -476,6 +486,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 b0be9c83d5d5c361bb575b8b03710a72f0888ba9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:35:09 -0300 Subject: [PATCH 27/37] 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 d7d69008f5..bdbc036e01 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 abd56c6155..63a5f2d2b6 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -119,7 +119,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 b512cb9bbcfdeb471141b319d65060075312a287 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 15:49:59 -0300 Subject: [PATCH 28/37] 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 | 182 +++--------------- 3 files changed, 240 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 57deb4165f..5dc1277452 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, @@ -70,7 +68,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'; @@ -90,7 +87,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'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; @@ -2600,120 +2602,21 @@ 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, context.story); - } else { - this.applyFragmentFrame(fragmentEl, fragment, context.section, context.story); - 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: (el, item, imageFragment, section) => + this.applyResolvedFragmentFrame(el, item, imageFragment, section, context.story), + applyFragmentFrame: (el, imageFragment, section) => + this.applyFragmentFrame(el, imageFragment, section, context.story), + applyFragmentWrapperZIndex: this.applyFragmentWrapperZIndex.bind(this), + applySdtDataset, + applyContainerSdtDataset, + buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), + createErrorPlaceholder: this.createErrorPlaceholder.bind(this), + }); } /** @@ -2804,7 +2707,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); @@ -2818,16 +2721,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, @@ -3226,20 +3119,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); @@ -3707,19 +3587,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(); } @@ -3823,7 +3691,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 815af266e1ca74620b10a8d3f9dd401be0c37a10 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:11:44 -0300 Subject: [PATCH 29/37] 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 bdbc036e01..485e126da2 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 63a5f2d2b6..b8b94bd600 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -83,7 +83,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 f823bb214bc26c0ef3ae36fc26204bcdbf8b9d50 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:12:27 -0300 Subject: [PATCH 30/37] 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 e586394e1b171ceb2553dd5cebdd588d6c425a81 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:12:59 -0300 Subject: [PATCH 31/37] 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 9491a08ee4611c1bc8c7660d00ceaacad2cd10bd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:13:48 -0300 Subject: [PATCH 32/37] 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 9d2871f4f360590c5773ae4edd9da383d2bc8d74 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:14:31 -0300 Subject: [PATCH 33/37] 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 5dc1277452..fe761d5050 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -94,6 +94,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'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; @@ -4045,11 +4046,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 6e716917d834f00b6b633500091dcd83921d26ce Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:14:57 -0300 Subject: [PATCH 34/37] 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 61004492afe3170076b470ed05aa6f850235c33f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:15:36 -0300 Subject: [PATCH 35/37] 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 77c811edc800a567b114967b911b1f48768651a2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 17:40:45 -0300 Subject: [PATCH 36/37] 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'; From dabfe329f5899c05d211330b1b8cab2531635c44 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 12:10:23 -0300 Subject: [PATCH 37/37] docs(layout-resolved): clarify deriveBlockVersion no longer duplicated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the comment to reflect that the painter-dom copy has been removed — this is now the single source of truth, not a duplicate to keep in sync. --- .../layout-engine/layout-resolved/src/versionSignature.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index b8b94bd600..bb8cbc5d8d 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -257,9 +257,8 @@ export const resolveFragmentLayoutIdentity = (fragment: Fragment, story?: Layout * 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. * - * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to - * pre-compute block versions without depending on painter-dom. Keep the two copies in sync - * until the painter fully migrates to resolved versions. + * Kept in layout-resolved so the resolved layout stage can pre-compute block + * versions without depending on painter-dom. */ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'paragraph') {