From d8d18800679e21c10c2f407d39696c70f6e51f6b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 16:50:38 -0300 Subject: [PATCH 01/21] 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 fe032d0001..369af59d9a 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 671bbdb2e3..cf07c6bff4 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'; @@ -72,11 +72,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 890679e6da..893f015d78 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -70,7 +70,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 15340458bf..b07553b029 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 5057b2677b..ed417cb4bc 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -12,7 +12,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'; @@ -83,7 +83,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); @@ -384,7 +384,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, @@ -546,7 +546,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 ce05c1081a8df91558e1fe5be0675bb801e8cd7c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 16:56:03 -0300 Subject: [PATCH 02/21] 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 06871d544a64f5288573c6c36a6356538a932f0f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 16:57:55 -0300 Subject: [PATCH 03/21] 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 ed417cb4bc..b51e048fe8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -385,6 +385,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, @@ -547,6 +548,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 6a49fd8fcfec209f731a0a38f7f346083fce5ba4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:02:25 -0300 Subject: [PATCH 04/21] 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 26da8b6ebe..a4fa5c6986 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'; @@ -135,6 +136,99 @@ describe('renderTableFragment', () => { expect(element.dataset.pmEnd).toBe('34'); }); + 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 b51e048fe8..0613b8102f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -12,7 +12,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); @@ -385,7 +391,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, @@ -548,7 +554,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 227b810c50038127c400e283205387bb85bd2645 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:08:08 -0300 Subject: [PATCH 05/21] 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 9fe6701b87bb3636c9c23393ca1d4d8813340f4f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:18:14 -0300 Subject: [PATCH 06/21] 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 0613b8102f..b583555bbe 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -45,6 +45,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, @@ -147,6 +151,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 487972c6623ba94ef24aebce29caae1de25143b2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:25:48 -0300 Subject: [PATCH 07/21] 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 9cf932e3abdb42bbca0acf3496adf4617b449d4f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:31:40 -0300 Subject: [PATCH 08/21] 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 b583555bbe..717df305db 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); @@ -399,8 +402,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, @@ -562,8 +565,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 b8ee607fdebca6ed9120936eb685a63bfa587a07 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:37:52 -0300 Subject: [PATCH 09/21] 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 658eed98a4ad28b37593bd3205e0ff1c88065892 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 14 May 2026 17:45:05 -0300 Subject: [PATCH 10/21] 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 a4521bcff90dd0f68f77c19007277a11bb47d546 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:45:42 -0300 Subject: [PATCH 11/21] 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 be7b62f1ae7328f193c59d746a80f4cbc4485c6c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:46:23 -0300 Subject: [PATCH 12/21] 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 a4fa5c6986..5c1bcdfbeb 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -229,6 +229,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 717df305db..b8b8d9f3c7 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 2df6de16335704e3750548e20994ff2596624527 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:47:57 -0300 Subject: [PATCH 13/21] 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 b8b8d9f3c7..b53b3272e3 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -16,6 +16,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 459c207ef8dea5ac4178cb4fae27ed30a7dfbd7b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 09:56:50 -0300 Subject: [PATCH 14/21] 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 54a12c46412ee60413c88a02b8b6ceb13e3f79fc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:19:19 -0300 Subject: [PATCH 15/21] 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 88b7b7e8ad..df9232099a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -75,6 +75,13 @@ export { export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.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 525329512c..84bec6ed47 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2763,7 +2763,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: [ @@ -2785,10 +2785,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: [ @@ -2810,7 +2810,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 28f63bb75b..bec8b56f74 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -21,12 +21,12 @@ import type { ParagraphBlock, ParagraphMeasure, } 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, fragmentSignature, sourceAnchorSignature } from './versionSignature.js'; @@ -156,17 +156,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 369af59d9a..ed6c50e3e6 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 b53b3272e3..03803b543d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -50,6 +50,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, @@ -154,6 +156,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); @@ -407,8 +414,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, @@ -570,8 +578,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 0e256b7e59752f92e7f200aa612447ae36e38616 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:19:42 -0300 Subject: [PATCH 16/21] 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 c16746ae425189ac4e17981736abab3a990f4641 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:31:28 -0300 Subject: [PATCH 17/21] 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 4cbf3a7f55948ac1d53bf9260d70ca0943456696 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:37:32 -0300 Subject: [PATCH 18/21] 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 ed6c50e3e6..c1a5e91387 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 03803b543d..59c4029e91 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 { getSdtContainerKey, getSdtContainerMetadata, hasExplicitSdtContainerKey, + type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; @@ -50,6 +51,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 */ @@ -156,6 +161,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); @@ -416,6 +429,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, @@ -580,6 +595,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 20b06948c14d8352976291ea5b037303a5126ba7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:41:16 -0300 Subject: [PATCH 19/21] 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 18f376670e817623e5e9922c5bf7b841b9d2e9f9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 10:45:39 -0300 Subject: [PATCH 20/21] 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 1ce01ee06fa7cc3fefdd3b913551f01e3918647f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 15 May 2026 16:06:43 -0300 Subject: [PATCH 21/21] 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 | 384 ++---------------- .../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} | 7 +- .../painters/dom/src/sdt/snapshot.ts | 78 ++++ 6 files changed, 317 insertions(+), 345 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} (87%) 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 893f015d78..8a97c2a890 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, @@ -50,7 +49,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, @@ -70,7 +68,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'; @@ -80,6 +97,11 @@ import type { RunRenderContext } from './runs/types.js'; import { buildImageFilters } from './runs/image-run.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; +export type { + PaintSnapshotStructuredContentBlockEntity, + PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; + type LineEnd = { type?: string; width?: string; @@ -291,22 +313,6 @@ export type PaintSnapshotAnnotationEntity = { type?: string; }; -export type PaintSnapshotStructuredContentBlockEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; -}; - -export type PaintSnapshotStructuredContentInlineEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; -}; - export type PaintSnapshotImageEntity = { element: HTMLElement; pageIndex: number; @@ -510,43 +516,13 @@ 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), - }) 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), - }) as PaintSnapshotStructuredContentInlineEntity, - ); - } + const sdtEntities = collectSdtSnapshotEntitiesFromDomRoot(rootEl, { + resolvePageIndex: resolveSnapshotPageIndex, + readDatasetNumber: readSnapshotDatasetNumber, + compactObject: compactSnapshotObject, + }); + entities.structuredContentBlocks.push(...sdtEntities.structuredContentBlocks); + entities.structuredContentInlines.push(...sdtEntities.structuredContentInlines); const inlineImageElements = Array.from( rootEl.querySelectorAll( @@ -2492,8 +2468,8 @@ export class DomPainter { applyResolvedFragmentFrame: (el, item, paraFragment) => this.applyResolvedFragmentFrame(el, item, paraFragment, context.section), applyFragmentFrame: (el, paraFragment) => this.applyFragmentFrame(el, paraFragment, context.section), - applySdtDataset: this.applySdtDataset.bind(this), - applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + applySdtDataset, + applyContainerSdtDataset, renderLine: ({ block, line, @@ -2582,8 +2558,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) { @@ -3886,8 +3862,8 @@ export class DomPainter { }, renderDrawingContent: renderDrawingContentForTableCell, applyFragmentFrame: applyFragmentFrameWithSection, - applySdtDataset: this.applySdtDataset.bind(this), - applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + applySdtDataset, + applyContainerSdtDataset, applyStyles, }); @@ -3945,23 +3921,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; } /** @@ -4143,263 +4120,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. - */ - 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 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': @@ -4442,24 +4164,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 87% rename from packages/layout-engine/painters/dom/src/runs/sdt.ts rename to packages/layout-engine/painters/dom/src/sdt/inline.ts index 4db629b8fb..1da85c170f 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; @@ -25,8 +25,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..2eb000f2dd --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/snapshot.ts @@ -0,0 +1,78 @@ +import { DOM_CLASS_NAMES } from '../constants.js'; + +export type PaintSnapshotStructuredContentBlockEntity = { + element: HTMLElement; + pageIndex: number; + sdtId: string; + pmStart?: number; + pmEnd?: number; +}; + +export type PaintSnapshotStructuredContentInlineEntity = { + element: HTMLElement; + pageIndex: number; + sdtId: string; + pmStart?: number; + pmEnd?: number; +}; + +export type SdtSnapshotEntities = { + structuredContentBlocks: PaintSnapshotStructuredContentBlockEntity[]; + structuredContentInlines: PaintSnapshotStructuredContentInlineEntity[]; +}; + +type CollectSdtSnapshotEntitiesOptions = { + resolvePageIndex: (element: HTMLElement) => number | null; + readDatasetNumber: (value: string | null | undefined) => number | null; + 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), + }) 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), + }) as PaintSnapshotStructuredContentInlineEntity, + ); + } + + return entities; +};