From ab66e7dbc88695cba98b6bfc48e376d3a9d38ca8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 17:49:19 -0300 Subject: [PATCH 01/35] refactor(painter-dom): share paragraph border grouping context --- .../src/paragraph/borders/group-analysis.ts | 186 +++++++++--------- .../dom/src/paragraph/borders/index.ts | 4 +- 2 files changed, 100 insertions(+), 90 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts index e6186becf0..10e50634ca 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts @@ -8,7 +8,7 @@ * @ooxml w:pPr/w:pBdr/w:between — between border for grouped paragraphs * @spec ECMA-376 §17.3.1.24 (pBdr) */ -import type { ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; +import type { ParagraphBorders, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; /** @@ -33,108 +33,48 @@ const isBetweenBorderNone = (borders: ResolvedFragmentItem['paragraphBorders']): return borders.between.style === 'none'; }; -/** - * Helper: check whether a resolved item is a ResolvedFragmentItem with - * pre-computed paragraph border data. - */ -function isResolvedFragmentWithBorders( - item: ResolvedPaintItem | undefined, -): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { - return ( - item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined - ); -} +export type ParagraphBorderGroupEntry = { + blockId: string; + x: number; + y: number; + height: number; + borders?: ParagraphBorders; + borderHash?: string; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; +}; -/** - * Pre-computes per-fragment between-border rendering info for a page. - * - * Two fragments (i, i+1) form a border group pair when: - * 1. Both are para fragments (not table/image/drawing) - * 2. Neither is a page-split continuation - * 3. They represent different logical paragraphs - * 4. Both have border definitions - * 5. Their full border definitions match (same border group) - * - * Per ECMA-376 §17.3.1.5: grouping occurs when all border properties are - * identical. A `between` border is NOT required — when absent, the group - * is rendered as a single box without a separator line. - * - * For each pair, the first fragment gets: - * - showBetweenBorder: true — bottom border replaced with between definition - * - gapBelow: px distance to extend border layer into spacing gap - * - * The second fragment gets: - * - suppressTopBorder: true — the previous fragment's extension covers the boundary - * - * Middle fragments in a chain of 3+ get both flags. - */ -export const computeBetweenBorderFlags = ( - resolvedItems: readonly ResolvedPaintItem[], +export const computeBetweenBorderContext = ( + entries: readonly ParagraphBorderGroupEntry[], ): Map => { - // Phase 1: determine which consecutive pairs form between-border groups const pairFlags = new Set(); const noBetweenPairs = new Set(); - for (let i = 0; i < resolvedItems.length - 1; i += 1) { - const resolvedCur = resolvedItems[i]; - if (resolvedCur.kind !== 'fragment') continue; - const frag = resolvedCur.fragment; - if (frag.kind !== 'para') continue; - if (frag.continuesOnNext) continue; - - if (!isResolvedFragmentWithBorders(resolvedCur)) continue; - const borders = resolvedCur.paragraphBorders; - - const resolvedNext = resolvedItems[i + 1]; - if (resolvedNext.kind !== 'fragment') continue; - const next = resolvedNext.fragment; - if (next.kind !== 'para') continue; - if (next.continuesFromPrev) continue; - if (next.blockId === frag.blockId) continue; - - if (!isResolvedFragmentWithBorders(resolvedNext)) continue; - const nextBorders = resolvedNext.paragraphBorders; - - // Compare using pre-computed hashes when available, falling back to computing on-the-fly. - const curHash = - 'paragraphBorderHash' in resolvedCur && (resolvedCur as ResolvedFragmentItem).paragraphBorderHash - ? (resolvedCur as ResolvedFragmentItem).paragraphBorderHash! - : hashParagraphBorders(borders); - const nextHash = - 'paragraphBorderHash' in resolvedNext && (resolvedNext as ResolvedFragmentItem).paragraphBorderHash - ? (resolvedNext as ResolvedFragmentItem).paragraphBorderHash! - : hashParagraphBorders(nextBorders); - if (curHash !== nextHash) continue; - - // Skip fragments in different columns (different x positions) - if (frag.x !== next.x) continue; + for (let i = 0; i < entries.length - 1; i += 1) { + const current = entries[i]; + const next = entries[i + 1]; + if (current.continuesOnNext || next.continuesFromPrev || current.blockId === next.blockId) continue; + if (!current.borders || !next.borders) continue; - pairFlags.add(i); + const currentHash = current.borderHash ?? hashParagraphBorders(current.borders); + const nextHash = next.borderHash ?? hashParagraphBorders(next.borders); + if (currentHash !== nextHash) continue; + if (current.x !== next.x) continue; - // Track nil/none/absent between pairs — these get suppressBottomBorder instead of showBetweenBorder. - // Per ECMA-376 §17.3.1.5: grouping happens when ALL borders are identical. - // When no between border is defined, the group has no separator line. - if (isBetweenBorderNone(borders) && isBetweenBorderNone(nextBorders)) { + pairFlags.add(i); + if (isBetweenBorderNone(current.borders) && isBetweenBorderNone(next.borders)) { noBetweenPairs.add(i); } } - // Phase 2: build per-fragment info with gap distances and top suppression const result = new Map(); for (const i of pairFlags) { - const resolvedCur = resolvedItems[i]; - const resolvedNext = resolvedItems[i + 1]; - if (resolvedCur.kind !== 'fragment' || resolvedNext.kind !== 'fragment') continue; - const frag = resolvedCur.fragment; - const next = resolvedNext.fragment; - const fragHeight = 'height' in resolvedCur && resolvedCur.height != null ? resolvedCur.height : 0; - const gapBelow = Math.max(0, next.y - (frag.y + fragHeight)); + const current = entries[i]; + const next = entries[i + 1]; + const gapBelow = Math.max(0, next.y - (current.y + current.height)); const isNoBetween = noBetweenPairs.has(i); - // Current fragment: extend into gap. - // Real between → showBetweenBorder (replace bottom with between definition). - // Nil/none between → suppressBottomBorder (hide bottom, keep left/right continuous). if (!result.has(i)) { result.set(i, { showBetweenBorder: !isNoBetween, @@ -149,7 +89,6 @@ export const computeBetweenBorderFlags = ( existing.gapBelow = gapBelow; } - // Next fragment: suppress top border (previous fragment's extended layer covers boundary) if (!result.has(i + 1)) { result.set(i + 1, { showBetweenBorder: false, @@ -164,3 +103,74 @@ export const computeBetweenBorderFlags = ( return result; }; + +/** + * Helper: check whether a resolved item is a ResolvedFragmentItem with + * pre-computed paragraph border data. + */ +function isResolvedFragmentWithBorders( + item: ResolvedPaintItem | undefined, +): item is ResolvedFragmentItem & { paragraphBorders: NonNullable } { + return ( + item !== undefined && item.kind === 'fragment' && 'paragraphBorders' in item && item.paragraphBorders !== undefined + ); +} + +/** + * Pre-computes per-fragment between-border rendering info for a page. + * + * Two fragments (i, i+1) form a border group pair when: + * 1. Both are para fragments (not table/image/drawing) + * 2. Neither is a page-split continuation + * 3. They represent different logical paragraphs + * 4. Both have border definitions + * 5. Their full border definitions match (same border group) + * + * Per ECMA-376 §17.3.1.5: grouping occurs when all border properties are + * identical. A `between` border is NOT required — when absent, the group + * is rendered as a single box without a separator line. + * + * For each pair, the first fragment gets: + * - showBetweenBorder: true — bottom border replaced with between definition + * - gapBelow: px distance to extend border layer into spacing gap + * + * The second fragment gets: + * - suppressTopBorder: true — the previous fragment's extension covers the boundary + * + * Middle fragments in a chain of 3+ get both flags. + */ +export const computeBetweenBorderFlags = ( + resolvedItems: readonly ResolvedPaintItem[], +): Map => { + const entries = resolvedItems.map((item, index): ParagraphBorderGroupEntry => { + const fallbackEntry = { + blockId: `item:${index}`, + x: 0, + y: 0, + height: 0, + }; + if (item.kind !== 'fragment') return fallbackEntry; + const fragment = item.fragment; + if (fragment.kind !== 'para' || !isResolvedFragmentWithBorders(item)) { + return { + ...fallbackEntry, + blockId: fragment.blockId, + x: 'x' in fragment ? fragment.x : 0, + y: 'y' in fragment ? fragment.y : 0, + }; + } + + return { + blockId: fragment.blockId, + x: fragment.x, + y: fragment.y, + height: 'height' in item && item.height != null ? item.height : 0, + borders: item.paragraphBorders, + borderHash: item.paragraphBorderHash, + continuesFromPrev: fragment.continuesFromPrev, + continuesOnNext: fragment.continuesOnNext, + }; + }); + + return computeBetweenBorderContext(entries); +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts index 79084b6abe..51364d9dd0 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts @@ -15,8 +15,8 @@ */ // Group analysis -export { computeBetweenBorderFlags } from './group-analysis.js'; -export type { BetweenBorderInfo } from './group-analysis.js'; +export { computeBetweenBorderContext, computeBetweenBorderFlags } from './group-analysis.js'; +export type { BetweenBorderInfo, ParagraphBorderGroupEntry } from './group-analysis.js'; // DOM layers and CSS export { From 6340cb8e43b4d451440178fc9c274bd6f3602b6e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 17:49:23 -0300 Subject: [PATCH 02/35] refactor(painter-dom): share marker line painting --- .../painters/dom/src/paragraph/list-marker.ts | 130 +++++++++++------- 1 file changed, 80 insertions(+), 50 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts index 221550e8e8..d73603eea0 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -78,6 +78,9 @@ type MarkerRunStyle = { const isMarkerSuffix = (suffix: unknown): suffix is 'tab' | 'space' | 'nothing' => suffix === 'tab' || suffix === 'space' || suffix === 'nothing'; +const isMarkerJustification = (value: unknown): value is 'left' | 'center' | 'right' => + value === 'left' || value === 'center' || value === 'right'; + export const createListMarkerElement = ( doc: Document, markerText: string, @@ -159,7 +162,7 @@ export const renderLegacyListMarker = (params: { : undefined; const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; - const markerJustification = markerLayout?.justification ?? 'left'; + const markerJustification = isMarkerJustification(markerLayout?.justification) ? markerLayout.justification : 'left'; let markerStartPos: number; let currentPos: number; if (markerJustification === 'left') { @@ -190,43 +193,21 @@ export const renderLegacyListMarker = (params: { suffixWidthPx = 4; } - if (isRtl) { - lineEl.style.paddingRight = `${anchorPoint}px`; - } else { - lineEl.style.paddingLeft = `${anchorPoint}px`; - } - - if ((markerLayout?.run as MarkerRunStyle | undefined)?.vanish) { - return; - } - - const markerContainer = createListMarkerElement( + renderListMarkerFrame({ doc, - markerLayout?.markerText ?? '', - markerLayout?.run ?? {}, + lineEl, + markerText: markerLayout?.markerText ?? '', + run: markerLayout?.run ?? {}, sourceAnchor, - ); - markerContainer.style.position = 'relative'; - if (markerJustification === 'right') { - markerContainer.style.position = 'absolute'; - if (isRtl) { - markerContainer.style.right = `${markerStartPos}px`; - } else { - markerContainer.style.left = `${markerStartPos}px`; - } - } else if (markerJustification === 'center') { - markerContainer.style.position = 'absolute'; - if (isRtl) { - markerContainer.style.right = `${markerStartPos - markerTextWidth / 2}px`; - lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + markerTextWidth / 2}px`; - } else { - markerContainer.style.left = `${markerStartPos - markerTextWidth / 2}px`; - lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + markerTextWidth / 2}px`; - } - } - - prependMarkerSuffix(doc, lineEl, isMarkerSuffix(suffix) ? suffix : undefined, suffixWidthPx, markerLayout?.run?.fontSize); - lineEl.prepend(markerContainer); + firstLinePaddingPx: anchorPoint, + markerStartPx: markerJustification === 'center' ? markerStartPos - markerTextWidth / 2 : markerStartPos, + justification: markerJustification, + centerPaddingAdjustPx: markerJustification === 'center' ? markerTextWidth / 2 : 0, + suffix: isMarkerSuffix(suffix) ? suffix : undefined, + suffixWidthPx, + isRtl, + vanish: (markerLayout?.run as MarkerRunStyle | undefined)?.vanish, + }); }; export const renderResolvedListMarker = (params: { @@ -237,38 +218,87 @@ export const renderResolvedListMarker = (params: { sourceAnchor?: SourceAnchor; }): void => { const { doc, lineEl, marker, isRtl, sourceAnchor } = params; + renderListMarkerFrame({ + doc, + lineEl, + markerText: marker.text, + run: marker.run, + sourceAnchor: marker.sourceAnchor ?? sourceAnchor, + firstLinePaddingPx: marker.firstLinePaddingLeftPx, + markerStartPx: + marker.justification === 'center' + ? marker.markerStartPx - (marker.centerPaddingAdjustPx ?? 0) + : marker.markerStartPx, + justification: marker.justification, + centerPaddingAdjustPx: marker.justification === 'center' ? (marker.centerPaddingAdjustPx ?? 0) : 0, + suffix: marker.suffix, + suffixWidthPx: marker.suffixWidthPx, + isRtl, + vanish: marker.vanish, + }); +}; + +const renderListMarkerFrame = (params: { + doc: Document; + lineEl: HTMLElement; + markerText: string; + run: MarkerRunStyle; + sourceAnchor?: SourceAnchor; + firstLinePaddingPx: number; + markerStartPx: number; + justification: 'left' | 'center' | 'right'; + centerPaddingAdjustPx: number; + suffix: 'tab' | 'space' | 'nothing' | undefined; + suffixWidthPx: number; + isRtl?: boolean; + vanish?: boolean | null; +}): void => { + const { + doc, + lineEl, + markerText, + run, + sourceAnchor, + firstLinePaddingPx, + markerStartPx, + justification, + centerPaddingAdjustPx, + suffix, + suffixWidthPx, + isRtl, + vanish, + } = params; if (isRtl) { - lineEl.style.paddingRight = `${marker.firstLinePaddingLeftPx}px`; + lineEl.style.paddingRight = `${firstLinePaddingPx}px`; } else { - lineEl.style.paddingLeft = `${marker.firstLinePaddingLeftPx}px`; + lineEl.style.paddingLeft = `${firstLinePaddingPx}px`; } - if (marker.vanish) { + if (vanish) { return; } - const markerContainer = createListMarkerElement(doc, marker.text, marker.run, marker.sourceAnchor ?? sourceAnchor); + const markerContainer = createListMarkerElement(doc, markerText, run, sourceAnchor); markerContainer.style.position = 'relative'; - if (marker.justification === 'right') { + if (justification === 'right') { markerContainer.style.position = 'absolute'; if (isRtl) { - markerContainer.style.right = `${marker.markerStartPx}px`; + markerContainer.style.right = `${markerStartPx}px`; } else { - markerContainer.style.left = `${marker.markerStartPx}px`; + markerContainer.style.left = `${markerStartPx}px`; } - } else if (marker.justification === 'center') { + } else if (justification === 'center') { markerContainer.style.position = 'absolute'; - const paddingAdjust = marker.centerPaddingAdjustPx ?? 0; if (isRtl) { - markerContainer.style.right = `${marker.markerStartPx - paddingAdjust}px`; - lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + paddingAdjust}px`; + markerContainer.style.right = `${markerStartPx}px`; + lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + centerPaddingAdjustPx}px`; } else { - markerContainer.style.left = `${marker.markerStartPx - paddingAdjust}px`; - lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + paddingAdjust}px`; + markerContainer.style.left = `${markerStartPx}px`; + lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + centerPaddingAdjustPx}px`; } } - prependMarkerSuffix(doc, lineEl, marker.suffix, marker.suffixWidthPx, marker.run.fontSize); + prependMarkerSuffix(doc, lineEl, suffix, suffixWidthPx, run.fontSize ?? undefined); lineEl.prepend(markerContainer); }; From ef8b045776adb7f107a56a5d6a14e6ee29f44c26 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 17:49:27 -0300 Subject: [PATCH 03/35] refactor(painter-dom): share drawing frame rendering --- .../painters/dom/src/drawings/drawingFrame.ts | 104 ++++++++++++++++++ .../dom/src/drawings/tableDrawingFrame.ts | 57 +++------- 2 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts diff --git a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts new file mode 100644 index 0000000000..afd286e4f4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts @@ -0,0 +1,104 @@ +import type { DrawingBlock, DrawingGeometry, SdtMetadata } from '@superdoc/contracts'; +import { createDrawingPlaceholder } from './placeholder.js'; + +export type RenderDrawingContentForPlacement = ( + block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, +) => HTMLElement; + +export type DrawingFramePlacement = + | { mode: 'body'; left?: never; top?: never; zIndex?: never; flexShrink?: never } + | { mode: 'flowing-table-cell'; flexShrink?: string; left?: never; top?: never; zIndex?: never } + | { mode: 'anchored-table-cell'; left: number; top: number; zIndex?: number; flexShrink?: never }; + +export type RenderDrawingFrameParams = { + doc: Document; + block: DrawingBlock; + width: number; + height: number; + placement: DrawingFramePlacement; + className: string; + geometry?: DrawingGeometry; + scale?: number; + suppressTransforms?: boolean; + renderDrawingContent?: RenderDrawingContentForPlacement; + applySdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; +}; + +const applyBodyDrawingTransform = ( + target: HTMLElement, + geometry: DrawingGeometry | undefined, + scale: number | undefined, +): void => { + if (!geometry) return; + const transforms: string[] = ['translate(-50%, -50%)']; + transforms.push(`rotate(${geometry.rotation ?? 0}deg)`); + transforms.push(`scaleX(${geometry.flipH ? -1 : 1})`); + transforms.push(`scaleY(${geometry.flipV ? -1 : 1})`); + transforms.push(`scale(${scale ?? 1})`); + target.style.transform = transforms.join(' '); +}; + +export const renderDrawingFrame = ({ + doc, + block, + width, + height, + placement, + className, + geometry, + scale, + suppressTransforms, + renderDrawingContent, + applySdtDataset, +}: RenderDrawingFrameParams): HTMLElement => { + const wrapper = doc.createElement('div'); + wrapper.style.position = + placement.mode === 'anchored-table-cell' || placement.mode === 'body' ? 'absolute' : 'relative'; + wrapper.style.width = `${width}px`; + wrapper.style.height = `${height}px`; + wrapper.style.boxSizing = 'border-box'; + wrapper.style.overflow = 'hidden'; + if (placement.mode === 'anchored-table-cell') { + wrapper.style.left = `${placement.left}px`; + wrapper.style.top = `${placement.top}px`; + if (placement.zIndex != null) { + wrapper.style.zIndex = String(placement.zIndex); + } + } else if (placement.mode === 'flowing-table-cell') { + wrapper.style.maxWidth = '100%'; + if (placement.flexShrink != null) { + wrapper.style.flexShrink = placement.flexShrink; + } + } + applySdtDataset?.(wrapper, block.attrs?.sdt as SdtMetadata | undefined); + + const inner = doc.createElement('div'); + inner.classList.add(className); + inner.style.width = '100%'; + inner.style.height = '100%'; + if (placement.mode === 'body') { + inner.style.position = 'absolute'; + inner.style.left = '50%'; + inner.style.top = '50%'; + inner.style.width = `${width}px`; + inner.style.height = `${height}px`; + inner.style.transformOrigin = 'center'; + if (!suppressTransforms) { + applyBodyDrawingTransform(inner, geometry, scale); + } + } else { + inner.style.display = 'flex'; + inner.style.alignItems = 'center'; + inner.style.justifyContent = 'center'; + } + inner.style.overflow = 'hidden'; + + const drawingContent = renderDrawingContent?.(block, { clipContainer: inner }) ?? createDrawingPlaceholder(doc); + drawingContent.style.width = '100%'; + drawingContent.style.height = '100%'; + inner.appendChild(drawingContent); + wrapper.appendChild(inner); + + return wrapper; +}; diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index 288d0bd929..a60f56ec26 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -1,5 +1,5 @@ import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; -import { createDrawingPlaceholder } from './placeholder.js'; +import { renderDrawingFrame, type RenderDrawingContentForPlacement } from './drawingFrame.js'; export type RenderTableDrawingFrameParams = { doc: Document; @@ -11,7 +11,7 @@ export type RenderTableDrawingFrameParams = { top?: number; zIndex?: number; flexShrink?: string; - renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; + renderDrawingContent?: RenderDrawingContentForPlacement; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; }; @@ -28,41 +28,20 @@ export const renderTableDrawingFrame = ({ renderDrawingContent, applySdtDataset, }: RenderTableDrawingFrameParams): HTMLElement => { - const drawingWrapper = doc.createElement('div'); - drawingWrapper.style.position = position; - if (left != null) { - drawingWrapper.style.left = `${left}px`; - } - if (top != null) { - drawingWrapper.style.top = `${top}px`; - } - drawingWrapper.style.width = `${width}px`; - drawingWrapper.style.height = `${height}px`; - if (flexShrink != null) { - drawingWrapper.style.flexShrink = flexShrink; - } - drawingWrapper.style.maxWidth = '100%'; - drawingWrapper.style.boxSizing = 'border-box'; - if (zIndex != null) { - drawingWrapper.style.zIndex = String(zIndex); - } - applySdtDataset(drawingWrapper, block.attrs?.sdt as SdtMetadata | undefined); - - const drawingInner = doc.createElement('div'); - drawingInner.classList.add('superdoc-table-drawing'); - drawingInner.style.width = '100%'; - drawingInner.style.height = '100%'; - drawingInner.style.display = 'flex'; - drawingInner.style.alignItems = 'center'; - drawingInner.style.justifyContent = 'center'; - drawingInner.style.overflow = 'hidden'; - - const drawingContent = - renderDrawingContent?.(block, { clipContainer: drawingInner }) ?? createDrawingPlaceholder(doc); - drawingContent.style.width = '100%'; - drawingContent.style.height = '100%'; - drawingInner.appendChild(drawingContent); - - drawingWrapper.appendChild(drawingInner); - return drawingWrapper; + return renderDrawingFrame({ + doc, + block, + width, + height, + placement: + position === 'absolute' + ? { mode: 'anchored-table-cell', left: left ?? 0, top: top ?? 0, zIndex } + : { mode: 'flowing-table-cell', flexShrink }, + className: 'superdoc-table-drawing', + suppressTransforms: true, + renderDrawingContent, + applySdtDataset, + }); }; + +export type { RenderDrawingContentForPlacement }; From cb6ce7c1463b431765784b10d2a69a191da4ca9c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 17:49:40 -0300 Subject: [PATCH 04/35] refactor(painter-dom): isolate table cell paragraph and image frames --- .../dom/src/images/table-image-frame.ts | 106 ++++++ .../dom/src/table/renderTableCell.test.ts | 162 +++++++++ .../painters/dom/src/table/renderTableCell.ts | 320 ++++++++++++------ .../dom/ImageInteractionLayer.test.ts | 26 ++ 4 files changed, 505 insertions(+), 109 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/images/table-image-frame.ts diff --git a/packages/layout-engine/painters/dom/src/images/table-image-frame.ts b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts new file mode 100644 index 0000000000..1671453a6c --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts @@ -0,0 +1,106 @@ +import type { ImageBlock, ImageFragmentMetadata, ImageMeasure, SdtMetadata } from '@superdoc/contracts'; +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { createBlockImageContent } from './image-block.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +type TableImagePlacement = + | { mode: 'flowing' } + | { + mode: 'anchored'; + left: number; + top: number; + zIndex?: number; + }; + +export type RenderTableImageFrameParams = { + doc: Document; + block: ImageBlock; + measure: ImageMeasure; + placement: TableImagePlacement; + contentMaxWidth: number; + contentMaxHeight: number; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; +}; + +const readFiniteNumber = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) ? value : undefined; + +const readPmRange = (block: ImageBlock): { pmStart?: number; pmEnd?: number } => ({ + pmStart: readFiniteNumber(block.attrs?.pmStart), + pmEnd: readFiniteNumber(block.attrs?.pmEnd), +}); + +const buildTableImageMetadata = ( + block: ImageBlock, + measure: ImageMeasure, + maxWidth: number, + maxHeight: number, +): ImageFragmentMetadata => { + const originalWidth = readFiniteNumber(block.width) ?? measure.width; + const originalHeight = readFiniteNumber(block.height) ?? measure.height; + const aspectRatio = originalWidth > 0 && originalHeight > 0 ? originalWidth / originalHeight : 1; + const minWidth = 20; + return { + originalWidth, + originalHeight, + maxWidth, + maxHeight, + aspectRatio, + minWidth, + minHeight: minWidth / aspectRatio, + }; +}; + +export const renderTableImageFrame = ({ + doc, + block, + measure, + placement, + contentMaxWidth, + contentMaxHeight, + applySdtDataset, + buildImageHyperlinkAnchor, +}: RenderTableImageFrameParams): HTMLElement => { + const wrapper = doc.createElement('div'); + wrapper.classList.add(DOM_CLASS_NAMES.IMAGE_FRAGMENT); + wrapper.style.position = placement.mode === 'anchored' ? 'absolute' : 'relative'; + wrapper.style.width = `${measure.width}px`; + wrapper.style.height = `${measure.height}px`; + wrapper.style.maxWidth = '100%'; + wrapper.style.boxSizing = 'border-box'; + if (placement.mode === 'flowing') { + wrapper.style.flexShrink = '0'; + } else { + wrapper.style.left = `${placement.left}px`; + wrapper.style.top = `${placement.top}px`; + if (placement.zIndex != null) { + wrapper.style.zIndex = String(placement.zIndex); + } + } + + wrapper.setAttribute('data-sd-block-id', block.id); + const pmRange = readPmRange(block); + if (pmRange.pmStart != null) wrapper.dataset.pmStart = String(pmRange.pmStart); + if (pmRange.pmEnd != null) wrapper.dataset.pmEnd = String(pmRange.pmEnd); + if (!block.attrs?.vmlWatermark) { + wrapper.setAttribute( + 'data-image-metadata', + JSON.stringify(buildTableImageMetadata(block, measure, contentMaxWidth, contentMaxHeight)), + ); + } + applySdtDataset(wrapper, block.attrs?.sdt); + + wrapper.appendChild( + createBlockImageContent({ + doc, + block, + className: 'superdoc-table-image', + clipContainer: wrapper, + imageDisplay: 'block', + buildImageHyperlinkAnchor, + }), + ); + + return wrapper; +}; 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 4d7f5bbf6c..9f4848d6e9 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -241,6 +241,43 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.height).toBe('40px'); }); + it('stamps interaction metadata on flowing image wrappers inside table cells', () => { + const imageBlock: ImageBlock = { + kind: 'image', + id: 'img-flowing-interactive', + src: 'data:image/png;base64,AAA', + width: 100, + height: 80, + alt: 'Flowing table image', + attrs: { pmStart: 10, pmEnd: 11, sdt: { id: 'sdt-image', type: 'structuredContent' } as SdtMetadata }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + applySdtDataset: (el, metadata) => { + if (el && metadata?.id) el.dataset.sdtId = metadata.id; + }, + cellMeasure: { + blocks: [{ kind: 'image' as const, width: 50, height: 40 }], + width: 80, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-flowing-interactive-image', blocks: [imageBlock], attrs: {} }, + }); + + const wrapper = cellElement.querySelector('.superdoc-image-fragment') as HTMLElement | null; + expect(wrapper).toBeTruthy(); + expect(wrapper?.dataset.pmStart).toBe('10'); + expect(wrapper?.dataset.pmEnd).toBe('11'); + expect(wrapper?.getAttribute('data-sd-block-id')).toBe('img-flowing-interactive'); + expect(wrapper?.getAttribute('data-image-metadata')).toContain('"originalWidth":100'); + expect(wrapper?.dataset.sdtId).toBe('sdt-image'); + expect(wrapper?.querySelector('img.superdoc-table-image')?.getAttribute('alt')).toBe('Flowing table image'); + }); + it('forces flowing image blocks to block display inside table cells', () => { const imageBlock: ImageBlock = { kind: 'image', @@ -397,6 +434,47 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.top).toBe('5px'); }); + it('stamps interaction metadata on anchored image wrappers inside table cells', () => { + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-anchor-interactive', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 16 }], + }; + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'img-anchored-interactive', + src: 'data:image/png;base64,AAA', + width: 20, + height: 10, + alt: 'Anchored table image', + anchor: { isAnchored: true, alignH: 'left', offsetH: 10, vRelativeFrom: 'paragraph', offsetV: 5 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-anchor-interactive', pmStart: 30, pmEnd: 31 }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 10 }], + width: 80, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-anchored-interactive-image', blocks: [para, anchoredImage], attrs: {} }, + }); + + const wrapper = cellElement.querySelector('.superdoc-image-fragment') as HTMLElement | null; + expect(wrapper).toBeTruthy(); + expect(wrapper?.style.position).toBe('absolute'); + expect(wrapper?.style.left).toBe('10px'); + expect(wrapper?.style.top).toBe('5px'); + expect(wrapper?.dataset.pmStart).toBe('30'); + expect(wrapper?.dataset.pmEnd).toBe('31'); + expect(wrapper?.getAttribute('data-image-metadata')).toContain('"originalWidth":20'); + }); + it('applies top-level clipPath to anchored image blocks inside table cells', () => { const para: ParagraphBlock = { kind: 'paragraph', @@ -2501,6 +2579,90 @@ describe('renderTableCell', () => { expectCssColor(borderLayer2.style.borderTopColor, '#0000ff'); }); + it('applies between-border context to consecutive table-cell paragraphs', () => { + const borders = { + top: { width: 1, style: 'solid' as const, color: '#111111' }, + bottom: { width: 1, style: 'solid' as const, color: '#111111' }, + between: { width: 2, style: 'dashed' as const, color: '#222222' }, + }; + const para1: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-1', + runs: [{ text: 'First', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders }, + }; + const para2: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-2', + runs: [{ text: 'Second', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders: { ...borders } }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, paragraphMeasure], + width: 120, + height: 60, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-between-borders', blocks: [para1, para2], attrs: {} }, + }); + + const wrappers = Array.from(cellElement.querySelectorAll(':scope > div > div')); + expect(wrappers[0]?.dataset.betweenBorder).toBe('true'); + expect(wrappers[1]?.dataset.suppressTopBorder).toBe('true'); + const firstBorder = getParagraphBorderLayer(wrappers[0]!); + expect(firstBorder.style.borderBottomStyle).toBe('dashed'); + expect(firstBorder.style.borderBottomWidth).toBe('2px'); + }); + + it('breaks table-cell between-border groups when between borders differ', () => { + const para1: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-break-1', + runs: [{ text: 'First', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + borders: { + top: { width: 1, style: 'solid', color: '#111111' }, + bottom: { width: 1, style: 'solid', color: '#111111' }, + between: { width: 1, style: 'none', color: '#111111' }, + }, + }, + }; + const para2: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-break-2', + runs: [{ text: 'Second', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + borders: { + top: { width: 1, style: 'solid', color: '#111111' }, + bottom: { width: 1, style: 'solid', color: '#111111' }, + between: { width: 1, style: 'dashed', color: '#111111' }, + }, + }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, paragraphMeasure], + width: 120, + height: 60, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-between-break', blocks: [para1, para2], attrs: {} }, + }); + + const wrappers = Array.from(cellElement.querySelectorAll(':scope > div > div')); + expect(wrappers[0]?.dataset.betweenBorder).toBeUndefined(); + expect(wrappers[1]?.dataset.suppressTopBorder).toBeUndefined(); + }); + it('should not apply borders when paragraph has no borders attribute', () => { 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 e17c14b379..93f4f39933 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -7,6 +7,7 @@ import type { ImageHyperlink, ImageMeasure, Line, + Measure, ParagraphBlock, ParagraphMeasure, PartialRowInfo, @@ -20,7 +21,7 @@ import { getCellLines, normalizeZIndex } from '@superdoc/contracts'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; -import { createBlockImageContent } from '../images/image-block.js'; +import { renderTableImageFrame } from '../images/table-image-frame.js'; import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; import { getSdtContainerKeyForBlock, @@ -31,6 +32,8 @@ import { import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; +import { computeBetweenBorderContext, type BetweenBorderInfo } from '../paragraph/borders/index.js'; +import { hashParagraphBorders } from '../paragraph-hash-utils.js'; import { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; import { @@ -421,6 +424,148 @@ export type TableCellRenderResult = { cellElement: HTMLElement; }; +type TableCellParagraphRenderParams = { + doc: Document; + content: HTMLElement; + cellEl: HTMLElement; + block: ParagraphBlock; + paragraphMeasure: ParagraphMeasure; + blockIndex: number; + blockCount: number; + cumulativeLineCount: number; + globalFromLine: number; + globalToLine: number; + contentWidthPx: number; + paddingTop: number; + flowCursorY: number; + sdtBoundary?: SdtBoundaryOptions; + betweenInfo?: BetweenBorderInfo; + context: FragmentRenderContext; + renderLine: TableCellRenderDependencies['renderLine']; + applySdtDataset: TableCellRenderDependencies['applySdtDataset']; + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + onSdtContainerChrome?: () => void; +}; + +type TableCellParagraphRenderResult = { + nextCumulativeLineCount: number; + renderedHeight: number; + renderedLines: RenderedLineInfo[]; +}; + +const getMeasuredBlockHeight = (measure: Measure | undefined): number => { + if (!measure) return 0; + if (measure.kind === 'paragraph') { + return ( + (measure as ParagraphMeasure).totalHeight ?? + ((measure as ParagraphMeasure).lines ?? []).reduce((sum, line) => sum + line.lineHeight, 0) + ); + } + return 'height' in measure && typeof measure.height === 'number' ? measure.height : 0; +}; + +const sliceSdtBoundaryForParagraph = ( + baseBoundary: SdtBoundaryOptions | undefined, + localStartLine: number, + localEndLine: number, + blockLineCount: number, +): SdtBoundaryOptions | undefined => + baseBoundary + ? { + ...baseBoundary, + isStart: (baseBoundary.isStart ?? true) && localStartLine === 0, + isEnd: (baseBoundary.isEnd ?? true) && localEndLine >= blockLineCount, + showLabel: baseBoundary.showLabel === undefined ? undefined : baseBoundary.showLabel && localStartLine === 0, + } + : undefined; + +const renderTableCellParagraphBlock = ({ + doc, + content, + cellEl, + block, + paragraphMeasure, + blockIndex, + blockCount, + cumulativeLineCount, + globalFromLine, + globalToLine, + contentWidthPx, + paddingTop, + flowCursorY, + sdtBoundary, + betweenInfo, + context, + renderLine, + applySdtDataset, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, +}: TableCellParagraphRenderParams): TableCellParagraphRenderResult => { + const lines = paragraphMeasure.lines; + const blockLineCount = lines?.length || 0; + const blockStartGlobal = cumulativeLineCount; + const blockEndGlobal = cumulativeLineCount + blockLineCount; + const nextCumulativeLineCount = blockEndGlobal; + + if (blockEndGlobal <= globalFromLine || blockStartGlobal >= globalToLine) { + return { nextCumulativeLineCount, renderedHeight: 0, renderedLines: [] }; + } + + const localStartLine = Math.max(0, globalFromLine - blockStartGlobal); + const localEndLine = Math.min(blockLineCount, globalToLine - blockStartGlobal); + const paraWrapper = doc.createElement('div'); + paraWrapper.style.position = 'relative'; + paraWrapper.style.left = '0'; + paraWrapper.style.width = '100%'; + content.appendChild(paraWrapper); + + const wordLayout = (block.attrs?.wordLayout ?? null) as MinimalWordLayout | null; + const isLastBlockInCell = blockIndex === blockCount - 1; + const result = renderParagraphContent({ + doc, + frameEl: paraWrapper, + block, + measure: paragraphMeasure, + containerKind: 'table-cell', + width: contentWidthPx, + localStartLine, + localEndLine, + wordLayout: wordLayout ?? undefined, + spacingPolicy: { + isFirstBlock: blockIndex === 0, + isLastBlock: isLastBlockInCell, + paddingTop, + }, + betweenInfo, + sdtBoundary: sliceSdtBoundaryForParagraph(sdtBoundary, localStartLine, localEndLine, blockLineCount), + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome: () => { + cellEl.style.overflow = 'visible'; + onSdtContainerChrome?.(); + }, + applySdtDataset, + renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => + renderLine(block, line, context, lineIndex, isLastLine, resolvedListTextStartPx), + convertFinalParagraphMark: isLastBlockInCell, + lineTopOffset: flowCursorY, + }); + + return { + nextCumulativeLineCount, + renderedHeight: result.totalHeight, + renderedLines: result.renderedLines, + }; +}; + /** * Renders a table cell as a DOM element. * @@ -626,6 +771,31 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const effectiveCellWidth = cellWidth ?? cellMeasure.width; const contentWidthPx = Math.max(0, effectiveCellWidth - paddingLeft - paddingRight); const contentHeightPx = Math.max(0, rowHeight - paddingTop - paddingBottom); + let paragraphContextY = 0; + const betweenInfoByBlockIndex = computeBetweenBorderContext( + cellBlocks.slice(0, Math.min(blockMeasures.length, cellBlocks.length)).map((block, index) => { + const measure = blockMeasures[index]; + const y = paragraphContextY; + const height = getMeasuredBlockHeight(measure); + paragraphContextY += height; + if (block?.kind !== 'paragraph' || measure?.kind !== 'paragraph' || !block.attrs?.borders) { + return { + blockId: block?.id ?? `cell-block:${index}`, + x: 0, + y, + height, + }; + } + return { + blockId: block.id, + x: 0, + y, + height, + borders: block.attrs.borders, + borderHash: hashParagraphBorders(block.attrs.borders), + }; + }), + ); let flowCursorY = 0; const anchoredBlocks: Array<{ block: ImageBlock | DrawingBlock; measure: ImageMeasure | DrawingMeasure }> = []; const renderedLines: RenderedLineInfo[] = []; @@ -686,25 +856,16 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } - const imageWrapper = doc.createElement('div'); - imageWrapper.style.position = 'relative'; - imageWrapper.style.width = `${blockMeasure.width}px`; - imageWrapper.style.height = `${blockMeasure.height}px`; - imageWrapper.style.flexShrink = '0'; - imageWrapper.style.maxWidth = '100%'; - imageWrapper.style.boxSizing = 'border-box'; - applySdtDataset(imageWrapper, (block as ImageBlock).attrs?.sdt); - - imageWrapper.appendChild( - createBlockImageContent({ - doc, - block, - className: 'superdoc-table-image', - clipContainer: imageWrapper, - imageDisplay: 'block', - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, - }), - ); + const imageWrapper = renderTableImageFrame({ + doc, + block, + measure: blockMeasure as ImageMeasure, + placement: { mode: 'flowing' }, + contentMaxWidth: contentWidthPx, + contentMaxHeight: contentHeightPx, + applySdtDataset, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }); content.appendChild(imageWrapper); flowCursorY += blockMeasure.height; continue; @@ -745,82 +906,34 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen } if (blockMeasure.kind === 'paragraph' && block?.kind === 'paragraph') { - const paragraphMeasure = blockMeasure as ParagraphMeasure; - const lines = paragraphMeasure.lines; - const blockLineCount = lines?.length || 0; - const isLastBlockInCell = i === Math.min(blockMeasures.length, cellBlocks.length) - 1; - const wordLayout = (block.attrs?.wordLayout ?? null) as MinimalWordLayout | null; - - // Calculate the global line indices for this block - const blockStartGlobal = cumulativeLineCount; - const blockEndGlobal = cumulativeLineCount + blockLineCount; - - // Skip blocks entirely before/after the global range - if (blockEndGlobal <= globalFromLine) { - cumulativeLineCount += blockLineCount; - continue; - } - if (blockStartGlobal >= globalToLine) { - cumulativeLineCount += blockLineCount; - continue; - } - - // Calculate local line indices within this block - const localStartLine = Math.max(0, globalFromLine - blockStartGlobal); - const localEndLine = Math.min(blockLineCount, globalToLine - blockStartGlobal); - - // Create wrapper for this paragraph's SDT metadata - // Use absolute positioning within the content container to stack blocks vertically - const paraWrapper = doc.createElement('div'); - paraWrapper.style.position = 'relative'; - paraWrapper.style.left = '0'; - paraWrapper.style.width = '100%'; - 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({ + const result = renderTableCellParagraphBlock({ doc, - frameEl: paraWrapper, + content, + cellEl, block: block as ParagraphBlock, - measure: paragraphMeasure, - containerKind: 'table-cell', - width: contentWidthPx, - localStartLine, - localEndLine, - wordLayout: wordLayout ?? undefined, - spacingPolicy: { - isFirstBlock: i === 0, - isLastBlock: isLastBlockInCell, - paddingTop, - }, - sdtBoundary, + paragraphMeasure: blockMeasure as ParagraphMeasure, + blockIndex: i, + blockCount: Math.min(blockMeasures.length, cellBlocks.length), + cumulativeLineCount, + globalFromLine, + globalToLine, + contentWidthPx, + paddingTop, + flowCursorY, + sdtBoundary: sdtBoundaries[i], + betweenInfo: betweenInfoByBlockIndex.get(i), + context, + renderLine, + applySdtDataset, ancestorContainerKey, ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, - onSdtContainerChrome: () => { - cellEl.style.overflow = 'visible'; - onSdtContainerChrome?.(); - }, - applySdtDataset, - renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => - renderLine(block, line, context, lineIndex, isLastLine, resolvedListTextStartPx), - convertFinalParagraphMark: isLastBlockInCell, - lineTopOffset: flowCursorY, + onSdtContainerChrome, }); renderedLines.push(...result.renderedLines); - flowCursorY += result.totalHeight; - - cumulativeLineCount += blockLineCount; + flowCursorY += result.renderedHeight; + cumulativeLineCount = result.nextCumulativeLineCount; } // Unsupported block types are skipped (no line count contribution) // TODO: Handle other block types (list) if needed @@ -877,27 +990,16 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen } if (anchoredBlock.kind === 'image') { - const imageWrapper = doc.createElement('div'); - imageWrapper.style.position = 'absolute'; - imageWrapper.style.left = `${left}px`; - imageWrapper.style.top = `${top}px`; - imageWrapper.style.width = `${objectWidth}px`; - imageWrapper.style.height = `${objectHeight}px`; - imageWrapper.style.maxWidth = '100%'; - imageWrapper.style.boxSizing = 'border-box'; - imageWrapper.style.zIndex = String(zIndex); - applySdtDataset(imageWrapper, anchoredBlock.attrs?.sdt); - - imageWrapper.appendChild( - createBlockImageContent({ - doc, - block: anchoredBlock, - className: 'superdoc-table-image', - clipContainer: imageWrapper, - imageDisplay: 'block', - buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, - }), - ); + const imageWrapper = renderTableImageFrame({ + doc, + block: anchoredBlock, + measure: anchoredMeasure as ImageMeasure, + placement: { mode: 'anchored', left, top, zIndex }, + contentMaxWidth: contentWidthPx, + contentMaxHeight: contentHeightPx, + applySdtDataset, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }); content.appendChild(imageWrapper); } else { const drawingWrapper = renderTableDrawingFrame({ diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts index ba5b0d5563..3eff582663 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/ImageInteractionLayer.test.ts @@ -77,6 +77,32 @@ describe('ImageInteractionLayer', () => { expect(block.dataset.displayLabel).toBe('Block image'); }); + it('marks table-cell block image wrappers as draggable block images', () => { + container.innerHTML = ` +
+
+ Table image +
+
+ `; + + layer.apply(7); + + const image = container.querySelector(`.${DOM_CLASS_NAMES.IMAGE_FRAGMENT}`) as HTMLElement; + expect(image.draggable).toBe(true); + expect(image.dataset.dragSourceKind).toBe('existingImage'); + expect(image.dataset.imageKind).toBe('block'); + expect(image.dataset.displayLabel).toBe('Table image'); + expect(image.dataset.pmStart).toBe('40'); + expect(image.dataset.pmEnd).toBe('41'); + }); + it('skips elements without PM position metadata', () => { container.innerHTML = `
From 4c8e25ad8281b98f623b61c3917c057ebdad0a56 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:17:29 -0300 Subject: [PATCH 05/35] fix(painter-dom): clamp anchored table drawings --- .../layout-engine/painters/dom/src/drawings/drawingFrame.ts | 4 +++- .../painters/dom/src/table/renderTableCell.test.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts index afd286e4f4..85bf642cac 100644 --- a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts @@ -59,6 +59,9 @@ export const renderDrawingFrame = ({ wrapper.style.height = `${height}px`; wrapper.style.boxSizing = 'border-box'; wrapper.style.overflow = 'hidden'; + if (placement.mode === 'anchored-table-cell' || placement.mode === 'flowing-table-cell') { + wrapper.style.maxWidth = '100%'; + } if (placement.mode === 'anchored-table-cell') { wrapper.style.left = `${placement.left}px`; wrapper.style.top = `${placement.top}px`; @@ -66,7 +69,6 @@ export const renderDrawingFrame = ({ wrapper.style.zIndex = String(placement.zIndex); } } else if (placement.mode === 'flowing-table-cell') { - wrapper.style.maxWidth = '100%'; if (placement.flexShrink != null) { wrapper.style.flexShrink = placement.flexShrink; } 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 9f4848d6e9..e00aa4db6c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -758,6 +758,7 @@ describe('renderTableCell', () => { expect(drawingWrapper?.style.position).toBe('absolute'); expect(drawingWrapper?.style.left).toBe('12px'); expect(drawingWrapper?.style.top).toBe('7px'); + expect(drawingWrapper?.style.maxWidth).toBe('100%'); }); it('renders image drawing blocks inside table cells through the shared drawing renderer', () => { From ff4e1b8b71939e4943f86fd75b9a6917508cbf9a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:18:12 -0300 Subject: [PATCH 06/35] fix(painter-dom): keep anchored table images passive --- .../dom/src/images/table-image-frame.ts | 22 +++++++++---------- .../dom/src/table/renderTableCell.test.ts | 13 ++++++----- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/images/table-image-frame.ts b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts index 1671453a6c..f6a611bd0a 100644 --- a/packages/layout-engine/painters/dom/src/images/table-image-frame.ts +++ b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts @@ -63,14 +63,24 @@ export const renderTableImageFrame = ({ buildImageHyperlinkAnchor, }: RenderTableImageFrameParams): HTMLElement => { const wrapper = doc.createElement('div'); - wrapper.classList.add(DOM_CLASS_NAMES.IMAGE_FRAGMENT); wrapper.style.position = placement.mode === 'anchored' ? 'absolute' : 'relative'; wrapper.style.width = `${measure.width}px`; wrapper.style.height = `${measure.height}px`; wrapper.style.maxWidth = '100%'; wrapper.style.boxSizing = 'border-box'; if (placement.mode === 'flowing') { + wrapper.classList.add(DOM_CLASS_NAMES.IMAGE_FRAGMENT); wrapper.style.flexShrink = '0'; + wrapper.setAttribute('data-sd-block-id', block.id); + const pmRange = readPmRange(block); + if (pmRange.pmStart != null) wrapper.dataset.pmStart = String(pmRange.pmStart); + if (pmRange.pmEnd != null) wrapper.dataset.pmEnd = String(pmRange.pmEnd); + if (!block.attrs?.vmlWatermark) { + wrapper.setAttribute( + 'data-image-metadata', + JSON.stringify(buildTableImageMetadata(block, measure, contentMaxWidth, contentMaxHeight)), + ); + } } else { wrapper.style.left = `${placement.left}px`; wrapper.style.top = `${placement.top}px`; @@ -79,16 +89,6 @@ export const renderTableImageFrame = ({ } } - wrapper.setAttribute('data-sd-block-id', block.id); - const pmRange = readPmRange(block); - if (pmRange.pmStart != null) wrapper.dataset.pmStart = String(pmRange.pmStart); - if (pmRange.pmEnd != null) wrapper.dataset.pmEnd = String(pmRange.pmEnd); - if (!block.attrs?.vmlWatermark) { - wrapper.setAttribute( - 'data-image-metadata', - JSON.stringify(buildTableImageMetadata(block, measure, contentMaxWidth, contentMaxHeight)), - ); - } applySdtDataset(wrapper, block.attrs?.sdt); wrapper.appendChild( 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 e00aa4db6c..56b20d6190 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -434,7 +434,7 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.top).toBe('5px'); }); - it('stamps interaction metadata on anchored image wrappers inside table cells', () => { + it('does not stamp interaction metadata on anchored image wrappers inside table cells', () => { const para: ParagraphBlock = { kind: 'paragraph', id: 'para-anchor-interactive', @@ -465,14 +465,17 @@ describe('renderTableCell', () => { cell: { id: 'cell-anchored-interactive-image', blocks: [para, anchoredImage], attrs: {} }, }); - const wrapper = cellElement.querySelector('.superdoc-image-fragment') as HTMLElement | null; + const image = cellElement.querySelector('img.superdoc-table-image') as HTMLElement | null; + const wrapper = image?.parentElement as HTMLElement | null; expect(wrapper).toBeTruthy(); expect(wrapper?.style.position).toBe('absolute'); expect(wrapper?.style.left).toBe('10px'); expect(wrapper?.style.top).toBe('5px'); - expect(wrapper?.dataset.pmStart).toBe('30'); - expect(wrapper?.dataset.pmEnd).toBe('31'); - expect(wrapper?.getAttribute('data-image-metadata')).toContain('"originalWidth":20'); + expect(wrapper?.classList.contains('superdoc-image-fragment')).toBe(false); + expect(wrapper?.dataset.pmStart).toBeUndefined(); + expect(wrapper?.dataset.pmEnd).toBeUndefined(); + expect(wrapper?.getAttribute('data-sd-block-id')).toBeNull(); + expect(wrapper?.getAttribute('data-image-metadata')).toBeNull(); }); it('applies top-level clipPath to anchored image blocks inside table cells', () => { From e7786977c956a983a976374f9ddf89ed00e39767 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:19:49 -0300 Subject: [PATCH 07/35] fix(painter-dom): ignore anchored media in border grouping --- .../dom/src/table/renderTableCell.test.ts | 57 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 46 ++++++++++----- 2 files changed, 90 insertions(+), 13 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 56b20d6190..20b394ffac 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -2623,6 +2623,63 @@ describe('renderTableCell', () => { expect(firstBorder.style.borderBottomWidth).toBe('2px'); }); + it('groups table-cell paragraph borders across anchored out-of-flow blocks', () => { + const borders = { + top: { width: 1, style: 'solid' as const, color: '#111111' }, + bottom: { width: 1, style: 'solid' as const, color: '#111111' }, + between: { width: 2, style: 'dashed' as const, color: '#222222' }, + }; + const para1: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-anchor-1', + runs: [{ text: 'First', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders }, + }; + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'cell-between-anchor-image', + src: 'data:image/png;base64,AAA', + anchor: { isAnchored: true, alignH: 'left', offsetH: 0, vRelativeFrom: 'paragraph', offsetV: 0 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'cell-between-anchor-1' }, + }; + const para2: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-anchor-2', + runs: [{ text: 'Second', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders: { ...borders } }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + renderLine: (block) => { + const line = doc.createElement('div'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 10 }, paragraphMeasure], + width: 120, + height: 60, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-between-borders-across-anchor', blocks: [para1, anchoredImage, para2], attrs: {} }, + }); + + const firstWrapper = cellElement.querySelector('[data-block-id="cell-between-anchor-1"]') + ?.parentElement as HTMLElement | null; + const secondWrapper = cellElement.querySelector('[data-block-id="cell-between-anchor-2"]') + ?.parentElement as HTMLElement | null; + expect(firstWrapper?.dataset.betweenBorder).toBe('true'); + expect(firstWrapper?.dataset.gapBelow).toBeUndefined(); + expect(secondWrapper?.dataset.suppressTopBorder).toBe('true'); + const firstBorder = getParagraphBorderLayer(firstWrapper!); + expect(firstBorder.style.borderBottomStyle).toBe('dashed'); + expect(firstBorder.style.borderBottomWidth).toBe('2px'); + }); + it('breaks table-cell between-border groups when between borders differ', () => { const para1: 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 93f4f39933..1c6982e097 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -467,6 +467,14 @@ const getMeasuredBlockHeight = (measure: Measure | undefined): number => { return 'height' in measure && typeof measure.height === 'number' ? measure.height : 0; }; +const isAnchoredMediaBlock = ( + block: ParagraphBlock | TableBlock | ImageBlock | DrawingBlock | undefined, + measure: Measure | undefined, +): boolean => + (block?.kind === 'image' || block?.kind === 'drawing') && + (measure?.kind === 'image' || measure?.kind === 'drawing') && + block.anchor?.isAnchored === true; + const sliceSdtBoundaryForParagraph = ( baseBoundary: SdtBoundaryOptions | undefined, localStartLine: number, @@ -772,30 +780,42 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const contentWidthPx = Math.max(0, effectiveCellWidth - paddingLeft - paddingRight); const contentHeightPx = Math.max(0, rowHeight - paddingTop - paddingBottom); let paragraphContextY = 0; + const betweenEntryBlockIndexes: number[] = []; const betweenInfoByBlockIndex = computeBetweenBorderContext( - cellBlocks.slice(0, Math.min(blockMeasures.length, cellBlocks.length)).map((block, index) => { + cellBlocks.slice(0, Math.min(blockMeasures.length, cellBlocks.length)).flatMap((block, index) => { const measure = blockMeasures[index]; + if (isAnchoredMediaBlock(block, measure)) { + return []; + } const y = paragraphContextY; const height = getMeasuredBlockHeight(measure); paragraphContextY += height; + betweenEntryBlockIndexes.push(index); if (block?.kind !== 'paragraph' || measure?.kind !== 'paragraph' || !block.attrs?.borders) { - return { + return [ + { + blockId: block?.id ?? `cell-block:${index}`, + x: 0, + y, + height, + }, + ]; + } + return [ + { blockId: block?.id ?? `cell-block:${index}`, x: 0, y, height, - }; - } - return { - blockId: block.id, - x: 0, - y, - height, - borders: block.attrs.borders, - borderHash: hashParagraphBorders(block.attrs.borders), - }; + borders: block.attrs.borders, + borderHash: hashParagraphBorders(block.attrs.borders), + }, + ]; }), ); + const betweenInfoByOriginalBlockIndex = new Map( + Array.from(betweenInfoByBlockIndex, ([entryIndex, info]) => [betweenEntryBlockIndexes[entryIndex], info]), + ); let flowCursorY = 0; const anchoredBlocks: Array<{ block: ImageBlock | DrawingBlock; measure: ImageMeasure | DrawingMeasure }> = []; const renderedLines: RenderedLineInfo[] = []; @@ -921,7 +941,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paddingTop, flowCursorY, sdtBoundary: sdtBoundaries[i], - betweenInfo: betweenInfoByBlockIndex.get(i), + betweenInfo: betweenInfoByOriginalBlockIndex.get(i), context, renderLine, applySdtDataset, From 76c9ac47bd91c20ce3e6ce160db31d73e560a61d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:21:06 -0300 Subject: [PATCH 08/35] fix(painter-dom): flag split table cell paragraphs --- .../painters/dom/src/table/renderTableCell.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 1c6982e097..07c5b6b2e2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -552,6 +552,8 @@ const renderTableCellParagraphBlock = ({ }, betweenInfo, sdtBoundary: sliceSdtBoundaryForParagraph(sdtBoundary, localStartLine, localEndLine, blockLineCount), + continuesFromPrev: localStartLine > 0, + continuesOnNext: localEndLine < blockLineCount, ancestorContainerKey, ancestorContainerSdt, ancestorContainerKeys, @@ -780,10 +782,14 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const contentWidthPx = Math.max(0, effectiveCellWidth - paddingLeft - paddingRight); const contentHeightPx = Math.max(0, rowHeight - paddingTop - paddingBottom); let paragraphContextY = 0; + let borderContextSegmentStart = 0; const betweenEntryBlockIndexes: number[] = []; const betweenInfoByBlockIndex = computeBetweenBorderContext( cellBlocks.slice(0, Math.min(blockMeasures.length, cellBlocks.length)).flatMap((block, index) => { const measure = blockMeasures[index]; + const blockStartGlobal = borderContextSegmentStart; + const blockLineCount = blockLineCounts[index] ?? 0; + borderContextSegmentStart += blockLineCount; if (isAnchoredMediaBlock(block, measure)) { return []; } @@ -809,6 +815,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen height, borders: block.attrs.borders, borderHash: hashParagraphBorders(block.attrs.borders), + continuesFromPrev: blockStartGlobal < globalFromLine, + continuesOnNext: blockStartGlobal + blockLineCount > globalToLine, }, ]; }), From 83342244cfd0ad84581b2a236e0517b4f85445b2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:21:43 -0300 Subject: [PATCH 09/35] refactor(painter-dom): drop unused drawing body frame --- .../painters/dom/src/drawings/drawingFrame.ts | 44 +++---------------- .../dom/src/drawings/tableDrawingFrame.ts | 1 - 2 files changed, 5 insertions(+), 40 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts index 85bf642cac..d56b74e161 100644 --- a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts @@ -1,4 +1,4 @@ -import type { DrawingBlock, DrawingGeometry, SdtMetadata } from '@superdoc/contracts'; +import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; import { createDrawingPlaceholder } from './placeholder.js'; export type RenderDrawingContentForPlacement = ( @@ -7,7 +7,6 @@ export type RenderDrawingContentForPlacement = ( ) => HTMLElement; export type DrawingFramePlacement = - | { mode: 'body'; left?: never; top?: never; zIndex?: never; flexShrink?: never } | { mode: 'flowing-table-cell'; flexShrink?: string; left?: never; top?: never; zIndex?: never } | { mode: 'anchored-table-cell'; left: number; top: number; zIndex?: number; flexShrink?: never }; @@ -18,27 +17,10 @@ export type RenderDrawingFrameParams = { height: number; placement: DrawingFramePlacement; className: string; - geometry?: DrawingGeometry; - scale?: number; - suppressTransforms?: boolean; renderDrawingContent?: RenderDrawingContentForPlacement; applySdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; }; -const applyBodyDrawingTransform = ( - target: HTMLElement, - geometry: DrawingGeometry | undefined, - scale: number | undefined, -): void => { - if (!geometry) return; - const transforms: string[] = ['translate(-50%, -50%)']; - transforms.push(`rotate(${geometry.rotation ?? 0}deg)`); - transforms.push(`scaleX(${geometry.flipH ? -1 : 1})`); - transforms.push(`scaleY(${geometry.flipV ? -1 : 1})`); - transforms.push(`scale(${scale ?? 1})`); - target.style.transform = transforms.join(' '); -}; - export const renderDrawingFrame = ({ doc, block, @@ -46,15 +28,11 @@ export const renderDrawingFrame = ({ height, placement, className, - geometry, - scale, - suppressTransforms, renderDrawingContent, applySdtDataset, }: RenderDrawingFrameParams): HTMLElement => { const wrapper = doc.createElement('div'); - wrapper.style.position = - placement.mode === 'anchored-table-cell' || placement.mode === 'body' ? 'absolute' : 'relative'; + wrapper.style.position = placement.mode === 'anchored-table-cell' ? 'absolute' : 'relative'; wrapper.style.width = `${width}px`; wrapper.style.height = `${height}px`; wrapper.style.boxSizing = 'border-box'; @@ -79,21 +57,9 @@ export const renderDrawingFrame = ({ inner.classList.add(className); inner.style.width = '100%'; inner.style.height = '100%'; - if (placement.mode === 'body') { - inner.style.position = 'absolute'; - inner.style.left = '50%'; - inner.style.top = '50%'; - inner.style.width = `${width}px`; - inner.style.height = `${height}px`; - inner.style.transformOrigin = 'center'; - if (!suppressTransforms) { - applyBodyDrawingTransform(inner, geometry, scale); - } - } else { - inner.style.display = 'flex'; - inner.style.alignItems = 'center'; - inner.style.justifyContent = 'center'; - } + inner.style.display = 'flex'; + inner.style.alignItems = 'center'; + inner.style.justifyContent = 'center'; inner.style.overflow = 'hidden'; const drawingContent = renderDrawingContent?.(block, { clipContainer: inner }) ?? createDrawingPlaceholder(doc); diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index a60f56ec26..d9c1cda23b 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -38,7 +38,6 @@ export const renderTableDrawingFrame = ({ ? { mode: 'anchored-table-cell', left: left ?? 0, top: top ?? 0, zIndex } : { mode: 'flowing-table-cell', flexShrink }, className: 'superdoc-table-drawing', - suppressTransforms: true, renderDrawingContent, applySdtDataset, }); From df59db5b63b39cf4f21f00f2633c59c08c369fae Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:22:09 -0300 Subject: [PATCH 10/35] perf(painter-dom): defer table cell border hashing --- .../layout-engine/painters/dom/src/table/renderTableCell.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 07c5b6b2e2..3ce93ea9d1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -33,7 +33,6 @@ import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; import { computeBetweenBorderContext, type BetweenBorderInfo } from '../paragraph/borders/index.js'; -import { hashParagraphBorders } from '../paragraph-hash-utils.js'; import { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; import { @@ -814,7 +813,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen y, height, borders: block.attrs.borders, - borderHash: hashParagraphBorders(block.attrs.borders), continuesFromPrev: blockStartGlobal < globalFromLine, continuesOnNext: blockStartGlobal + blockLineCount > globalToLine, }, From 1288ea3313e69502e8820473fa7e36e45c6c59ca Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 18 May 2026 18:22:43 -0300 Subject: [PATCH 11/35] test(painter-dom): stabilize table border selectors --- .../dom/src/table/renderTableCell.test.ts | 30 ++++++++++++++----- 1 file changed, 23 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 20b394ffac..d38dd26040 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -2604,6 +2604,11 @@ describe('renderTableCell', () => { const { cellElement } = renderTableCell({ ...createBaseDeps(), + renderLine: (block) => { + const line = doc.createElement('div'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, cellMeasure: { blocks: [paragraphMeasure, paragraphMeasure], width: 120, @@ -2615,10 +2620,13 @@ describe('renderTableCell', () => { cell: { id: 'cell-between-borders', blocks: [para1, para2], attrs: {} }, }); - const wrappers = Array.from(cellElement.querySelectorAll(':scope > div > div')); - expect(wrappers[0]?.dataset.betweenBorder).toBe('true'); - expect(wrappers[1]?.dataset.suppressTopBorder).toBe('true'); - const firstBorder = getParagraphBorderLayer(wrappers[0]!); + const firstWrapper = cellElement.querySelector('[data-block-id="cell-between-1"]') + ?.parentElement as HTMLElement | null; + const secondWrapper = cellElement.querySelector('[data-block-id="cell-between-2"]') + ?.parentElement as HTMLElement | null; + expect(firstWrapper?.dataset.betweenBorder).toBe('true'); + expect(secondWrapper?.dataset.suppressTopBorder).toBe('true'); + const firstBorder = getParagraphBorderLayer(firstWrapper!); expect(firstBorder.style.borderBottomStyle).toBe('dashed'); expect(firstBorder.style.borderBottomWidth).toBe('2px'); }); @@ -2708,6 +2716,11 @@ describe('renderTableCell', () => { const { cellElement } = renderTableCell({ ...createBaseDeps(), + renderLine: (block) => { + const line = doc.createElement('div'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, cellMeasure: { blocks: [paragraphMeasure, paragraphMeasure], width: 120, @@ -2719,9 +2732,12 @@ describe('renderTableCell', () => { cell: { id: 'cell-between-break', blocks: [para1, para2], attrs: {} }, }); - const wrappers = Array.from(cellElement.querySelectorAll(':scope > div > div')); - expect(wrappers[0]?.dataset.betweenBorder).toBeUndefined(); - expect(wrappers[1]?.dataset.suppressTopBorder).toBeUndefined(); + const firstWrapper = cellElement.querySelector('[data-block-id="cell-between-break-1"]') + ?.parentElement as HTMLElement | null; + const secondWrapper = cellElement.querySelector('[data-block-id="cell-between-break-2"]') + ?.parentElement as HTMLElement | null; + expect(firstWrapper?.dataset.betweenBorder).toBeUndefined(); + expect(secondWrapper?.dataset.suppressTopBorder).toBeUndefined(); }); it('should not apply borders when paragraph has no borders attribute', () => { From 4de07314082a8cdc904e982fd9e315396994602b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 19 May 2026 15:52:30 -0300 Subject: [PATCH 12/35] docs(painter-dom): document renderer organization rules Add painters/dom/AGENTS.md spelling out the renderer.ts orchestration boundary, concern-directory layout, and hard invariants so feature and content rendering stays out of the page-level orchestrator. Cross-link from layout-engine/AGENTS.md and painters/dom/README.md, and trim the inline rendering bullets from the DomPainter class JSDoc to point at the new guidance. --- packages/layout-engine/AGENTS.md | 15 ++++- packages/layout-engine/painters/dom/AGENTS.md | 62 +++++++++++++++++++ packages/layout-engine/painters/dom/README.md | 18 ++++++ .../painters/dom/src/renderer.ts | 24 ++----- 4 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 packages/layout-engine/painters/dom/AGENTS.md diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 3d6a3a73de..c1ce711803 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -16,7 +16,7 @@ ProseMirror Doc → pm-adapter → FlowBlock[] → layout-engine → Layout[] | `pm-adapter/` | PM document → FlowBlocks conversion | `src/internal.ts` | | `layout-engine/` | Pagination algorithms | `src/index.ts` | | `layout-bridge/` | Layout orchestration & bridge utilities | `src/incrementalLayout.ts` | -| `painters/dom/` | DOM rendering | `src/renderer.ts` | +| `painters/dom/` | DOM rendering | `AGENTS.md`, `src/renderer.ts` | | `style-engine/` | OOXML style resolution | `src/index.ts` | | `geometry-utils/` | Math utilities for layout | `src/index.ts` | @@ -97,9 +97,17 @@ setActiveComment(commentId) → increments layoutVersion → clears pageIndexToS Maps block IDs to entries for change detection. Only changed pages re-render. See `blockIdToEntry` in `painters/dom/src/renderer.ts`. +## DomPainter Organization (`painters/dom/AGENTS.md`) + +`painters/dom/src/renderer.ts` is the page-level orchestration layer. Keep +feature and content rendering in concern-specific modules under +`painters/dom/src/` (`paragraph/`, `runs/`, `table/`, `images/`, `drawings/`, +`sdt/`, `notes/`, `textbox/`, `ruler/`, `features/`, or `utils/`). Read +`painters/dom/AGENTS.md` before adding renderer code. + ## DomPainter Feature Modules (`painters/dom/src/features/`) -Rendering logic for specific OOXML features is extracted into **feature modules** under `painters/dom/src/features//`. This keeps `renderer.ts` focused on orchestration while feature-specific logic lives in discoverable, self-contained modules. +Rendering logic for specific OOXML features belongs in **feature modules** under `painters/dom/src/features//` or the matching concern directory. This keeps `renderer.ts` focused on orchestration while feature-specific logic lives in discoverable, self-contained modules. ### How to find where an OOXML element renders @@ -134,7 +142,8 @@ Rendering logic for specific OOXML features is extracted into **feature modules* ## Entry Points -- `painters/dom/src/renderer.ts` - Main DOM rendering orchestrator (large file — feature logic is being extracted to `features/`) +- `painters/dom/AGENTS.md` - DOM painter organization and contribution rules +- `painters/dom/src/renderer.ts` - Main DOM rendering orchestrator - `painters/dom/src/features/feature-registry.ts` - OOXML element → feature module lookup - `painters/dom/src/styles.ts` - CSS class definitions - `layout-bridge/src/incrementalLayout.ts` - Layout orchestration (called by PresentationEditor) diff --git a/packages/layout-engine/painters/dom/AGENTS.md b/packages/layout-engine/painters/dom/AGENTS.md new file mode 100644 index 0000000000..5e06cc85fd --- /dev/null +++ b/packages/layout-engine/painters/dom/AGENTS.md @@ -0,0 +1,62 @@ +# DOM Painter + +Renderer for paint-ready `ResolvedLayout` input. Keep this package organized by +rendering concern so `src/renderer.ts` stays focused on orchestration. + +## Renderer Boundary + +`src/renderer.ts` owns page-level coordination: + +- mount lifecycle and paint entrypoints +- page containers, spreads, headers, footers, and virtualization +- incremental page state, active state, snapshots, and provider wiring +- dispatching resolved paint items to focused renderers + +Do not add substantial feature or content rendering logic to `renderer.ts`. +If a change is about how paragraphs, runs, tables, images, drawings, SDT, +notes, textboxes, math, or ruler UI render, put that logic in the matching +concern directory under `src/` and call it from the renderer. + +## Concern Directories + +Use the existing directories before creating new ones: + +| Concern | Location | +| --- | --- | +| Paragraph frame, lines, borders, markers, indentation | `src/paragraph/` | +| Runs, fields, links, track changes, formatting marks | `src/runs/` | +| Tables and table-cell rendering | `src/table/` | +| Image fragments, image elements, image selection | `src/images/` | +| Drawings, shapes, charts, drawing wrappers | `src/drawings/` | +| Structured document tag chrome and datasets | `src/sdt/` | +| Footnote/endnote story handling | `src/notes/` | +| Textbox and shape text helpers | `src/textbox/` | +| Ruler UI and ruler measurement helpers | `src/ruler/` | +| Cross-cutting renderer utilities | `src/utils/` | +| OOXML feature lookup modules | `src/features/` | + +Create a new concern directory only when none of the existing boundaries fit. +Keep public entrypoints narrow and export only the helpers the renderer or +neighboring concern modules need. + +## Adding Rendering Code + +- Keep container placement separate from content rendering. Body pages, + table cells, headers/footers, notes, and textboxes can place content + differently, but should reuse the same content renderers where possible. +- Do not duplicate renderer paths for the same document content. Paragraphs, + markers, images, drawings, SDT chrome, and nested tables should have shared + helpers instead of body-only and table-cell-only implementations. +- Feature modules may import contracts and local utilities, but should not + import from `src/renderer.ts`. +- If a patch would add a large private method, nested branch, or helper block + to `renderer.ts`, extract it first and leave the renderer as the caller. + +## Hard Invariants + +- DomPainter consumes `ResolvedLayout`; it does not run layout, measurement, + PM-adapter conversion, or style cascade resolution. +- The painter does not perform paint-time DOM measurement of rendered content. + Required size and offset data must come from the resolved layout. +- The resolved item is the source of truth for painter-read fields. Do not add + fallback reads from legacy fragment back-pointers. diff --git a/packages/layout-engine/painters/dom/README.md b/packages/layout-engine/painters/dom/README.md index 57255f56a7..4dd41722ec 100644 --- a/packages/layout-engine/painters/dom/README.md +++ b/packages/layout-engine/painters/dom/README.md @@ -57,3 +57,21 @@ Notes: a producer-completeness issue to fix in `layout-resolved`, not at paint time. Enforced by absence — any future regression to a `?? fragment.X` fallback fails review. + +## Code organization + +Keep `src/renderer.ts` focused on page-level orchestration: mount lifecycle, +paint entrypoints, page containers, headers/footers, virtualization, +incremental page state, active state, snapshots, and dispatching resolved +paint items to focused renderers. + +Rendering logic belongs in concern-specific modules under `src/`: +`paragraph/`, `runs/`, `table/`, `images/`, `drawings/`, `sdt/`, `notes/`, +`textbox/`, `ruler/`, `features/`, or `utils/`. Prefer extending those modules +over adding private helper blocks to `renderer.ts`. + +When adding visual behavior, keep container placement separate from content +rendering. Body pages, table cells, headers/footers, notes, and textboxes can +place content differently, but they should reuse shared content renderers for +paragraphs, markers, images, drawings, SDT chrome, and nested tables. See +`AGENTS.md` in this directory for detailed contributor guidance. diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ea13a1e540..53385a1efc 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -687,28 +687,16 @@ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; /** * DOM-based document painter that renders layout fragments to HTML elements. - * Manages page rendering, virtualization, headers/footers, and incremental updates. + * Manages page-level orchestration, virtualization, headers/footers, snapshots, + * providers, and incremental updates. * * @class DomPainter * * @remarks - * The DomPainter is responsible for: - * - Rendering layout fragments (paragraphs, lists, images, tables, drawings) to DOM elements - * - Managing page-level DOM structure and styling - * - Providing virtualization for large documents (vertical mode only) - * - Handling headers and footers via PageDecorationProvider - * - Incremental re-rendering when only specific blocks change - * - Hyperlink rendering with security sanitization and accessibility - * - * @example - * ```typescript - * const painter = new DomPainter(blocks, measures, { - * layoutMode: 'vertical', - * pageStyles: { width: '8.5in', height: '11in' } - * }); - * painter.mount(document.getElementById('editor-container')); - * painter.render(layout); - * ``` + * Keep feature and content rendering in focused modules under `src/` (for + * example `paragraph/`, `table/`, `images/`, `drawings/`, `runs/`, `sdt/`, + * `notes/`, or `textbox/`). `renderer.ts` should dispatch to those modules + * instead of growing feature-specific rendering paths. */ export class DomPainter { private readonly options: PainterOptions; From 3d31ca70a43765e7e3a807b9a081f72f431a03de Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 10:36:25 -0300 Subject: [PATCH 13/35] fix(painter-dom): render separate table borders without spacing --- .../dom/src/table/renderTableFragment.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 b2cbdad377..1a1413dc71 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -365,6 +365,42 @@ describe('renderTableFragment', () => { }); describe('merged-cell border ownership', () => { + it('renders separate outer borders when cell spacing is zero', () => { + const block: TableBlock = { + ...createTestTableBlock(), + attrs: { + borderCollapse: 'separate', + cellSpacing: 0, + borders: { + top: { style: 'single', width: 2, color: '#111111' }, + right: { style: 'single', width: 2, color: '#222222' }, + bottom: { style: 'single', width: 2, color: '#333333' }, + left: { style: 'single', width: 2, color: '#444444' }, + }, + }, + }; + const measure = createTestTableMeasure(); + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + expect(element.style.borderTopWidth).toBe('2px'); + expect(element.style.borderRightWidth).toBe('2px'); + expect(element.style.borderBottomWidth).toBe('2px'); + expect(element.style.borderLeftWidth).toBe('2px'); + }); + it('renders the outer right border for a merged header cell in collapsed mode', () => { const block: TableBlock = { kind: 'table', From a06071fbc95a0d40911a804e767a0ee925af3d32 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 10:43:42 -0300 Subject: [PATCH 14/35] fix(painter-dom): skip zero-height table media segments --- .../dom/src/table/renderTableCell.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 8 +++ 2 files changed, 72 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 d38dd26040..21813ae3ed 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -648,6 +648,70 @@ describe('renderTableCell', () => { expect(renderedLines[0]?.dataset.blockId).toBe('para-after-anchor'); }); + it('keeps partial-row segment indexing aligned when zero-height flowing media precede paragraphs', () => { + const paraAfter: ParagraphBlock = { + kind: 'paragraph', + id: 'para-after-zero-media', + runs: [{ text: 'After media', fontFamily: 'Arial', fontSize: 16 }], + }; + const zeroHeightImage: ImageBlock = { + kind: 'image', + id: 'zero-height-image', + src: 'data:image/png;base64,AAA', + }; + const zeroHeightDrawing: DrawingBlock = { + kind: 'drawing', + id: 'zero-height-drawing', + drawingKind: 'image', + src: 'data:image/png;base64,BBB', + } as DrawingBlock; + const zeroImageMeasure = { + kind: 'image' as const, + width: 20, + height: 0, + }; + const zeroDrawingMeasure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'image', + width: 20, + height: 0, + scale: 1, + naturalWidth: 20, + naturalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [zeroImageMeasure, zeroDrawingMeasure, paragraphMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-zero-height-flowing-media', + blocks: [zeroHeightImage, zeroHeightDrawing, paraAfter], + attrs: {}, + }, + fromLine: 0, + toLine: 1, + renderLine: (block) => { + const line = doc.createElement('div'); + line.classList.add('segment-alignment-line'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, + }); + + const renderedLines = Array.from(cellElement.querySelectorAll('.segment-alignment-line')) as HTMLElement[]; + expect(renderedLines).toHaveLength(1); + expect(renderedLines[0]?.dataset.blockId).toBe('para-after-zero-media'); + expect(cellElement.querySelector('.superdoc-image-fragment')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-table-drawing')).toBeFalsy(); + }); + it('adjusts column-relative anchored images by table indent and cell offset', () => { 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 3ce93ea9d1..937d32b660 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -874,6 +874,10 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } + if (blockMeasure.height <= 0) { + continue; + } + // Non-paragraph blocks occupy 1 segment in the combined line/segment index. const imgSegmentIndex = cumulativeLineCount; cumulativeLineCount += 1; @@ -908,6 +912,10 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen continue; } + if (blockMeasure.height <= 0) { + continue; + } + // Non-paragraph blocks occupy 1 segment in the combined line/segment index. const drawSegmentIndex = cumulativeLineCount; cumulativeLineCount += 1; From 619c7e28f7e80b149c07347b92aa78cdd31bda29 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 10:44:45 -0300 Subject: [PATCH 15/35] fix(painter-dom): rebuild stale sdt boundary chrome --- .../painters/dom/src/sdt/container.test.ts | 43 +++++++++++++++++++ .../painters/dom/src/sdt/container.ts | 17 +++++++- 2 files changed, 59 insertions(+), 1 deletion(-) 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 08d57980d8..9e16d398ff 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -5,6 +5,7 @@ import { getSdtContainerKey, getSdtContainerKeyForBlock, getSdtSiblingBoundaries, + shouldRebuildForSdtBoundary, shouldRenderSdtContainerChrome, } from './container.js'; @@ -197,4 +198,46 @@ describe('SDT container chrome', () => { 'structuredContent:media-sdt', ); }); + + it('requires rebuild when boundary label visibility changes', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'label-flip-sdt', + alias: 'Label Flip', + }; + + applySdtContainerChrome(doc, el, sdt, null, { isStart: true, isEnd: true, showLabel: false }); + + expect( + shouldRebuildForSdtBoundary(el, { + isStart: true, + isEnd: true, + showLabel: true, + }), + ).toBe(true); + }); + + it('requires rebuild when boundary bottom padding changes', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'padding-flip-sdt', + alias: 'Padding Flip', + }; + + applySdtContainerChrome(doc, el, sdt, null, { isStart: true, isEnd: true, paddingBottomOverride: 12 }); + + expect( + shouldRebuildForSdtBoundary(el, { + isStart: true, + isEnd: true, + paddingBottomOverride: 24, + }), + ).toBe(true); + }); }); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index e601b909e1..28bb784a19 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -165,5 +165,20 @@ export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtB if (startAttr === undefined || endAttr === undefined) { return true; } - return startAttr !== expectedStart || endAttr !== expectedEnd; + if (startAttr !== expectedStart || endAttr !== expectedEnd) { + return true; + } + + const expectedShowLabel = boundary.showLabel ?? boundary.isStart ?? true; + const hasLabel = + element.querySelector('.superdoc-structured-content__label, .superdoc-document-section__tooltip') !== null; + if (hasLabel !== expectedShowLabel) { + return true; + } + + const expectedPaddingBottom = + boundary.paddingBottomOverride != null && boundary.paddingBottomOverride > 0 + ? `${boundary.paddingBottomOverride}px` + : ''; + return element.style.paddingBottom !== expectedPaddingBottom; } From 7d88d590d53f9c10f233daf91032a6e4d8076229 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 10:54:12 -0300 Subject: [PATCH 16/35] fix(painter-dom): ignore zero-height media for cell spacing --- .../contracts/src/table-cell-slice.test.ts | 31 ++++++++++- .../contracts/src/table-cell-slice.ts | 28 ++++++++-- .../dom/src/table/renderTableCell.test.ts | 54 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 31 +++++++++-- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts index 15b1215a2a..07052ab035 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.test.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { ParagraphMeasure, TableBlock, TableCellMeasure, TableMeasure } from './index.js'; +import type { ParagraphMeasure, TableBlock, TableCell, TableCellMeasure, TableMeasure } from './index.js'; import { computeCellSliceContentHeight, computeFullCellContentHeight, @@ -30,13 +30,22 @@ describe('table cell segment mapping', () => { height, }); - const makeParagraphBlock = (id: string, spacing?: { before?: number; after?: number }) => ({ + const makeParagraphBlock = ( + id: string, + spacing?: { before?: number; after?: number }, + ): TableCell['blocks'][number] => ({ kind: 'paragraph' as const, id, runs: [], attrs: spacing ? { spacing } : undefined, }); + const makeImageBlock = (): TableCell['blocks'][number] => ({ + kind: 'image', + id: 'zero-height-image', + src: 'data:image/png;base64,AAA', + }); + it('counts paragraph and positive-height object segments', () => { const cell: TableCellMeasure = { blocks: [makeParagraph(2), makeImage(50), makeImage(0), makeParagraph(3)], @@ -47,6 +56,24 @@ describe('table cell segment mapping', () => { expect(getCellLines(cell)).toHaveLength(6); }); + it('ignores zero-height object blocks for final paragraph spacing', () => { + const cell: TableCellMeasure = { + blocks: [makeParagraph(1), makeImage(0)], + width: 200, + height: 20, + }; + const block: TableCell = { + id: 'cell-zero-height-tail', + blocks: [makeParagraphBlock('paragraph-after', { after: 12 }), makeImageBlock()], + }; + const blocks = describeCellRenderBlocks(cell, block, { top: 0, bottom: 5 }); + + expect(blocks).toHaveLength(1); + expect(blocks[0].isLastBlock).toBe(true); + expect(blocks[0].spacingAfter).toBe(0); + expect(computeFullCellContentHeight(cell, block, { top: 0, bottom: 5 })).toBe(27); + }); + it('falls back to legacy single-paragraph cells', () => { const cell: TableCellMeasure = { paragraph: makeParagraph(3), diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index 61efcec615..cadaf03f41 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -106,12 +106,15 @@ export function describeCellRenderBlocks( const result: CellRenderBlock[] = []; let globalLine = 0; + const visibleBlockIndexes = getVisibleCellBlockIndexes(measuredBlocks); + const firstVisibleBlockIndex = visibleBlockIndexes[0] ?? -1; + const lastVisibleBlockIndex = visibleBlockIndexes[visibleBlockIndexes.length - 1] ?? -1; for (let i = 0; i < measuredBlocks.length; i += 1) { const measure = measuredBlocks[i]; const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; - const isFirstBlock = i === 0; - const isLastBlock = i === measuredBlocks.length - 1; + const isFirstBlock = i === firstVisibleBlockIndex; + const isLastBlock = i === lastVisibleBlockIndex; if (measure.kind === 'paragraph') { const paraMeasure = measure as ParagraphMeasure; @@ -388,11 +391,15 @@ export function computeFullCellContentHeight( // row-height preflight from comparing measured row heights to renderer-only // slice heights. let height = 0; + const visibleBlockIndexes = getVisibleCellBlockIndexes(measuredBlocks); + const firstVisibleBlockIndex = visibleBlockIndexes[0] ?? -1; + const lastVisibleBlockIndex = visibleBlockIndexes[visibleBlockIndexes.length - 1] ?? -1; + for (let i = 0; i < measuredBlocks.length; i += 1) { const measure = measuredBlocks[i]; const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; - const isFirstBlock = i === 0; - const isLastBlock = i === measuredBlocks.length - 1; + const isFirstBlock = i === firstVisibleBlockIndex; + const isLastBlock = i === lastVisibleBlockIndex; if (measure.kind === 'paragraph') { const paraMeasure = measure as ParagraphMeasure; @@ -452,6 +459,19 @@ function resolveSpacingAfter(spacingAfter: number | undefined, isLastBlock: bool return typeof spacingAfter === 'number' && spacingAfter > 0 ? spacingAfter : 0; } +function getVisibleCellBlockIndexes(measuredBlocks: TableCellMeasure['blocks']): number[] { + const indexes: number[] = []; + for (let i = 0; i < measuredBlocks.length; i += 1) { + if (isVisibleCellBlockMeasure(measuredBlocks[i])) indexes.push(i); + } + return indexes; +} + +function isVisibleCellBlockMeasure(measure: TableCellMeasure['blocks'][number]): boolean { + if (measure.kind === 'paragraph' || measure.kind === 'table') return true; + return 'height' in measure && typeof measure.height === 'number' && measure.height > 0; +} + function isAnchoredOutOfFlow(block: unknown): boolean { if (!block || typeof block !== 'object') return false; const b = block as Record; 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 21813ae3ed..cf75818028 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -1466,6 +1466,60 @@ describe('renderTableCell', () => { expect(paraWrapper.style.marginBottom).toBe(''); }); + it('should NOT apply spacing.after when the last paragraph is followed by zero-height media', () => { + const lastPara: ParagraphBlock = { + kind: 'paragraph', + id: 'para-last-before-zero-media', + runs: [{ text: 'Last paragraph', fontFamily: 'Arial', fontSize: 16 }], + attrs: { spacing: { after: 15 } }, + }; + const zeroHeightImage: ImageBlock = { + kind: 'image', + id: 'zero-height-after-last', + src: 'data:image/png;base64,AAA', + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 14, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + { kind: 'image', width: 20, height: 0 }, + ], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-last-before-zero-media', + blocks: [lastPara, zeroHeightImage], + attrs: {}, + }, + }); + + const contentElement = cellElement.firstElementChild as HTMLElement; + const paraWrapper = contentElement.children[0] as HTMLElement; + expect(contentElement.children).toHaveLength(1); + expect(paraWrapper.style.marginBottom).toBe(''); + }); + it('should only apply margin-bottom when spacing.after > 0', () => { const para1: 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 937d32b660..00eac332a9 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -466,6 +466,22 @@ const getMeasuredBlockHeight = (measure: Measure | undefined): number => { return 'height' in measure && typeof measure.height === 'number' ? measure.height : 0; }; +const getTableCellVisibleBlockIndexes = (measures: Measure[], blockCount: number): number[] => { + const indexes: number[] = []; + for (let i = 0; i < blockCount; i += 1) { + const measure = measures[i]; + if (!measure) continue; + if (measure.kind === 'paragraph' || measure.kind === 'table') { + indexes.push(i); + continue; + } + if ('height' in measure && typeof measure.height === 'number' && measure.height > 0) { + indexes.push(i); + } + } + return indexes; +}; + const isAnchoredMediaBlock = ( block: ParagraphBlock | TableBlock | ImageBlock | DrawingBlock | undefined, measure: Measure | undefined, @@ -755,8 +771,13 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // which uses getEmbeddedRowLines() for recursive nested table expansion). // Non-paragraph blocks (images, drawings) occupy 1 segment each when height > 0, // including anchored blocks (matching getCellLines() in layout-table.ts). + const rawBlockCount = Math.min(blockMeasures.length, cellBlocks.length); + const visibleBlockIndexes = getTableCellVisibleBlockIndexes(blockMeasures as Measure[], rawBlockCount); + const visibleBlockIndexByOriginalIndex = new Map( + visibleBlockIndexes.map((originalIndex, visibleIndex) => [originalIndex, visibleIndex]), + ); const blockLineCounts: number[] = []; - for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) { + for (let i = 0; i < rawBlockCount; i++) { const bm = blockMeasures[i]; if (bm.kind === 'paragraph') { blockLineCounts.push((bm as ParagraphMeasure).lines?.length || 0); @@ -784,7 +805,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen let borderContextSegmentStart = 0; const betweenEntryBlockIndexes: number[] = []; const betweenInfoByBlockIndex = computeBetweenBorderContext( - cellBlocks.slice(0, Math.min(blockMeasures.length, cellBlocks.length)).flatMap((block, index) => { + cellBlocks.slice(0, rawBlockCount).flatMap((block, index) => { const measure = blockMeasures[index]; const blockStartGlobal = borderContextSegmentStart; const blockLineCount = blockLineCounts[index] ?? 0; @@ -827,7 +848,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const renderedLines: RenderedLineInfo[] = []; let cumulativeLineCount = 0; // Track cumulative line count across blocks - for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) { + for (let i = 0; i < rawBlockCount; i++) { const blockMeasure = blockMeasures[i]; const block = cellBlocks[i]; @@ -946,8 +967,8 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen cellEl, block: block as ParagraphBlock, paragraphMeasure: blockMeasure as ParagraphMeasure, - blockIndex: i, - blockCount: Math.min(blockMeasures.length, cellBlocks.length), + blockIndex: visibleBlockIndexByOriginalIndex.get(i) ?? i, + blockCount: visibleBlockIndexes.length, cumulativeLineCount, globalFromLine, globalToLine, From 78d5094985787920c258a4de2b00b92330f55106 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 10:59:03 -0300 Subject: [PATCH 17/35] fix(contracts): type visible table cell blocks --- packages/layout-engine/contracts/src/table-cell-slice.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index cadaf03f41..5a076b6e4e 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -459,7 +459,9 @@ function resolveSpacingAfter(spacingAfter: number | undefined, isLastBlock: bool return typeof spacingAfter === 'number' && spacingAfter > 0 ? spacingAfter : 0; } -function getVisibleCellBlockIndexes(measuredBlocks: TableCellMeasure['blocks']): number[] { +type TableCellMeasureBlock = NonNullable[number]; + +function getVisibleCellBlockIndexes(measuredBlocks: TableCellMeasureBlock[]): number[] { const indexes: number[] = []; for (let i = 0; i < measuredBlocks.length; i += 1) { if (isVisibleCellBlockMeasure(measuredBlocks[i])) indexes.push(i); @@ -467,7 +469,7 @@ function getVisibleCellBlockIndexes(measuredBlocks: TableCellMeasure['blocks']): return indexes; } -function isVisibleCellBlockMeasure(measure: TableCellMeasure['blocks'][number]): boolean { +function isVisibleCellBlockMeasure(measure: TableCellMeasureBlock): boolean { if (measure.kind === 'paragraph' || measure.kind === 'table') return true; return 'height' in measure && typeof measure.height === 'number' && measure.height > 0; } From 33ac88f8c7148c124c9c20793ff0d59e34bc8e42 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 11:08:50 -0300 Subject: [PATCH 18/35] fix(painter-dom): ignore anchored media for cell spacing --- .../contracts/src/table-cell-slice.test.ts | 26 +++++++++ .../contracts/src/table-cell-slice.ts | 15 +++-- .../dom/src/table/renderTableCell.test.ts | 57 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 12 +++- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts index 07052ab035..ab24b634a4 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.test.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -46,6 +46,14 @@ describe('table cell segment mapping', () => { src: 'data:image/png;base64,AAA', }); + const makeAnchoredImageBlock = (): TableCell['blocks'][number] => ({ + kind: 'image', + id: 'anchored-image', + src: 'data:image/png;base64,AAA', + anchor: { isAnchored: true }, + wrap: { type: 'None' }, + }); + it('counts paragraph and positive-height object segments', () => { const cell: TableCellMeasure = { blocks: [makeParagraph(2), makeImage(50), makeImage(0), makeParagraph(3)], @@ -74,6 +82,24 @@ describe('table cell segment mapping', () => { expect(computeFullCellContentHeight(cell, block, { top: 0, bottom: 5 })).toBe(27); }); + it('ignores anchored out-of-flow object blocks for final paragraph spacing', () => { + const cell: TableCellMeasure = { + blocks: [makeParagraph(1), makeImage(20)], + width: 200, + height: 20, + }; + const block: TableCell = { + id: 'cell-anchored-tail', + blocks: [makeParagraphBlock(12), makeAnchoredImageBlock()], + }; + const blocks = describeCellRenderBlocks(cell, block, { top: 0, bottom: 5 }); + + expect(blocks).toHaveLength(2); + expect(blocks[0].isLastBlock).toBe(true); + expect(blocks[0].spacingAfter).toBe(0); + expect(computeFullCellContentHeight(cell, block, { top: 0, bottom: 5 })).toBe(27); + }); + it('falls back to legacy single-paragraph cells', () => { const cell: TableCellMeasure = { paragraph: makeParagraph(3), diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts index 5a076b6e4e..5ea94d1571 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -106,7 +106,7 @@ export function describeCellRenderBlocks( const result: CellRenderBlock[] = []; let globalLine = 0; - const visibleBlockIndexes = getVisibleCellBlockIndexes(measuredBlocks); + const visibleBlockIndexes = getVisibleCellBlockIndexes(measuredBlocks, blockDataArray); const firstVisibleBlockIndex = visibleBlockIndexes[0] ?? -1; const lastVisibleBlockIndex = visibleBlockIndexes[visibleBlockIndexes.length - 1] ?? -1; @@ -391,7 +391,7 @@ export function computeFullCellContentHeight( // row-height preflight from comparing measured row heights to renderer-only // slice heights. let height = 0; - const visibleBlockIndexes = getVisibleCellBlockIndexes(measuredBlocks); + const visibleBlockIndexes = getVisibleCellBlockIndexes(measuredBlocks, blockDataArray); const firstVisibleBlockIndex = visibleBlockIndexes[0] ?? -1; const lastVisibleBlockIndex = visibleBlockIndexes[visibleBlockIndexes.length - 1] ?? -1; @@ -460,17 +460,22 @@ function resolveSpacingAfter(spacingAfter: number | undefined, isLastBlock: bool } type TableCellMeasureBlock = NonNullable[number]; +type TableCellBlock = NonNullable[number]; -function getVisibleCellBlockIndexes(measuredBlocks: TableCellMeasureBlock[]): number[] { +function getVisibleCellBlockIndexes( + measuredBlocks: TableCellMeasureBlock[], + blockDataArray: TableCell['blocks'] | undefined, +): number[] { const indexes: number[] = []; for (let i = 0; i < measuredBlocks.length; i += 1) { - if (isVisibleCellBlockMeasure(measuredBlocks[i])) indexes.push(i); + if (isVisibleCellBlockMeasure(measuredBlocks[i], blockDataArray?.[i])) indexes.push(i); } return indexes; } -function isVisibleCellBlockMeasure(measure: TableCellMeasureBlock): boolean { +function isVisibleCellBlockMeasure(measure: TableCellMeasureBlock, data: TableCellBlock | undefined): boolean { if (measure.kind === 'paragraph' || measure.kind === 'table') return true; + if (isAnchoredOutOfFlow(data)) return false; return 'height' in measure && typeof measure.height === 'number' && measure.height > 0; } diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index cf75818028..e02369c8e1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -1520,6 +1520,63 @@ describe('renderTableCell', () => { expect(paraWrapper.style.marginBottom).toBe(''); }); + it('should NOT apply spacing.after when the last paragraph is followed by anchored media', () => { + const lastPara: ParagraphBlock = { + kind: 'paragraph', + id: 'para-last-before-anchored-media', + runs: [{ text: 'Last paragraph', fontFamily: 'Arial', fontSize: 16 }], + attrs: { spacing: { after: 15 } }, + }; + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'anchored-after-last', + src: 'data:image/png;base64,AAA', + anchor: { isAnchored: true, alignH: 'left', offsetH: 0, vRelativeFrom: 'paragraph', offsetV: 0 }, + wrap: { type: 'None' }, + attrs: { anchorParagraphId: 'para-last-before-anchored-media' }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 14, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + { kind: 'image', width: 20, height: 10 }, + ], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-last-before-anchored-media', + blocks: [lastPara, anchoredImage], + attrs: {}, + }, + }); + + const contentElement = cellElement.firstElementChild as HTMLElement; + const paraWrapper = contentElement.children[0] as HTMLElement; + expect(paraWrapper.style.marginBottom).toBe(''); + expect(cellElement.querySelector('img.superdoc-table-image')).toBeTruthy(); + }); + it('should only apply margin-bottom when spacing.after > 0', () => { const para1: 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 00eac332a9..ca61cddb30 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -466,15 +466,23 @@ const getMeasuredBlockHeight = (measure: Measure | undefined): number => { return 'height' in measure && typeof measure.height === 'number' ? measure.height : 0; }; -const getTableCellVisibleBlockIndexes = (measures: Measure[], blockCount: number): number[] => { +const getTableCellVisibleBlockIndexes = ( + measures: Measure[], + blocks: Array, + blockCount: number, +): number[] => { const indexes: number[] = []; for (let i = 0; i < blockCount; i += 1) { const measure = measures[i]; + const block = blocks[i]; if (!measure) continue; if (measure.kind === 'paragraph' || measure.kind === 'table') { indexes.push(i); continue; } + if (isAnchoredMediaBlock(block, measure)) { + continue; + } if ('height' in measure && typeof measure.height === 'number' && measure.height > 0) { indexes.push(i); } @@ -772,7 +780,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // Non-paragraph blocks (images, drawings) occupy 1 segment each when height > 0, // including anchored blocks (matching getCellLines() in layout-table.ts). const rawBlockCount = Math.min(blockMeasures.length, cellBlocks.length); - const visibleBlockIndexes = getTableCellVisibleBlockIndexes(blockMeasures as Measure[], rawBlockCount); + const visibleBlockIndexes = getTableCellVisibleBlockIndexes(blockMeasures as Measure[], cellBlocks, rawBlockCount); const visibleBlockIndexByOriginalIndex = new Map( visibleBlockIndexes.map((originalIndex, visibleIndex) => [originalIndex, visibleIndex]), ); From 53aba388ba4c9351251e558e37f728a09fa9f849 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 11:10:52 -0300 Subject: [PATCH 19/35] fix(painter-dom): preserve embedded table row slices --- .../src/table/embeddedTableFragment.test.ts | 62 ++++++++++++- .../dom/src/table/embeddedTableFragment.ts | 35 +++++++ .../painters/dom/src/table/renderTableCell.ts | 93 ++++++++++++------- 3 files changed, 157 insertions(+), 33 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts index 33ea4143d1..7a5bf1841a 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { TableBlock, TableMeasure } from '@superdoc/contracts'; -import { mapEmbeddedTableRowSlice } from './embeddedTableFragment.js'; +import { mapEmbeddedTableRowSlice, mapEmbeddedTableRowSlices } from './embeddedTableFragment.js'; const makeNestedTableMeasure = (rowHeights: number[]): TableMeasure => ({ kind: 'table', @@ -84,6 +84,66 @@ describe('mapEmbeddedTableRowSlice', () => { }); }); + it('preserves both partial rows when a segment window clips adjacent multi-segment rows', () => { + const firstInnerMeasure = makeNestedTableMeasure([5, 7]); + const secondInnerMeasure = makeNestedTableMeasure([11, 13]); + const firstInnerBlock = makeNestedTableBlock('first-inner', 2); + const secondInnerBlock = makeNestedTableBlock('second-inner', 2); + const block: TableBlock = { + kind: 'table', + id: 'table', + rows: [ + { + id: 'row-0', + cells: [{ id: 'cell-0', blocks: [firstInnerBlock], attrs: {} }], + attrs: {}, + }, + { + id: 'row-1', + cells: [{ id: 'cell-1', blocks: [secondInnerBlock], attrs: {} }], + attrs: {}, + }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { height: 12, cells: [{ width: 40, height: 12, blocks: [firstInnerMeasure] }] }, + { height: 24, cells: [{ width: 40, height: 24, blocks: [secondInnerMeasure] }] }, + ], + columnWidths: [40], + totalWidth: 40, + totalHeight: 36, + }; + + expect(mapEmbeddedTableRowSlices({ block, measure, localFrom: 1, localTo: 3 })).toEqual([ + { + fromRow: 0, + toRow: 1, + partialRow: { + rowIndex: 0, + fromLineByCell: [1], + toLineByCell: [2], + isFirstPart: false, + isLastPart: true, + partialHeight: 7, + }, + }, + { + fromRow: 1, + toRow: 2, + partialRow: { + rowIndex: 1, + fromLineByCell: [0], + toLineByCell: [1], + isFirstPart: true, + isLastPart: false, + partialHeight: 11, + }, + }, + ]); + }); + it('returns null for an out-of-range segment window', () => { const block = makeNestedTableBlock('table', 1); const measure: TableMeasure = { diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts index 27d80ee8f7..19f9557a3d 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts @@ -117,6 +117,41 @@ export function mapEmbeddedTableRowSlice(params: { return { fromRow, toRow, partialRow }; } +export function mapEmbeddedTableRowSlices(params: { + block: TableBlock; + measure: TableMeasure; + localFrom: number; + localTo: number; +}): RowSliceResult[] { + const { block, measure, localFrom, localTo } = params; + const slices: RowSliceResult[] = []; + let segmentOffset = 0; + + for (let r = 0; r < measure.rows.length; r += 1) { + const rowSegmentCount = getEmbeddedRowLines(measure.rows[r]).length; + const rowStart = segmentOffset; + const rowEnd = segmentOffset + rowSegmentCount; + segmentOffset = rowEnd; + + if (rowEnd <= localFrom || rowStart >= localTo) continue; + + let partialRow: PartialRowInfo | undefined; + if (rowSegmentCount > 1 && (rowStart < localFrom || rowEnd > localTo)) { + partialRow = buildPartialRowInfo({ + blockRow: block.rows[r], + row: measure.rows[r], + rowIndex: r, + rowLocalFrom: Math.max(0, localFrom - rowStart), + rowLocalTo: Math.min(rowSegmentCount, localTo - rowStart), + }); + } + + slices.push({ fromRow: r, toRow: r + 1, partialRow }); + } + + return slices; +} + function buildPartialRowInfo(params: { blockRow: TableRow | undefined; row: TableMeasure['rows'][number]; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index ca61cddb30..87e00b1a08 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -39,7 +39,7 @@ import { computeRenderedTableFragmentHeight, createEmbeddedTableFragment, getEmbeddedTableSegmentCount, - mapEmbeddedTableRowSlice, + mapEmbeddedTableRowSlices, } from './embeddedTableFragment.js'; type TableRowMeasure = TableMeasure['rows'][number]; @@ -283,19 +283,23 @@ function renderPartialEmbeddedTable(params: { const localFrom = Math.max(0, globalFromLine - tableStartSegment); const localTo = Math.min(totalTableSegments, globalToLine - tableStartSegment); - const rowSlice = mapEmbeddedTableRowSlice({ block, measure: tableMeasure, localFrom, localTo }); - if (!rowSlice) { + const rowSlices = mapEmbeddedTableRowSlices({ block, measure: tableMeasure, localFrom, localTo }); + if (rowSlices.length === 0) { return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } - const { fromRow: embeddedFromRow, toRow: embeddedToRow, partialRow: partialRowInfo } = rowSlice; - const visibleHeight = computeRenderedTableFragmentHeight({ - block, - measure: tableMeasure, - fromRow: embeddedFromRow, - toRow: embeddedToRow, - partialRow: partialRowInfo, - }); + const visibleHeight = rowSlices.reduce( + (height, rowSlice) => + height + + computeRenderedTableFragmentHeight({ + block, + measure: tableMeasure, + fromRow: rowSlice.fromRow, + toRow: rowSlice.toRow, + partialRow: rowSlice.partialRow, + }), + 0, + ); const effectiveSdtBoundary = sdtBoundary ? { ...sdtBoundary, @@ -312,33 +316,58 @@ function renderPartialEmbeddedTable(params: { tableWrapper.style.flexShrink = '0'; tableWrapper.style.boxSizing = 'border-box'; - const tableResult = renderEmbeddedTable({ - doc, - table: block, - measure: tableMeasure, - availableWidth: contentWidthPx, - context, - renderLine, - captureLineSnapshot, - renderDrawingContent, - applySdtDataset, - fromRow: embeddedFromRow, - toRow: embeddedToRow, - partialRow: partialRowInfo, - sdtBoundary: effectiveSdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, - ancestorContainerKeys, - ancestorContainerSdts, - onSdtContainerChrome, + let sliceTop = 0; + let hasSdtContainerChrome = false; + rowSlices.forEach((rowSlice, index) => { + const sliceHeight = computeRenderedTableFragmentHeight({ + block, + measure: tableMeasure, + fromRow: rowSlice.fromRow, + toRow: rowSlice.toRow, + partialRow: rowSlice.partialRow, + }); + const tableResult = renderEmbeddedTable({ + doc, + table: block, + measure: tableMeasure, + availableWidth: contentWidthPx, + context, + renderLine, + captureLineSnapshot, + renderDrawingContent, + applySdtDataset, + fromRow: rowSlice.fromRow, + toRow: rowSlice.toRow, + partialRow: rowSlice.partialRow, + sdtBoundary: + effectiveSdtBoundary && rowSlices.length > 1 + ? { + ...effectiveSdtBoundary, + isStart: (effectiveSdtBoundary.isStart ?? true) && index === 0, + isEnd: (effectiveSdtBoundary.isEnd ?? true) && index === rowSlices.length - 1, + showLabel: + effectiveSdtBoundary.showLabel === undefined + ? undefined + : effectiveSdtBoundary.showLabel && index === 0, + } + : effectiveSdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, + }); + tableResult.element.style.top = `${sliceTop}px`; + tableWrapper.appendChild(tableResult.element); + hasSdtContainerChrome ||= tableResult.hasSdtContainerChrome; + sliceTop += sliceHeight; }); - tableWrapper.appendChild(tableResult.element); return { element: tableWrapper, height: visibleHeight, nextCumulativeLineCount, - hasSdtContainerChrome: tableResult.hasSdtContainerChrome, + hasSdtContainerChrome, }; } From b741433fb107da8ee99192476bf89a5537d05676 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 11:19:16 -0300 Subject: [PATCH 20/35] fix(painter-dom): coalesce full embedded table rows --- .../src/table/embeddedTableFragment.test.ts | 18 ++++++++ .../dom/src/table/embeddedTableFragment.ts | 10 +++- .../dom/src/table/renderTableCell.test.ts | 46 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts index 7a5bf1841a..3cc572e2ea 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts @@ -144,6 +144,24 @@ describe('mapEmbeddedTableRowSlice', () => { ]); }); + it('coalesces adjacent full rows into one fragment slice', () => { + const block = makeNestedTableBlock('table', 2); + const measure: TableMeasure = { + kind: 'table', + rows: [ + { height: 10, cells: [{ width: 40, height: 10, blocks: [] }] }, + { height: 12, cells: [{ width: 40, height: 12, blocks: [] }] }, + ], + columnWidths: [40], + totalWidth: 40, + totalHeight: 22, + }; + + expect(mapEmbeddedTableRowSlices({ block, measure, localFrom: 0, localTo: 2 })).toEqual([ + { fromRow: 0, toRow: 2, partialRow: undefined }, + ]); + }); + it('returns null for an out-of-range segment window', () => { const block = makeNestedTableBlock('table', 1); const measure: TableMeasure = { diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts index 19f9557a3d..033fd1d8b8 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts @@ -136,7 +136,8 @@ export function mapEmbeddedTableRowSlices(params: { if (rowEnd <= localFrom || rowStart >= localTo) continue; let partialRow: PartialRowInfo | undefined; - if (rowSegmentCount > 1 && (rowStart < localFrom || rowEnd > localTo)) { + const isPartial = rowSegmentCount > 1 && (rowStart < localFrom || rowEnd > localTo); + if (isPartial) { partialRow = buildPartialRowInfo({ blockRow: block.rows[r], row: measure.rows[r], @@ -146,7 +147,12 @@ export function mapEmbeddedTableRowSlices(params: { }); } - slices.push({ fromRow: r, toRow: r + 1, partialRow }); + const previous = slices[slices.length - 1]; + if (!isPartial && previous && !previous.partialRow && previous.toRow === r) { + previous.toRow = r + 1; + } else { + slices.push({ fromRow: r, toRow: r + 1, partialRow }); + } } return slices; 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 e02369c8e1..31dcaecbef 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4996,6 +4996,52 @@ describe('renderTableCell', () => { expect(tableEl?.parentElement?.style.height).toBe('24px'); }); + it('coalesces full embedded table row ranges into one table fragment', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-multi-row-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-multi-row-spacing-table', + rows: [ + { id: 'nested-row-1', cells: [{ id: 'nested-cell-1', blocks: [nestedParagraph] }] }, + { id: 'nested-row-2', cells: [{ id: 'nested-cell-2', blocks: [nestedParagraph] }] }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 10, + cells: [{ width: 80, height: 10, 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: 36, + cellSpacingPx: 2, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { ...baseCellMeasure, blocks: [nestedMeasure], height: 36 }, + cell: { ...baseCell, blocks: [nestedTable] }, + }); + + const tableFragments = cellElement.querySelectorAll( + '[data-block-id="nested-multi-row-spacing-table"]', + ); + expect(tableFragments).toHaveLength(1); + expect(tableFragments[0]?.style.height).toBe('36px'); + expect(tableFragments[0]?.parentElement?.style.height).toBe('36px'); + }); + it('includes separate-border outer table height for embedded table fragments', () => { const nestedParagraph: ParagraphBlock = { kind: 'paragraph', From 9cd977dd789b75d8b06bbee4dbd136a2fe0e34d1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:34:07 -0300 Subject: [PATCH 21/35] fix(painter-dom): suppress split embedded table chrome --- .../src/table/embeddedTableFragment.test.ts | 37 ++++++++++++- .../dom/src/table/embeddedTableFragment.ts | 52 +++++++++++++++++-- .../painters/dom/src/table/renderTableCell.ts | 16 +++++- .../dom/src/table/renderTableFragment.test.ts | 35 +++++++++++++ .../dom/src/table/renderTableFragment.ts | 21 +++++--- 5 files changed, 147 insertions(+), 14 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts index 3cc572e2ea..de795b65ab 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { TableBlock, TableMeasure } from '@superdoc/contracts'; -import { mapEmbeddedTableRowSlice, mapEmbeddedTableRowSlices } from './embeddedTableFragment.js'; +import { + createEmbeddedTableFragment, + mapEmbeddedTableRowSlice, + mapEmbeddedTableRowSlices, +} from './embeddedTableFragment.js'; const makeNestedTableMeasure = (rowHeights: number[]): TableMeasure => ({ kind: 'table', @@ -162,6 +166,37 @@ describe('mapEmbeddedTableRowSlice', () => { ]); }); + it('marks and sizes continuation slices without duplicated outer vertical chrome', () => { + const block: TableBlock = { + ...makeNestedTableBlock('table', 2), + attrs: { borderCollapse: 'separate' }, + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { height: 10, cells: [{ width: 40, height: 10, blocks: [] }] }, + { height: 12, cells: [{ width: 40, height: 12, blocks: [] }] }, + ], + columnWidths: [40], + totalWidth: 40, + totalHeight: 32, + cellSpacingPx: 2, + tableBorderWidths: { top: 3, right: 0, bottom: 5, left: 0 }, + }; + + const { fragment } = createEmbeddedTableFragment({ + block, + measure, + availableWidth: 40, + fromRow: 0, + toRow: 1, + continuesOnNext: true, + }); + + expect(fragment.continuesOnNext).toBe(true); + expect(fragment.height).toBe(15); + }); + it('returns null for an out-of-range segment window', () => { const block = makeNestedTableBlock('table', 1); const measure: TableMeasure = { diff --git a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts index 033fd1d8b8..9dfae56dad 100644 --- a/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/embeddedTableFragment.ts @@ -30,12 +30,23 @@ export function computeRenderedTableFragmentHeight(params: { toRow: number; partialRow?: PartialRowInfo; repeatHeaderCount?: number; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; }): number { - const { block, measure, fromRow, toRow, partialRow, repeatHeaderCount = 0 } = params; + const { + block, + measure, + fromRow, + toRow, + partialRow, + repeatHeaderCount = 0, + continuesFromPrev, + continuesOnNext, + } = params; const cellSpacingPx = measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing); const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); - return computeTableFragmentHeight({ + let height = computeTableFragmentHeight({ measure, fromRow, toRow, @@ -44,6 +55,18 @@ export function computeRenderedTableFragmentHeight(params: { partialRow, cellSpacingPx, }); + + if (cellSpacingPx > 0) { + if (continuesFromPrev) height -= cellSpacingPx; + if (continuesOnNext) height -= cellSpacingPx; + } + + if (borderCollapse === 'separate' && measure.tableBorderWidths) { + if (continuesFromPrev) height -= measure.tableBorderWidths.top; + if (continuesOnNext) height -= measure.tableBorderWidths.bottom; + } + + return Math.max(0, height); } export function createEmbeddedTableFragment(params: { @@ -53,11 +76,30 @@ export function createEmbeddedTableFragment(params: { fromRow?: number; toRow?: number; partialRow?: PartialRowInfo; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; }) { - const { block, measure, availableWidth, fromRow = 0, toRow = block.rows.length, partialRow } = params; + const { + block, + measure, + availableWidth, + fromRow = 0, + toRow = block.rows.length, + partialRow, + continuesFromPrev, + continuesOnNext, + } = params; const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, availableWidth); const fragmentWidth = columnWidths ? availableWidth : measure.totalWidth; - const height = computeRenderedTableFragmentHeight({ block, measure, fromRow, toRow, partialRow }); + const height = computeRenderedTableFragmentHeight({ + block, + measure, + fromRow, + toRow, + partialRow, + continuesFromPrev, + continuesOnNext, + }); return { fragment: { @@ -71,6 +113,8 @@ export function createEmbeddedTableFragment(params: { height, columnWidths, partialRow, + ...(continuesFromPrev ? { continuesFromPrev } : {}), + ...(continuesOnNext ? { continuesOnNext } : {}), }, effectiveColumnWidths: columnWidths ?? measure.columnWidths, cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing), diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 87e00b1a08..badfa79577 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -110,6 +110,10 @@ type EmbeddedTableRenderParams = { toRow?: number; /** Partial row info for mid-row splits within the embedded table */ partialRow?: PartialRowInfo; + /** Whether this embedded fragment continues a prior embedded table slice */ + continuesFromPrev?: boolean; + /** Whether this embedded fragment continues in a later embedded table slice */ + continuesOnNext?: boolean; /** Optional SDT boundary overrides for container styling */ sdtBoundary?: SdtBoundaryOptions; /** Ancestor SDT key used to suppress duplicate container chrome in nested tables */ @@ -168,6 +172,8 @@ const renderEmbeddedTable = ( fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, + continuesFromPrev, + continuesOnNext, sdtBoundary, ancestorContainerKey, ancestorContainerSdt, @@ -183,6 +189,8 @@ const renderEmbeddedTable = ( fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, + continuesFromPrev, + continuesOnNext, }); const applyFragmentFrame = (el: HTMLElement, frag: Fragment): void => { @@ -289,7 +297,7 @@ function renderPartialEmbeddedTable(params: { } const visibleHeight = rowSlices.reduce( - (height, rowSlice) => + (height, rowSlice, index) => height + computeRenderedTableFragmentHeight({ block, @@ -297,6 +305,8 @@ function renderPartialEmbeddedTable(params: { fromRow: rowSlice.fromRow, toRow: rowSlice.toRow, partialRow: rowSlice.partialRow, + continuesFromPrev: localFrom > 0 || index > 0, + continuesOnNext: localTo < totalTableSegments || index < rowSlices.length - 1, }), 0, ); @@ -325,6 +335,8 @@ function renderPartialEmbeddedTable(params: { fromRow: rowSlice.fromRow, toRow: rowSlice.toRow, partialRow: rowSlice.partialRow, + continuesFromPrev: localFrom > 0 || index > 0, + continuesOnNext: localTo < totalTableSegments || index < rowSlices.length - 1, }); const tableResult = renderEmbeddedTable({ doc, @@ -339,6 +351,8 @@ function renderPartialEmbeddedTable(params: { fromRow: rowSlice.fromRow, toRow: rowSlice.toRow, partialRow: rowSlice.partialRow, + continuesFromPrev: localFrom > 0 || index > 0, + continuesOnNext: localTo < totalTableSegments || index < rowSlices.length - 1, sdtBoundary: effectiveSdtBoundary && rowSlices.length > 1 ? { 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 1a1413dc71..d621d86932 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -401,6 +401,41 @@ describe('renderTableFragment', () => { expect(element.style.borderLeftWidth).toBe('2px'); }); + it('suppresses vertical separate outer borders on continuation edges', () => { + const block: TableBlock = { + ...createTestTableBlock(), + attrs: { + borderCollapse: 'separate', + borders: { + top: { style: 'single', width: 2, color: '#111111' }, + right: { style: 'single', width: 2, color: '#222222' }, + bottom: { style: 'single', width: 2, color: '#333333' }, + left: { style: 'single', width: 2, color: '#444444' }, + }, + }, + }; + const measure = createTestTableMeasure(); + + const element = renderTableFragment({ + doc, + fragment: { ...createTestTableFragment(), continuesFromPrev: true, continuesOnNext: true }, + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + expect(element.style.borderTopWidth).toBe(''); + expect(element.style.borderRightWidth).toBe('2px'); + expect(element.style.borderBottomWidth).toBe(''); + expect(element.style.borderLeftWidth).toBe('2px'); + }); + it('renders the outer right border for a merged header cell in collapsed mode', () => { const block: TableBlock = { kind: 'table', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index afd35449da..1b73059a7d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -251,6 +251,9 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); // Cell spacing pre-computed by the resolver; no cross-stage import needed. + const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + const drawsSeparateTop = borderCollapse !== 'separate' || fragment.continuesFromPrev !== true; + const drawsSeparateBottom = borderCollapse !== 'separate' || fragment.continuesOnNext !== true; // Add metadata for interactive table resizing if (fragment.metadata?.columnBoundaries) { @@ -293,7 +296,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // For each rendered row, determine which grid columns have cell boundaries // A boundary exists at column X if there's a cell that ENDS at column X (gridColumnStart + colSpan = X) // rowY includes outer spacing (before first row, between rows, after last) so segment positions match rendered cells - let rowY = cellSpacingPx; + let rowY = drawsSeparateTop ? cellSpacingPx : 0; for (let i = 0; i < renderedRows.length; i++) { const { rowIndex, height } = renderedRows[i]; const rowMeasure = measure.rows[rowIndex]; @@ -338,7 +341,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } } - rowY += height + cellSpacingPx; + rowY += height + (i === renderedRows.length - 1 && !drawsSeparateBottom ? 0 : cellSpacingPx); } const metadata: Record = { @@ -380,11 +383,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement container.setAttribute('data-sd-block-id', block.id); } - const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); if (borderCollapse === 'separate' && tableBorders) { - applyBorder(container, 'Top', borderValueToSpec(tableBorders.top)); + if (drawsSeparateTop) applyBorder(container, 'Top', borderValueToSpec(tableBorders.top)); applyBorder(container, 'Right', borderValueToSpec(isRtl ? tableBorders.left : tableBorders.right)); - applyBorder(container, 'Bottom', borderValueToSpec(tableBorders.bottom)); + if (drawsSeparateBottom) applyBorder(container, 'Bottom', borderValueToSpec(tableBorders.bottom)); applyBorder(container, 'Left', borderValueToSpec(isRtl ? tableBorders.right : tableBorders.left)); } @@ -401,7 +403,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement }); // First row starts after space before table content (space between table border and first row) - let y = cellSpacingPx; + let y = drawsSeparateTop ? cellSpacingPx : 0; // If this is a continuation fragment with repeated headers, render headers first. // NOTE: This header-then-body iteration must stay in sync with the metadata @@ -439,7 +441,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement cellSpacingPx, }); // Add row height + spacing after every row (including last) for outer spacing after last row - y += rowMeasure.height + cellSpacingPx; + const hasBodyRows = fragment.fromRow < fragment.toRow; + y += + rowMeasure.height + + (r === fragment.repeatHeaderCount - 1 && !hasBodyRows && !drawsSeparateBottom ? 0 : cellSpacingPx); } } @@ -608,7 +613,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement cellSpacingPx, }); // Add row height + spacing after every row (including last) for outer spacing after last row - y += actualRowHeight + cellSpacingPx; + y += actualRowHeight + (isLastRenderedBodyRow && !drawsSeparateBottom ? 0 : cellSpacingPx); } return container; From 1617630194c003d88e3e52f800e18e52789b76d9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:35:04 -0300 Subject: [PATCH 22/35] fix(painter-dom): keep zero-height media in border groups --- .../dom/src/table/renderTableCell.test.ts | 53 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 10 +++- 2 files changed, 62 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 31dcaecbef..b8182789ac 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -2863,6 +2863,59 @@ describe('renderTableCell', () => { expect(firstBorder.style.borderBottomWidth).toBe('2px'); }); + it('groups table-cell paragraph borders across zero-height inline media', () => { + const borders = { + top: { width: 1, style: 'solid' as const, color: '#111111' }, + bottom: { width: 1, style: 'solid' as const, color: '#111111' }, + between: { width: 2, style: 'dashed' as const, color: '#222222' }, + }; + const para1: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-zero-media-1', + runs: [{ text: 'First', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders }, + }; + const inlineImage: ImageBlock = { + kind: 'image', + id: 'cell-between-zero-media-image', + src: 'data:image/png;base64,AAA', + }; + const para2: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-zero-media-2', + runs: [{ text: 'Second', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders: { ...borders } }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + renderLine: (block) => { + const line = doc.createElement('div'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 0 }, paragraphMeasure], + width: 120, + height: 60, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-between-borders-across-zero-media', blocks: [para1, inlineImage, para2], attrs: {} }, + }); + + const firstWrapper = cellElement.querySelector('[data-block-id="cell-between-zero-media-1"]') + ?.parentElement as HTMLElement | null; + const secondWrapper = cellElement.querySelector('[data-block-id="cell-between-zero-media-2"]') + ?.parentElement as HTMLElement | null; + expect(firstWrapper?.dataset.betweenBorder).toBe('true'); + expect(secondWrapper?.dataset.suppressTopBorder).toBe('true'); + const firstBorder = getParagraphBorderLayer(firstWrapper!); + expect(firstBorder.style.borderBottomStyle).toBe('dashed'); + expect(firstBorder.style.borderBottomWidth).toBe('2px'); + }); + it('breaks table-cell between-border groups when between borders differ', () => { const para1: 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 badfa79577..d3b19a6414 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -541,6 +541,14 @@ const isAnchoredMediaBlock = ( (measure?.kind === 'image' || measure?.kind === 'drawing') && block.anchor?.isAnchored === true; +const isZeroHeightMediaBlock = ( + block: ParagraphBlock | TableBlock | ImageBlock | DrawingBlock | undefined, + measure: Measure | undefined, +): boolean => + (block?.kind === 'image' || block?.kind === 'drawing') && + (measure?.kind === 'image' || measure?.kind === 'drawing') && + getMeasuredBlockHeight(measure) <= 0; + const sliceSdtBoundaryForParagraph = ( baseBoundary: SdtBoundaryOptions | undefined, localStartLine: number, @@ -861,7 +869,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const blockStartGlobal = borderContextSegmentStart; const blockLineCount = blockLineCounts[index] ?? 0; borderContextSegmentStart += blockLineCount; - if (isAnchoredMediaBlock(block, measure)) { + if (isAnchoredMediaBlock(block, measure) || isZeroHeightMediaBlock(block, measure)) { return []; } const y = paragraphContextY; From 2bb63542c8e2f152d4dcdece29a540c9990bbe41 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:35:25 -0300 Subject: [PATCH 23/35] refactor(painter-dom): reuse shared style helper --- .../painters/dom/src/table/renderTableCell.ts | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index d3b19a6414..0a4fbae59a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -35,6 +35,7 @@ import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; import { computeBetweenBorderContext, type BetweenBorderInfo } from '../paragraph/borders/index.js'; import { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; +import { applyStyles } from '../utils/apply-styles.js'; import { computeRenderedTableFragmentHeight, createEmbeddedTableFragment, @@ -49,24 +50,6 @@ export function getCellSegmentCount(cell: TableCellMeasure): number { return getCellLines(cell).length; } -/** - * Applies inline CSS styles to an element, filtering out null/undefined/empty values. - * - * Only applies styles where the key exists in the element's style object and - * the value is non-null and non-empty. This prevents accidentally clearing - * existing styles with undefined values. - * - * @param el - The HTML element to apply styles to - * @param styles - Partial CSSStyleDeclaration with styles to apply - */ -const applyInlineStyles = (el: HTMLElement, styles: Partial): void => { - Object.entries(styles).forEach(([key, value]) => { - if (value != null && value !== '' && key in el.style) { - (el.style as unknown as Record)[key] = String(value); - } - }); -}; - /** * Parameters for rendering a nested table inside a table cell. * @@ -214,7 +197,7 @@ const renderEmbeddedTable = ( renderDrawingContent, applyFragmentFrame, applySdtDataset, - applyStyles: applyInlineStyles, + applyStyles, sdtBoundary, ancestorContainerKey, ancestorContainerSdt, From 08a87c036580de63d7da6c72d0c44627a5e96c4d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:36:39 -0300 Subject: [PATCH 24/35] refactor(painter-dom): move fragment context type --- .../dom/src/drawings/renderDrawingContent.ts | 2 +- .../dom/src/drawings/renderDrawingFragment.ts | 2 +- .../painters/dom/src/fragment-context.ts | 10 ++++++++++ .../painters/dom/src/images/image-fragment.ts | 2 +- .../painters/dom/src/renderer.ts | 20 +------------------ .../painters/dom/src/runs/render-line.ts | 2 +- .../painters/dom/src/runs/render-run.ts | 2 +- .../painters/dom/src/runs/text-run.ts | 2 +- .../painters/dom/src/runs/types.ts | 2 +- ...rResolvedTableFragment.integration.test.ts | 2 +- .../table/renderResolvedTableFragment.test.ts | 2 +- .../src/table/renderResolvedTableFragment.ts | 2 +- .../painters/dom/src/table/renderTableCell.ts | 3 ++- .../dom/src/table/renderTableFragment.test.ts | 2 +- .../dom/src/table/renderTableFragment.ts | 2 +- .../painters/dom/src/table/renderTableRow.ts | 2 +- .../src/textbox/renderTextboxContent.test.ts | 2 +- .../dom/src/textbox/renderTextboxContent.ts | 2 +- 18 files changed, 28 insertions(+), 35 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/fragment-context.ts diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts index c78ce73dbe..3f749069f1 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts @@ -17,7 +17,7 @@ import { createChartElement as renderChartToElement } from '../chart-renderer.js import { createDrawingImageElement, createShapeGroupImageElement } from '../images/drawing-image.js'; import type { BuildImageHyperlinkAnchor } from '../images/types.js'; import { applyAlphaToSVG, applyGradientToSVG } from '../svg-utils.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { hasShapeTextContent, renderTextboxContent } from '../textbox/renderTextboxContent.js'; import { createDrawingPlaceholder } from './placeholder.js'; diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts index ecef3183e1..fe86322e6e 100644 --- a/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts @@ -1,5 +1,5 @@ import type { DrawingBlock, DrawingFragment, ResolvedDrawingItem } from '@superdoc/contracts'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { applyStyles } from '../utils/apply-styles.js'; import type { BuildImageHyperlinkAnchor } from '../images/types.js'; diff --git a/packages/layout-engine/painters/dom/src/fragment-context.ts b/packages/layout-engine/painters/dom/src/fragment-context.ts new file mode 100644 index 0000000000..b2334f8f4f --- /dev/null +++ b/packages/layout-engine/painters/dom/src/fragment-context.ts @@ -0,0 +1,10 @@ +import type { LayoutStoryLocator } from '@superdoc/contracts'; + +export type FragmentRenderContext = { + pageNumber: number; + totalPages: number; + section: 'body' | 'header' | 'footer'; + story?: LayoutStoryLocator; + pageNumberText?: string; + pageIndex?: number; +}; diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts index 015c28fd7a..55e83f403e 100644 --- a/packages/layout-engine/painters/dom/src/images/image-fragment.ts +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -1,6 +1,6 @@ import type { ImageBlock, ImageFragment, ResolvedImageItem, SdtMetadata } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { applyStyles } from '../utils/apply-styles.js'; import { createBlockImageContent } from './image-block.js'; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 53385a1efc..6f868f7e23 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -70,6 +70,7 @@ import type { RunRenderContext } from './runs/types.js'; import { renderImageFragment as renderImageFragmentElement } from './images/image-fragment.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyStyles } from './utils/apply-styles.js'; +import type { FragmentRenderContext } from './fragment-context.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; import { renderDrawingFragment as renderDrawingFragmentElement } from './drawings/renderDrawingFragment.js'; @@ -205,25 +206,6 @@ type PageDomState = { fragments: FragmentDomState[]; }; -/** - * Rendering context passed to fragment renderers containing page metadata. - * Provides information about the current page position and section for dynamic content like page numbers. - * - * @typedef {Object} FragmentRenderContext - * @property {number} pageNumber - Current page number (1-indexed) - * @property {number} totalPages - Total number of pages in the document - * @property {'body'|'header'|'footer'} section - Document section being rendered - * @property {string} [pageNumberText] - Optional formatted page number text (e.g., "Page 1 of 10") - */ -export type FragmentRenderContext = { - pageNumber: number; - totalPages: number; - section: 'body' | 'header' | 'footer'; - story?: LayoutStoryLocator; - pageNumberText?: string; - pageIndex?: number; -}; - export type PaintSnapshotLineStyle = { paddingLeftPx?: number; paddingRightPx?: number; diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index 76edade860..9a92257c55 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -375,7 +375,7 @@ export const renderLine = ({ type RunRenderBranchParams = { line: import('@superdoc/contracts').Line; - context: import('../renderer.js').FragmentRenderContext; + context: import('../fragment-context.js').FragmentRenderContext; el: HTMLElement; styleId?: string; runContext: RenderLineParams['runContext']; diff --git a/packages/layout-engine/painters/dom/src/runs/render-run.ts b/packages/layout-engine/painters/dom/src/runs/render-run.ts index 14a49373e2..5d05bd644b 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-run.ts @@ -1,5 +1,5 @@ import type { FieldAnnotationRun, ImageRun, MathRun, Run, TextRun } from '@superdoc/contracts'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import type { RunRenderContext, TrackedChangesRenderConfig } from './types.js'; import { renderFieldAnnotationRun } from './field-annotation-run.js'; import { renderImageRun } from './image-run.js'; diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts index 25ac8c50f9..1fdbe43a3e 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -1,7 +1,7 @@ import type { FlowRunLink, Run, TextRun } from '@superdoc/contracts'; import { normalizeBaselineShift, resolveBaseFontSizeForVerticalText } from '@superdoc/contracts'; import { assertPmPositions } from '../pm-position-validation.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js'; import type { RunRenderContext, TrackedChangesRenderConfig } from './types.js'; import { applyRunDataAttributes } from './hash.js'; diff --git a/packages/layout-engine/painters/dom/src/runs/types.ts b/packages/layout-engine/painters/dom/src/runs/types.ts index aff98713af..2c7ae11374 100644 --- a/packages/layout-engine/painters/dom/src/runs/types.ts +++ b/packages/layout-engine/painters/dom/src/runs/types.ts @@ -1,5 +1,5 @@ import type { ImageHyperlink, ParagraphBlock, Run, SdtMetadata, TrackedChangesMode } from '@superdoc/contracts'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; export type RenderedLineInfo = { el: HTMLElement; diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.integration.test.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.integration.test.ts index fb34e6e601..d2e7b9e861 100644 --- a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.integration.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.integration.test.ts @@ -7,7 +7,7 @@ import type { TableFragment, TableMeasure, } from '@superdoc/contracts'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { renderResolvedTableFragment } from './renderResolvedTableFragment.js'; describe('renderResolvedTableFragment integration', () => { diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts index b9cf3ae6bf..52083aa33c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.test.ts @@ -8,7 +8,7 @@ import type { TableFragment, TableMeasure, } from '@superdoc/contracts'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { renderDrawingContent } from '../drawings/renderDrawingContent.js'; import { renderResolvedTableFragment } from './renderResolvedTableFragment.js'; import { renderTableFragment } from './renderTableFragment.js'; diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts index 4541fd0f6c..65d3e96092 100644 --- a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts @@ -13,7 +13,7 @@ import type { import { expandRunsForInlineNewlines } from '@superdoc/contracts'; import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from '../images/hyperlink.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import type { SdtBoundaryOptions } from '../sdt/container.js'; import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; import { applyStyles } from '../utils/apply-styles.js'; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 0a4fbae59a..70223c9b23 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -19,7 +19,8 @@ import type { } from '@superdoc/contracts'; import { getCellLines, normalizeZIndex } from '@superdoc/contracts'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; +import type { RenderedLineInfo } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { renderTableImageFrame } from '../images/table-image-frame.js'; import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; 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 d621d86932..d23e049b2f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -14,7 +14,7 @@ import type { ParagraphBlock, SdtMetadata, } from '@superdoc/contracts'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; /** * Create a minimal table block for testing diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 1b73059a7d..76842ed895 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -11,7 +11,7 @@ import type { import { getTableVisualDirection } from '@superdoc/contracts'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { renderTableRow } from './renderTableRow.js'; import { applySdtContainerChrome, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index eeb751c718..a1c9bf754a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -18,7 +18,7 @@ import { swapCellBordersLR, } from './border-utils.js'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import type { SdtAncestorOptions } from '../sdt/container.js'; type TableRowMeasure = TableMeasure['rows'][number]; diff --git a/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts index ea56feb885..f2171336e0 100644 --- a/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts +++ b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { hasShapeTextContent, renderTextboxContent } from './renderTextboxContent.js'; describe('renderTextboxContent', () => { diff --git a/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts index df45d18973..754ab508fc 100644 --- a/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts +++ b/packages/layout-engine/painters/dom/src/textbox/renderTextboxContent.ts @@ -1,6 +1,6 @@ import type { ShapeTextContent } from '@superdoc/contracts'; import { createShapeTextImageElement } from '../images/drawing-image.js'; -import type { FragmentRenderContext } from '../renderer.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; import { validateHexColor } from '../svg-utils.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; From 691129e4e6092ba0bd7af11fb82af3a4dea715a9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:38:48 -0300 Subject: [PATCH 25/35] refactor(painter-dom): fold sdt ancestor state --- .../src/paragraph/renderParagraphContent.ts | 6 ---- .../painters/dom/src/sdt/container.test.ts | 8 ++--- .../painters/dom/src/sdt/container.ts | 6 ++-- .../dom/src/table/renderTableCell.test.ts | 20 ++++++------- .../painters/dom/src/table/renderTableCell.ts | 30 ------------------- .../dom/src/table/renderTableFragment.test.ts | 2 +- .../dom/src/table/renderTableFragment.ts | 17 +---------- .../painters/dom/src/table/renderTableRow.ts | 8 ----- 8 files changed, 18 insertions(+), 79 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 71ca0b8ba2..898f438955 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -83,8 +83,6 @@ export type RenderParagraphContentParams = { betweenInfo?: BetweenBorderInfo; sdtBoundary?: SdtBoundaryOptions; spacingPolicy?: ParagraphSpacingPolicy; - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; @@ -124,8 +122,6 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re betweenInfo, sdtBoundary, spacingPolicy, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -149,8 +145,6 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, }); 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 9e16d398ff..bef55e1e18 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -117,14 +117,14 @@ describe('SDT container chrome', () => { expect( shouldRenderSdtContainerChrome(childSdt, null, { - ancestorContainerKey: getSdtContainerKey(ancestorSdt), + ancestorContainerKeys: [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), + ancestorContainerKeys: [getSdtContainerKey(ancestorSdt)], }); expect(el.classList.contains('superdoc-structured-content-block')).toBe(false); }); @@ -143,7 +143,7 @@ describe('SDT container chrome', () => { expect( shouldRenderSdtContainerChrome(childSdt, ancestorSdt, { - ancestorContainerSdt: ancestorSdt, + ancestorContainerSdts: [ancestorSdt], }), ).toBe(true); }); @@ -157,7 +157,7 @@ describe('SDT container chrome', () => { expect( shouldRenderSdtContainerChrome(null, sharedSdt, { - ancestorContainerSdt: sharedSdt, + ancestorContainerSdts: [sharedSdt], }), ).toBe(false); }); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 28bb784a19..024e34bf11 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -24,8 +24,6 @@ export type SdtBoundaryOptions = { }; export type SdtAncestorOptions = { - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; ancestorContainerKeys?: readonly (string | null | undefined)[]; ancestorContainerSdts?: readonly (SdtMetadata | null | undefined)[]; }; @@ -82,12 +80,12 @@ export function shouldRenderSdtContainerChrome( if (!metadata) return false; const containerKey = getSdtContainerKey(sdt, containerSdt); - const ancestorKeys = [options?.ancestorContainerKey, ...(options?.ancestorContainerKeys ?? [])]; + const ancestorKeys = options?.ancestorContainerKeys ?? []; if (containerKey && ancestorKeys.includes(containerKey)) { return false; } - const ancestorSdts = [options?.ancestorContainerSdt, ...(options?.ancestorContainerSdts ?? [])]; + const ancestorSdts = options?.ancestorContainerSdts ?? []; if (ancestorSdts.includes(metadata)) { 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 b8182789ac..a17e0ab782 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4799,7 +4799,7 @@ describe('renderTableCell', () => { ...createBaseDeps(), cellMeasure, cell, - ancestorContainerKey: 'structuredContent:table-sdt', + ancestorContainerKeys: ['structuredContent:table-sdt'], }); expect(cellElement.style.overflow).toBe('hidden'); @@ -4852,7 +4852,7 @@ describe('renderTableCell', () => { blocks: [para], attrs: {}, }, - ancestorContainerSdt: sharedSdt, + ancestorContainerSdts: [sharedSdt], }); expect(cellElement.style.overflow).toBe('hidden'); @@ -5346,8 +5346,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorContainerKey: 'structuredContent:ancestor-table-sdt', - ancestorContainerSdt: sharedSdt, + ancestorContainerKeys: ['structuredContent:ancestor-table-sdt'], + ancestorContainerSdts: [sharedSdt], }); const tableElement = cellElement.querySelector('[data-block-id="nested-ancestor-sdt-table"]') as HTMLElement; @@ -5439,8 +5439,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorContainerKey: 'structuredContent:outer-table-sdt', - ancestorContainerSdt: sharedSdt, + ancestorContainerKeys: ['structuredContent:outer-table-sdt'], + ancestorContainerSdts: [sharedSdt], }); expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); @@ -5537,8 +5537,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorContainerKey: 'structuredContent:outer-table-sdt', - ancestorContainerSdt: ancestorSdt, + ancestorContainerKeys: ['structuredContent:outer-table-sdt'], + ancestorContainerSdts: [ancestorSdt], }); expect(cellElement.style.overflow).toBe('visible'); @@ -5629,8 +5629,8 @@ describe('renderTableCell', () => { blocks: [nestedTable], attrs: {}, }, - ancestorContainerKey: 'structuredContent:outer-table-sdt', - ancestorContainerSdt: ancestorSdt, + ancestorContainerKeys: ['structuredContent:outer-table-sdt'], + ancestorContainerSdts: [ancestorSdt], }); const labels = cellElement.querySelectorAll('.superdoc-structured-content__label'); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 70223c9b23..1e1676962c 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -100,10 +100,6 @@ type EmbeddedTableRenderParams = { continuesOnNext?: boolean; /** 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; /** 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 */ @@ -159,8 +155,6 @@ const renderEmbeddedTable = ( continuesFromPrev, continuesOnNext, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -200,8 +194,6 @@ const renderEmbeddedTable = ( applySdtDataset, applyStyles, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome: () => { @@ -234,8 +226,6 @@ function renderPartialEmbeddedTable(params: { renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; sdtBoundary?: SdtBoundaryOptions; - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; @@ -254,8 +244,6 @@ function renderPartialEmbeddedTable(params: { renderDrawingContent, applySdtDataset, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -349,8 +337,6 @@ function renderPartialEmbeddedTable(params: { : effectiveSdtBoundary.showLabel && index === 0, } : effectiveSdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -420,10 +406,6 @@ type TableCellRenderDependencies = { context: FragmentRenderContext; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** 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; /** 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 */ @@ -469,8 +451,6 @@ type TableCellParagraphRenderParams = { context: FragmentRenderContext; renderLine: TableCellRenderDependencies['renderLine']; applySdtDataset: TableCellRenderDependencies['applySdtDataset']; - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; @@ -567,8 +547,6 @@ const renderTableCellParagraphBlock = ({ context, renderLine, applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -612,8 +590,6 @@ const renderTableCellParagraphBlock = ({ sdtBoundary: sliceSdtBoundaryForParagraph(sdtBoundary, localStartLine, localEndLine, blockLineCount), continuesFromPrev: localStartLine > 0, continuesOnNext: localEndLine < blockLineCount, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome: () => { @@ -704,8 +680,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, context, applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -910,8 +884,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, applySdtDataset, sdtBoundary: sdtBoundaries[i], - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -1023,8 +995,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen context, renderLine, applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, 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 d23e049b2f..c549c1ed91 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -349,7 +349,7 @@ describe('renderTableFragment', () => { measure, cellSpacingPx: 0, effectiveColumnWidths: measure.columnWidths, - ancestorContainerKey: 'structuredContent:outer-sdt', + ancestorContainerKeys: ['structuredContent:outer-sdt'], renderLine: () => doc.createElement('div'), applyFragmentFrame: () => {}, applySdtDataset: () => {}, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 76842ed895..bdfa6a1d0d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -48,10 +48,6 @@ 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; /** 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 */ @@ -160,8 +156,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement effectiveColumnWidths, context, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -226,8 +220,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // Apply SDT container styling (document sections, structured content blocks) if ( applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, }) @@ -238,14 +230,11 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); 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( + const nextAncestorContainerSdts = [...(ancestorContainerSdts ?? []), 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); @@ -430,8 +419,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorContainerKey: nextAncestorContainerKey, - ancestorContainerSdt: nextAncestorContainerSdt, ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, @@ -599,8 +586,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorContainerKey: nextAncestorContainerKey, - ancestorContainerSdt: nextAncestorContainerSdt, ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index a1c9bf754a..f45118b467 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -174,10 +174,6 @@ type TableRowRenderDependencies = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** 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; /** 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 */ @@ -263,8 +259,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { captureLineSnapshot, renderDrawingContent, applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -439,8 +433,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderDrawingContent, context, applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, From 3ed4c43f5a95d70e62598a23a3f0c3841ed93970 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:47:17 -0300 Subject: [PATCH 26/35] refactor(painter-dom): import sdt dataset helpers --- .../paragraph/renderParagraphContent.test.ts | 5 -- .../src/paragraph/renderParagraphContent.ts | 6 +- .../src/paragraph/renderParagraphFragment.ts | 7 --- .../painters/dom/src/renderer.ts | 2 - .../src/table/renderResolvedTableFragment.ts | 3 - .../dom/src/table/renderTableCell.test.ts | 17 ++--- .../painters/dom/src/table/renderTableCell.ts | 19 +----- .../dom/src/table/renderTableFragment.test.ts | 63 ------------------- .../dom/src/table/renderTableFragment.ts | 10 +-- .../dom/src/table/renderTableRow.test.ts | 1 - .../painters/dom/src/table/renderTableRow.ts | 6 -- 11 files changed, 7 insertions(+), 132 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts index 92f0d6e1d2..b20b4de82d 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -40,7 +40,6 @@ describe('renderParagraphContent', () => { lineIndexOffset: 0, linesOverride: measure.lines.slice(0, 1), continuesOnNext: true, - applySdtDataset: () => {}, renderLine: () => doc.createElement('div'), }); @@ -75,7 +74,6 @@ describe('renderParagraphContent', () => { localEndLine: 2, lineIndexOffset: 0, linesOverride: [line(0), line(1)], - applySdtDataset: () => {}, renderLine: ({ lineIndex, isLastLine, skipJustify }) => { renderedLines.push({ lineIndex, isLastLine, skipJustify }); return doc.createElement('div'); @@ -128,7 +126,6 @@ describe('renderParagraphContent', () => { localEndLine: 1, markerWidth: 10, markerTextWidth: 8, - applySdtDataset: () => {}, renderLine: () => { lineEl = doc.createElement('div'); return lineEl; @@ -183,7 +180,6 @@ describe('renderParagraphContent', () => { localStartLine: 0, localEndLine: 1, resolvedContent, - applySdtDataset: () => {}, renderLine: () => doc.createElement('div'), }); @@ -229,7 +225,6 @@ describe('renderParagraphContent', () => { localEndLine: 1, resolvedContent, convertFinalParagraphMark: true, - applySdtDataset: () => {}, renderLine: () => { const lineEl = doc.createElement('div'); const mark = doc.createElement('span'); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 898f438955..5ea02313f5 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -5,7 +5,6 @@ import type { ParagraphMeasure, ResolvedParagraphContent, Run, - SdtMetadata, SourceAnchor, } from '@superdoc/contracts'; import { @@ -20,6 +19,7 @@ import { type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; +import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; import { applyParagraphLineIndentation, @@ -86,8 +86,6 @@ export type RenderParagraphContentParams = { ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; renderLine: ParagraphRenderLine; renderDropCap?: ParagraphRenderDropCap; captureLineSnapshot?: ( @@ -125,8 +123,6 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, - applySdtDataset, - applyContainerSdtDataset, renderDropCap, lineTopOffset = 0, } = params; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 3b89dcce38..47ddfc758b 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -4,7 +4,6 @@ import type { ParagraphBlock, ParagraphMeasure, ResolvedFragmentItem, - SdtMetadata, } from '@superdoc/contracts'; import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; @@ -24,8 +23,6 @@ type RenderParagraphFragmentParams = { applyStyles: ApplyStyles; applyResolvedFragmentFrame: (el: HTMLElement, item: ResolvedFragmentItem, fragment: ParaFragment) => void; applyFragmentFrame: (el: HTMLElement, fragment: ParaFragment) => void; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - applyContainerSdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; renderLine: (input: ParagraphRenderLineInput) => HTMLElement; captureLineSnapshot: ( lineEl: HTMLElement, @@ -46,8 +43,6 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): applyStyles, applyResolvedFragmentFrame, applyFragmentFrame, - applySdtDataset, - applyContainerSdtDataset, renderLine, captureLineSnapshot, createErrorPlaceholder, @@ -121,8 +116,6 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): resolvedContent: content, betweenInfo, sdtBoundary, - applySdtDataset, - applyContainerSdtDataset, renderDropCap: (descriptor, dropCapMeasure) => renderDropCap(doc, descriptor, dropCapMeasure), renderLine, captureLineSnapshot: (lineEl, options) => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 6f868f7e23..5e733a42fb 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2473,8 +2473,6 @@ export class DomPainter { applyResolvedFragmentFrame: (el, item, paraFragment) => this.applyResolvedFragmentFrame(el, item, paraFragment, context.section, context.story), applyFragmentFrame: (el, paraFragment) => this.applyFragmentFrame(el, paraFragment, context.section, context.story), - applySdtDataset, - applyContainerSdtDataset, renderLine: ({ block, line, diff --git a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts index 65d3e96092..2296307489 100644 --- a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts @@ -15,7 +15,6 @@ import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/ import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from '../images/hyperlink.js'; import type { FragmentRenderContext } from '../fragment-context.js'; import type { SdtBoundaryOptions } from '../sdt/container.js'; -import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; import { applyStyles } from '../utils/apply-styles.js'; import { renderTableFragment } from './renderTableFragment.js'; @@ -156,8 +155,6 @@ export const renderResolvedTableFragment = ({ }, renderDrawingContent: renderDrawingContentForTableCell, applyFragmentFrame: (element, innerFragment) => applyFragmentFrame(element, innerFragment, context.section), - applySdtDataset, - applyContainerSdtDataset, applyStyles, }); 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 a17e0ab782..26985dfe30 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -96,9 +96,6 @@ describe('renderTableCell', () => { useDefaultBorder: false, context: { sectionIndex: 0, pageIndex: 0, columnIndex: 0 }, renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), - applySdtDataset: () => { - // noop for tests - }, }); it('uses an end-of-cell mark for the final paragraph in a table cell', () => { @@ -254,9 +251,6 @@ describe('renderTableCell', () => { const { cellElement } = renderTableCell({ ...createBaseDeps(), - applySdtDataset: (el, metadata) => { - if (el && metadata?.id) el.dataset.sdtId = metadata.id; - }, cellMeasure: { blocks: [{ kind: 'image' as const, width: 50, height: 40 }], width: 80, @@ -4474,6 +4468,8 @@ describe('renderTableCell', () => { it('applies drawing SDT metadata from attrs.sdt only', () => { const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', id: 'drawing-sdt', tag: 'Drawing SDT', alias: 'Drawing', @@ -4496,7 +4492,6 @@ describe('renderTableCell', () => { height: 100, }; - const appliedMetadata: Array = []; const { cellElement } = renderTableCell({ ...createBaseDeps(), cellMeasure: { @@ -4513,15 +4508,12 @@ describe('renderTableCell', () => { attrs: {}, }, renderDrawingContent: () => doc.createElement('div'), - applySdtDataset: (_el, metadata) => { - appliedMetadata.push(metadata); - }, }); const drawingWrapper = cellElement.querySelector('.superdoc-table-drawing')?.parentElement as HTMLElement | null; expect(drawingWrapper).toBeTruthy(); - expect(appliedMetadata).toContain(sdt); - expect(appliedMetadata).not.toContain(vectorShapeBlock.attrs as unknown as SdtMetadata); + expect(drawingWrapper?.dataset.sdtId).toBe('drawing-sdt'); + expect(drawingWrapper?.dataset.sdtType).toBe('structuredContent'); }); it('should apply width and height styles to returned element', () => { @@ -6408,7 +6400,6 @@ describe('RTL cell padding swap', () => { useDefaultBorder: false, context: { sectionIndex: 0, pageIndex: 0, columnIndex: 0 }, renderLine: () => doc.createElement('div'), - applySdtDataset: () => {}, }); const cellMeasure: TableCellMeasure = { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 1e1676962c..ba93e661d1 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -11,7 +11,6 @@ import type { ParagraphBlock, ParagraphMeasure, PartialRowInfo, - SdtMetadata, TableBlock, TableMeasure, WrapExclusion, @@ -30,6 +29,7 @@ import { type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; +import { applySdtDataset } from '../sdt/dataset.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; @@ -86,8 +86,6 @@ type EmbeddedTableRenderParams = { ) => void; /** Optional callback to render non-image drawing content (shapes, charts, etc.) */ renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; - /** Function to apply SDT metadata as data attributes */ - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** Starting row index for partial rendering (inclusive, default 0) */ fromRow?: number; /** Ending row index for partial rendering (exclusive, default all rows) */ @@ -131,7 +129,6 @@ type EmbeddedTableRenderParams = { * measure: nestedTableMeasure, * context, * renderLine, - * applySdtDataset, * }); * cellContent.appendChild(tableEl); * ``` @@ -148,7 +145,6 @@ const renderEmbeddedTable = ( renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, @@ -191,7 +187,6 @@ const renderEmbeddedTable = ( captureLineSnapshot, renderDrawingContent, applyFragmentFrame, - applySdtDataset, applyStyles, sdtBoundary, ancestorContainerKeys, @@ -224,7 +219,6 @@ function renderPartialEmbeddedTable(params: { renderLine: EmbeddedTableRenderParams['renderLine']; captureLineSnapshot?: EmbeddedTableRenderParams['captureLineSnapshot']; renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; - applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; sdtBoundary?: SdtBoundaryOptions; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; @@ -242,7 +236,6 @@ function renderPartialEmbeddedTable(params: { renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, sdtBoundary, ancestorContainerKeys, ancestorContainerSdts, @@ -319,7 +312,6 @@ function renderPartialEmbeddedTable(params: { renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, fromRow: rowSlice.fromRow, toRow: rowSlice.toRow, partialRow: rowSlice.partialRow, @@ -404,8 +396,6 @@ type TableCellRenderDependencies = { renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; /** Rendering context */ context: FragmentRenderContext; - /** Function to apply SDT metadata as data attributes */ - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** 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 */ @@ -450,7 +440,6 @@ type TableCellParagraphRenderParams = { betweenInfo?: BetweenBorderInfo; context: FragmentRenderContext; renderLine: TableCellRenderDependencies['renderLine']; - applySdtDataset: TableCellRenderDependencies['applySdtDataset']; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; @@ -546,7 +535,6 @@ const renderTableCellParagraphBlock = ({ betweenInfo, context, renderLine, - applySdtDataset, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -596,7 +584,6 @@ const renderTableCellParagraphBlock = ({ cellEl.style.overflow = 'visible'; onSdtContainerChrome?.(); }, - applySdtDataset, renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => renderLine(block, line, context, lineIndex, isLastLine, resolvedListTextStartPx), convertFinalParagraphMark: isLastBlockInCell, @@ -660,7 +647,6 @@ const renderTableCellParagraphBlock = ({ * return el; * }, * context, - * applySdtDataset * }); * container.appendChild(cellElement); * ``` @@ -679,7 +665,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot, renderDrawingContent, context, - applySdtDataset, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -882,7 +867,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, sdtBoundary: sdtBoundaries[i], ancestorContainerKeys, ancestorContainerSdts, @@ -994,7 +978,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen betweenInfo: betweenInfoByOriginalBlockIndex.get(i), context, renderLine, - applySdtDataset, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, 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 c549c1ed91..afcf5eb8ce 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -124,9 +124,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // The table renderer owns PM range metadata for table wrappers. }, - applySdtDataset: () => { - // Not relevant to this metadata test. - }, applyStyles: () => { // Not relevant to this metadata test. }, @@ -252,7 +249,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: measure.columnWidths, renderLine: () => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: (el, styles) => Object.assign(el.style, styles), }); @@ -352,7 +348,6 @@ describe('renderTableFragment', () => { ancestorContainerKeys: ['structuredContent:outer-sdt'], renderLine: () => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: (el, styles) => Object.assign(el.style, styles), }); @@ -391,7 +386,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: measure.columnWidths, renderLine: () => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }); @@ -426,7 +420,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: measure.columnWidths, renderLine: () => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }); @@ -555,9 +548,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -588,9 +578,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -630,9 +617,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -683,9 +667,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -726,9 +707,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -761,9 +739,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -792,9 +767,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -828,9 +800,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -862,9 +831,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -890,9 +856,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -925,9 +888,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -962,9 +922,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1007,9 +964,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1107,7 +1061,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }); @@ -1152,7 +1105,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }); @@ -1277,9 +1229,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1433,9 +1382,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1549,9 +1495,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1728,9 +1671,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1876,7 +1816,6 @@ describe('renderTableFragment', () => { renderLine: (_block: ParagraphBlock, _line: unknown, _ctx: unknown, _lineIndex: number, _isLastLine: boolean) => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }; @@ -2004,7 +1943,6 @@ describe('renderTableFragment', () => { renderLine: () => doc.createElement('div'), applyStyles: (e, s) => Object.assign(e.style, s), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, }); // Ghost cell for col 0 (rowSpan=2, width=100) should be mirrored. @@ -2073,7 +2011,6 @@ describe('renderTableFragment', () => { renderLine: () => doc.createElement('div'), applyStyles: (e, s) => Object.assign(e.style, s), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, }); // Cells should be mirrored: col 0 at x=100, col 1 at x=0 diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index bdfa6a1d0d..a2fc0a97ec 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -21,6 +21,7 @@ import { type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; +import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; import { getTableCellGridBounds } from './grid-geometry.js'; @@ -72,10 +73,6 @@ export type TableRenderDependencies = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply fragment positioning and dimensions */ applyFragmentFrame: (el: HTMLElement, fragment: Fragment) => void; - /** Function to apply SDT metadata as data attributes */ - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Function to apply container SDT metadata as data attributes */ - applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** Function to apply CSS styles to an element */ applyStyles: ApplyStylesFn; }; @@ -140,7 +137,6 @@ export type TableRenderDependencies = { * effectiveColumnWidths: tableMeasure.columnWidths, * renderLine, * applyFragmentFrame, - * applySdtDataset, * applyStyles * }); * container.appendChild(tableElement); @@ -163,8 +159,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applyFragmentFrame, - applySdtDataset, - applyContainerSdtDataset, applyStyles, } = deps; @@ -418,7 +412,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, @@ -585,7 +578,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 4c1661ced4..8446109920 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -44,7 +44,6 @@ describe('renderTableRow', () => { tableIndent: 0, context: { sectionIndex: 0, pageIndex: 0, columnIndex: 0 }, renderLine: () => doc.createElement('div'), - applySdtDataset: () => {}, cellSpacingPx: 6, ...overrides, }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index f45118b467..0ea7c0e17b 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -4,7 +4,6 @@ import type { Line, ParagraphBlock, PartialRowInfo, - SdtMetadata, TableBlock, TableBorders, TableMeasure, @@ -172,8 +171,6 @@ type TableRowRenderDependencies = { ) => void; /** Function to render non-image drawing content (shapes, charts, etc.) */ renderDrawingContent?: (block: DrawingBlock) => HTMLElement; - /** Function to apply SDT metadata as data attributes */ - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; /** 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 */ @@ -235,7 +232,6 @@ type TableRowRenderDependencies = { * tableBorders, * context, * renderLine, - * applySdtDataset * }); * // Appends all cell elements to container * ``` @@ -258,7 +254,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -432,7 +427,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { captureLineSnapshot, renderDrawingContent, context, - applySdtDataset, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, From 59bc5d5f67005f6623e1c401d60a5b4139a7442b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 14:58:50 -0300 Subject: [PATCH 27/35] refactor(painter-dom): inline pure render helpers --- .../painters/dom/src/drawings/drawingFrame.ts | 5 ++-- .../dom/src/drawings/tableDrawingFrame.ts | 5 +--- .../painters/dom/src/images/image-fragment.ts | 7 ++--- .../dom/src/images/table-image-frame.ts | 5 ++-- .../painters/dom/src/renderer.ts | 18 ++---------- .../painters/dom/src/runs/render-line.ts | 29 ++++++++----------- .../painters/dom/src/runs/text-run.ts | 3 +- .../painters/dom/src/runs/types.ts | 5 ---- .../painters/dom/src/table/renderTableCell.ts | 5 ---- 9 files changed, 23 insertions(+), 59 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts index d56b74e161..e128ef2895 100644 --- a/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts @@ -1,4 +1,5 @@ import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; +import { applySdtDataset } from '../sdt/dataset.js'; import { createDrawingPlaceholder } from './placeholder.js'; export type RenderDrawingContentForPlacement = ( @@ -18,7 +19,6 @@ export type RenderDrawingFrameParams = { placement: DrawingFramePlacement; className: string; renderDrawingContent?: RenderDrawingContentForPlacement; - applySdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; }; export const renderDrawingFrame = ({ @@ -29,7 +29,6 @@ export const renderDrawingFrame = ({ placement, className, renderDrawingContent, - applySdtDataset, }: RenderDrawingFrameParams): HTMLElement => { const wrapper = doc.createElement('div'); wrapper.style.position = placement.mode === 'anchored-table-cell' ? 'absolute' : 'relative'; @@ -51,7 +50,7 @@ export const renderDrawingFrame = ({ wrapper.style.flexShrink = placement.flexShrink; } } - applySdtDataset?.(wrapper, block.attrs?.sdt as SdtMetadata | undefined); + applySdtDataset(wrapper, block.attrs?.sdt as SdtMetadata | undefined); const inner = doc.createElement('div'); inner.classList.add(className); diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index d9c1cda23b..a512dbd781 100644 --- a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -1,4 +1,4 @@ -import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; +import type { DrawingBlock } from '@superdoc/contracts'; import { renderDrawingFrame, type RenderDrawingContentForPlacement } from './drawingFrame.js'; export type RenderTableDrawingFrameParams = { @@ -12,7 +12,6 @@ export type RenderTableDrawingFrameParams = { zIndex?: number; flexShrink?: string; renderDrawingContent?: RenderDrawingContentForPlacement; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; }; export const renderTableDrawingFrame = ({ @@ -26,7 +25,6 @@ export const renderTableDrawingFrame = ({ zIndex, flexShrink, renderDrawingContent, - applySdtDataset, }: RenderTableDrawingFrameParams): HTMLElement => { return renderDrawingFrame({ doc, @@ -39,7 +37,6 @@ export const renderTableDrawingFrame = ({ : { mode: 'flowing-table-cell', flexShrink }, className: 'superdoc-table-drawing', renderDrawingContent, - applySdtDataset, }); }; diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts index 55e83f403e..f32134aa3b 100644 --- a/packages/layout-engine/painters/dom/src/images/image-fragment.ts +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -1,6 +1,7 @@ -import type { ImageBlock, ImageFragment, ResolvedImageItem, SdtMetadata } from '@superdoc/contracts'; +import type { ImageBlock, ImageFragment, ResolvedImageItem } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; import type { FragmentRenderContext } from '../fragment-context.js'; +import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { applyStyles } from '../utils/apply-styles.js'; import { createBlockImageContent } from './image-block.js'; @@ -19,8 +20,6 @@ type RenderImageFragmentOptions = { ) => void; applyFragmentFrame: (el: HTMLElement, fragment: ImageFragment, section?: 'body' | 'header' | 'footer') => void; applyFragmentWrapperZIndex: (el: HTMLElement, fragment: ImageFragment) => void; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - applyContainerSdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; }; @@ -77,8 +76,6 @@ export const renderImageFragment = ({ applyResolvedFragmentFrame, applyFragmentFrame, applyFragmentWrapperZIndex, - applySdtDataset, - applyContainerSdtDataset, buildImageHyperlinkAnchor, createErrorPlaceholder, }: RenderImageFragmentOptions): HTMLElement => { diff --git a/packages/layout-engine/painters/dom/src/images/table-image-frame.ts b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts index f6a611bd0a..1faee2ac80 100644 --- a/packages/layout-engine/painters/dom/src/images/table-image-frame.ts +++ b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts @@ -1,5 +1,6 @@ -import type { ImageBlock, ImageFragmentMetadata, ImageMeasure, SdtMetadata } from '@superdoc/contracts'; +import type { ImageBlock, ImageFragmentMetadata, ImageMeasure } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { applySdtDataset } from '../sdt/dataset.js'; import { createBlockImageContent } from './image-block.js'; import type { BuildImageHyperlinkAnchor } from './types.js'; @@ -19,7 +20,6 @@ export type RenderTableImageFrameParams = { placement: TableImagePlacement; contentMaxWidth: number; contentMaxHeight: number; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; }; @@ -59,7 +59,6 @@ export const renderTableImageFrame = ({ placement, contentMaxWidth, contentMaxHeight, - applySdtDataset, buildImageHyperlinkAnchor, }: RenderTableImageFrameParams): HTMLElement => { const wrapper = doc.createElement('div'); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 5e733a42fb..aed32b5e42 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -6,7 +6,6 @@ import type { ImageFragment, ImageHyperlink, Line, - LineSegment, PageMargins, ParaFragment, ParagraphBlock, @@ -50,13 +49,8 @@ import { tableFragmentKey } from './table/fragmentKey.js'; import { getTableSnapshotFlags } from './table/snapshot.js'; import { computeSdtBoundaries } from './sdt/boundaries.js'; import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; -import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js'; -import { - createInlineSdtWrapper, - expandSdtWrapperPmRange, - resolveRunSdtId, - syncInlineSdtWrapperTypography, -} from './sdt/inline.js'; +import { applySdtDataset } from './sdt/dataset.js'; +import { createInlineSdtWrapper } from './sdt/inline.js'; import { collectSdtSnapshotEntitiesFromDomRoot, type PaintSnapshotStructuredContentBlockEntity, @@ -71,7 +65,6 @@ import { renderImageFragment as renderImageFragmentElement } from './images/imag import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; import { applyStyles } from './utils/apply-styles.js'; import type { FragmentRenderContext } from './fragment-context.js'; -import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; import { renderDrawingFragment as renderDrawingFragmentElement } from './drawings/renderDrawingFragment.js'; import { isWordArtTextboxWatermarkBlock } from './textbox/wordArtWatermark.js'; @@ -2551,8 +2544,6 @@ export class DomPainter { applyFragmentFrame: (el, imageFragment, section) => this.applyFragmentFrame(el, imageFragment, section, context.story), applyFragmentWrapperZIndex: this.applyFragmentWrapperZIndex.bind(this), - applySdtDataset, - applyContainerSdtDataset, buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind(this), createErrorPlaceholder: this.createErrorPlaceholder.bind(this), }); @@ -2649,12 +2640,7 @@ export class DomPainter { buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind( this, ) as RunRenderContext['buildImageHyperlinkAnchor'], - resolveTrackedChangesConfig, - applyTrackedChangeDecorations, - resolveRunSdtId, createInlineSdtWrapper: (sdt) => createInlineSdtWrapper(sdt, runContext), - syncInlineSdtWrapperTypography, - expandSdtWrapperPmRange, }; return runContext; } diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index 9a92257c55..924e59f53e 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -18,7 +18,10 @@ import { appendFormattingParagraphMark } from './formatting-marks.js'; import { textRunMergeSignature } from './hash.js'; import { isBreakRun, isFieldAnnotationRun, isImageRun, isLineBreakRun, isMathRun, renderRun } from './render-run.js'; import { renderInlineTabRun, renderPositionedTabRun } from './tab-run.js'; -import type { RenderLineParams } from './types.js'; +import { expandSdtWrapperPmRange, resolveRunSdtId, syncInlineSdtWrapperTypography } from '../sdt/inline.js'; +import { applyStyles } from '../utils/apply-styles.js'; +import { resolveTrackedChangesConfig } from './tracked-changes.js'; +import type { RenderLineParams, TrackedChangesRenderConfig } from './types.js'; /** * Type guard narrowing to the shared word layout contract type. @@ -28,14 +31,6 @@ function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { return isMinimalWordLayoutShared(value); } -const applyStyles = (el: HTMLElement, styles: Partial): void => { - Object.entries(styles).forEach(([key, value]) => { - if (value != null && value !== '' && key in el.style) { - (el.style as unknown as Record)[key] = String(value); - } - }); -}; - const countSpaces = (text: string): number => { let count = 0; for (let i = 0; i < text.length; i += 1) { @@ -184,7 +179,7 @@ export const renderLine = ({ if (lineRange.pmEnd != null) { el.dataset.pmEnd = String(lineRange.pmEnd); } - const trackedConfig = runContext.resolveTrackedChangesConfig(block); + const trackedConfig = resolveTrackedChangesConfig(block); // Preserve PM positions for DOM caret mapping on empty lines. if (runsForLine.length === 0) { @@ -379,7 +374,7 @@ type RunRenderBranchParams = { el: HTMLElement; styleId?: string; runContext: RenderLineParams['runContext']; - trackedConfig: ReturnType; + trackedConfig: TrackedChangesRenderConfig; }; const renderExplicitlyPositionedRuns = ({ @@ -478,7 +473,7 @@ const renderExplicitlyPositionedRuns = ({ * when the run has inline structuredContent metadata. */ const appendToLineGeo = (elem: HTMLElement, runForSdt: Run, elemLeftPx: number, elemWidthPx: number) => { - const resolved = runContext.resolveRunSdtId(runForSdt); + const resolved = resolveRunSdtId(runForSdt); const thisRunSdtId = resolved?.sdtId ?? null; if (thisRunSdtId !== geoSdtId) { @@ -496,10 +491,10 @@ const renderExplicitlyPositionedRuns = ({ geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; } - runContext.syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt); + syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt); elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); - runContext.expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); + expandSdtWrapperPmRange(geoSdtWrapper, (runForSdt as TextRun).pmStart, (runForSdt as TextRun).pmEnd); geoSdtWrapper.appendChild(elem); } else { el.appendChild(elem); @@ -696,7 +691,7 @@ const renderInlineRuns = ({ runsForLine.forEach((run) => { // Check if this run has inline structuredContent SDT - const resolved = runContext.resolveRunSdtId(run); + const resolved = resolveRunSdtId(run); const runSdtId = resolved?.sdtId ?? null; // If SDT context changed, close the current wrapper @@ -719,12 +714,12 @@ const renderInlineRuns = ({ if (resolved) { if (!currentInlineSdtWrapper) { currentInlineSdtWrapper = runContext.createInlineSdtWrapper(resolved.sdt); - runContext.syncInlineSdtWrapperTypography(currentInlineSdtWrapper, run); + syncInlineSdtWrapperTypography(currentInlineSdtWrapper, run); currentInlineSdtId = runSdtId; } // Typography is set when wrapper is created from the first run. // Follow-up (SD-2744): define a deterministic mixed-typography rule. - runContext.expandSdtWrapperPmRange(currentInlineSdtWrapper, run.pmStart, run.pmEnd); + expandSdtWrapperPmRange(currentInlineSdtWrapper, run.pmStart, run.pmEnd); currentInlineSdtWrapper.appendChild(elem); } else { el.appendChild(elem); diff --git a/packages/layout-engine/painters/dom/src/runs/text-run.ts b/packages/layout-engine/painters/dom/src/runs/text-run.ts index 1fdbe43a3e..d03952ea1e 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -8,6 +8,7 @@ import { applyRunDataAttributes } from './hash.js'; import { applyLinkAttributes, applyLinkDataset, buildLinkRenderData, enhanceAccessibility } from './links.js'; import { setTextContentWithFormattingSpaceMarks } from './formatting-marks.js'; import { normalizeRtlDateTokenForWordParity, resolveRunDirectionAttribute } from '../features/inline-direction/index.js'; +import { applyTrackedChangeDecorations } from './tracked-changes.js'; const DEFAULT_SUPERSCRIPT_RAISE_RATIO = 0.33; const DEFAULT_SUBSCRIPT_LOWER_RATIO = 0.14; @@ -246,7 +247,7 @@ export const renderTextRun = ( if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); elem.dataset.layoutEpoch = String(renderContext.layoutEpoch); if (trackedConfig) { - renderContext.applyTrackedChangeDecorations(elem, run, trackedConfig); + applyTrackedChangeDecorations(elem, run, trackedConfig); } renderContext.applySdtDataset(elem, run.sdt); diff --git a/packages/layout-engine/painters/dom/src/runs/types.ts b/packages/layout-engine/painters/dom/src/runs/types.ts index 2c7ae11374..90518ee661 100644 --- a/packages/layout-engine/painters/dom/src/runs/types.ts +++ b/packages/layout-engine/painters/dom/src/runs/types.ts @@ -33,12 +33,7 @@ export type RunRenderContext = { hyperlink: ImageHyperlink | undefined, display: string, ) => HTMLElement; - resolveTrackedChangesConfig: (block: ParagraphBlock) => TrackedChangesRenderConfig; - applyTrackedChangeDecorations: (elem: HTMLElement, run: Run, config: TrackedChangesRenderConfig) => void; - resolveRunSdtId: (run: Run) => { sdtId: string; sdt: SdtMetadata } | null; createInlineSdtWrapper: (sdt: SdtMetadata) => HTMLElement; - syncInlineSdtWrapperTypography: (wrapper: HTMLElement, runForSizing?: Run) => void; - expandSdtWrapperPmRange: (wrapper: HTMLElement, pmStart?: number | null, pmEnd?: number | null) => void; }; export type RenderLineParams = { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index ba93e661d1..0ebc00eefe 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -29,7 +29,6 @@ import { type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; -import { applySdtDataset } from '../sdt/dataset.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; @@ -913,7 +912,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen placement: { mode: 'flowing' }, contentMaxWidth: contentWidthPx, contentMaxHeight: contentHeightPx, - applySdtDataset, buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }); content.appendChild(imageWrapper); @@ -952,7 +950,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen position: 'relative', flexShrink: '0', renderDrawingContent: renderTableCellDrawingContent, - applySdtDataset, }); content.appendChild(drawingWrapper); flowCursorY += blockMeasure.height; @@ -1048,7 +1045,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen placement: { mode: 'anchored', left, top, zIndex }, contentMaxWidth: contentWidthPx, contentMaxHeight: contentHeightPx, - applySdtDataset, buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, }); content.appendChild(imageWrapper); @@ -1063,7 +1059,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen top, zIndex, renderDrawingContent: renderTableCellDrawingContent, - applySdtDataset, }); content.appendChild(drawingWrapper); } From eb61b03dd6dc0545a5387b675908c1adb454859f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 20 May 2026 16:02:44 -0300 Subject: [PATCH 28/35] fix(painter-dom): preserve cell spacing between adjacent partial embedded table slices When an embedded table is split across page breaks, adjacent row slices within the same outer cell now account for the table's cellSpacingPx gap. Both the slice positions (sliceTop) and the visible-height total include the inter-slice spacing, mirroring how full table fragments are laid out, so partial-slice rendering no longer collapses the gap. --- .../dom/src/table/renderTableCell.test.ts | 74 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 5 +- 2 files changed, 78 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 26985dfe30..15b2a8e760 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -5087,6 +5087,80 @@ describe('renderTableCell', () => { expect(tableFragments[0]?.parentElement?.style.height).toBe('36px'); }); + it('preserves spacing between adjacent partial embedded table row slices', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-adjacent-partial-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + }; + const makeInnerTableBlock = (id: string, rowCount: number): TableBlock => ({ + kind: 'table', + id, + rows: Array.from({ length: rowCount }, (_, index) => ({ + id: `${id}-row-${index}`, + cells: [{ id: `${id}-cell-${index}`, blocks: [nestedParagraph] }], + })), + }); + const makeInnerTableMeasure = (rowHeights: number[]): TableMeasure => ({ + kind: 'table', + rows: rowHeights.map((height) => ({ + height, + cells: [{ width: 80, height, gridColumnStart: 0, colSpan: 1, rowSpan: 1, blocks: [paragraphMeasure] }], + })), + columnWidths: [80], + totalWidth: 80, + totalHeight: rowHeights.reduce((sum, height) => sum + height, 0), + }); + const firstInnerTable = makeInnerTableBlock('first-adjacent-partial-inner', 2); + const secondInnerTable = makeInnerTableBlock('second-adjacent-partial-inner', 2); + const firstInnerMeasure = makeInnerTableMeasure([5, 7]); + const secondInnerMeasure = makeInnerTableMeasure([11, 13]); + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-adjacent-partial-table', + rows: [ + { id: 'nested-adjacent-row-1', cells: [{ id: 'nested-adjacent-cell-1', blocks: [firstInnerTable] }] }, + { id: 'nested-adjacent-row-2', cells: [{ id: 'nested-adjacent-cell-2', blocks: [secondInnerTable] }] }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 12, + cells: [{ width: 80, height: 12, gridColumnStart: 0, colSpan: 1, rowSpan: 1, blocks: [firstInnerMeasure] }], + }, + { + height: 24, + cells: [ + { width: 80, height: 24, gridColumnStart: 0, colSpan: 1, rowSpan: 1, blocks: [secondInnerMeasure] }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 42, + cellSpacingPx: 2, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { ...baseCellMeasure, blocks: [nestedMeasure], height: 20 }, + cell: { ...baseCell, blocks: [nestedTable] }, + fromLine: 1, + toLine: 3, + }); + + const tableFragments = cellElement.querySelectorAll( + '[data-block-id="nested-adjacent-partial-table"]', + ); + expect(tableFragments).toHaveLength(2); + expect(tableFragments[0]?.style.height).toBe('7px'); + expect(tableFragments[1]?.style.top).toBe('9px'); + expect(tableFragments[1]?.style.height).toBe('11px'); + expect(tableFragments[0]?.parentElement?.style.height).toBe('20px'); + }); + it('includes separate-border outer table height for embedded table fragments', () => { const nestedParagraph: 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 0ebc00eefe..71b1d5b636 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -16,7 +16,7 @@ import type { WrapExclusion, WrapTextMode, } from '@superdoc/contracts'; -import { getCellLines, normalizeZIndex } from '@superdoc/contracts'; +import { getCellLines, getCellSpacingPx, normalizeZIndex } from '@superdoc/contracts'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { RenderedLineInfo } from '../renderer.js'; import type { FragmentRenderContext } from '../fragment-context.js'; @@ -260,9 +260,11 @@ function renderPartialEmbeddedTable(params: { return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } + const internalSliceSpacingPx = tableMeasure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing); const visibleHeight = rowSlices.reduce( (height, rowSlice, index) => height + + (index > 0 ? internalSliceSpacingPx : 0) + computeRenderedTableFragmentHeight({ block, measure: tableMeasure, @@ -336,6 +338,7 @@ function renderPartialEmbeddedTable(params: { tableWrapper.appendChild(tableResult.element); hasSdtContainerChrome ||= tableResult.hasSdtContainerChrome; sliceTop += sliceHeight; + if (index < rowSlices.length - 1) sliceTop += internalSliceSpacingPx; }); return { From 8ee7c0839da91330cb391d04915efba79d4bbcaf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 10:07:49 -0300 Subject: [PATCH 29/35] fix(footnotes): render list markers in footnote paragraphs Extract the list rendering pipeline from numberingPlugin into a shared createListRenderingSync factory, and apply it in FootnotesBuilder so footnote paragraphs with numberingProperties get markerText, path, suffix, and justification populated before reaching toFlowBlocks. Previously only the main document body computed list markers, so numbered/bulleted footnotes rendered without markers. The helper is PM-agnostic (visitor + paragraph-property resolver) so the same code serves the editor plugin and the footnote builder. --- .../layout/FootnotesBuilder.ts | 65 ++++++++ .../tests/FootnotesBuilder.test.ts | 112 ++++++++++++- .../extensions/paragraph/listRenderingSync.js | 130 +++++++++++++++ .../paragraph/listRenderingSync.test.js | 157 ++++++++++++++++++ .../extensions/paragraph/numberingPlugin.js | 100 ++--------- 5 files changed, 474 insertions(+), 90 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.js create mode 100644 packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.test.js diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index 256d2dc826..20012b3180 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -23,12 +23,14 @@ import type { FlowBlock } from '@superdoc/contracts'; import { toFlowBlocks } from '@superdoc/pm-adapter'; import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; +import { resolveParagraphProperties } from '@superdoc/style-engine/ooxml'; import type { ProseMirrorJSON } from '../../types/EditorTypes.js'; import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; +import { createListRenderingSync } from '../../../extensions/paragraph/listRenderingSync.js'; // Re-export types for consumers export type { FootnoteReference, FootnotesLayoutInput }; @@ -40,6 +42,10 @@ export type { FootnoteReference, FootnotesLayoutInput }; /** Minimal shape of a converter object containing footnote data. */ export type ConverterLike = { footnotes?: Array<{ id?: unknown; content?: unknown[] }>; + numbering?: unknown; + translatedNumbering?: unknown; + translatedLinkedStyles?: unknown; + convertedXml?: unknown; }; export type NoteRenderOverride = { @@ -132,6 +138,7 @@ export function buildFootnotesInput( try { const footnoteDoc = resolveNoteDocJson(id, importedFootnotes, renderOverride); if (!footnoteDoc) return; + applyFootnoteListRendering(footnoteDoc, converter, converterContext); const result = toFlowBlocks(footnoteDoc, { blockIdPrefix: `footnote-${id}-`, @@ -265,6 +272,64 @@ function resolveNoteDocJson( }); } +function isPmJsonObject(value: unknown): value is ProseMirrorJSON & { attrs?: Record } { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function traversePmJson(node: ProseMirrorJSON, visitor: (node: ProseMirrorJSON) => boolean | void): void { + const result = visitor(node); + if (result === false || !Array.isArray(node.content)) return; + node.content.forEach((child) => { + if (isPmJsonObject(child)) { + traversePmJson(child, visitor); + } + }); +} + +function resolveFootnoteParagraphProperties( + node: ProseMirrorJSON & { attrs?: Record }, + converterContext: ConverterContext | undefined, +): Record { + const paragraphProperties = + typeof node.attrs?.paragraphProperties === 'object' && node.attrs.paragraphProperties !== null + ? (node.attrs.paragraphProperties as Record) + : {}; + + if (!converterContext) { + return paragraphProperties; + } + + return resolveParagraphProperties( + converterContext as never, + paragraphProperties as never, + converterContext.tableInfo, + ) as Record; +} + +function applyFootnoteListRendering( + footnoteDoc: ProseMirrorJSON, + converter: ConverterLike | null | undefined, + converterContext: ConverterContext | undefined, +): void { + const listRenderingSync = createListRenderingSync({ converter }); + let pos = 0; + + listRenderingSync.syncListRendering({ + visitNodes: (visit: (node: ProseMirrorJSON, pos: number) => boolean | void) => { + traversePmJson(footnoteDoc, (node) => visit(node, pos++)); + }, + resolveParagraphProperties: (node: ProseMirrorJSON & { attrs?: Record }) => + resolveFootnoteParagraphProperties(node, converterContext), + updateListRendering: ( + node: ProseMirrorJSON & { attrs?: Record }, + _pos: number, + listRendering: unknown, + ) => { + node.attrs = { ...(node.attrs ?? {}), listRendering: listRendering ?? null }; + }, + }); +} + function syncMarkerRun(target: Run, source: Run): void { target.kind = source.kind; target.text = source.text; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index 1766f4271b..a47a3ca2f4 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -1,24 +1,33 @@ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; import type { EditorState } from 'prosemirror-state'; import { buildFootnotesInput, type ConverterLike } from '../layout/FootnotesBuilder.js'; import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; import { toFlowBlocks } from '@superdoc/pm-adapter'; +const listMocks = vi.hoisted(() => ({ + allDefinitions: {}, + definitionDetailsByKey: new Map>(), +})); + // Mock toFlowBlocks vi.mock('@superdoc/pm-adapter', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - toFlowBlocks: vi.fn((_doc: unknown, opts?: { blockIdPrefix?: string }) => { + toFlowBlocks: vi.fn((doc: unknown, opts?: { blockIdPrefix?: string }) => { // Return mock blocks based on blockIdPrefix if (typeof opts?.blockIdPrefix === 'string') { const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); + const firstParagraph = (doc as { content?: Array<{ type?: string; attrs?: Record }> }) + ?.content?.[0]; + const listRendering = firstParagraph?.attrs?.listRendering as { markerText?: string } | undefined; return { blocks: [ { kind: 'paragraph', runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], + ...(listRendering ? { attrs: { wordLayout: { marker: { markerText: listRendering.markerText } } } } : {}), }, ], bookmarks: new Map(), @@ -29,6 +38,23 @@ vi.mock('@superdoc/pm-adapter', async (importOriginal) => { }; }); +vi.mock('@helpers/list-numbering-helpers.js', () => ({ + ListHelpers: { + getAllListDefinitions: vi.fn(() => listMocks.allDefinitions), + getListDefinitionDetails: vi.fn(({ numId, level }) => listMocks.definitionDetailsByKey.get(`${numId}:${level}`)), + }, +})); + +vi.mock('@helpers/orderedListUtils.js', () => ({ + generateOrderedListIndex: vi.fn(({ listLevel }) => `${listLevel.at(-1)}.`), +})); + +vi.mock('@core/super-converter/v2/importer/listImporter.js', () => ({ + docxNumberingHelpers: { + normalizeLvlTextChar: vi.fn(() => '•'), + }, +})); + // ============================================================================= // Test Helpers // ============================================================================= @@ -51,6 +77,15 @@ function createMockConverter(footnotes: Array<{ id: string; content: unknown[] } return { footnotes }; } +function createMockConverterWithNumbering(footnotes: Array<{ id: string; content: unknown[] }>): ConverterLike { + return { + footnotes, + numbering: {}, + translatedNumbering: {}, + convertedXml: {}, + }; +} + function createMockConverterContext(footnoteNumberById: Record): ConverterContext { return { footnoteNumberById } as ConverterContext; } @@ -64,6 +99,12 @@ function blocksFromResult(result: ReturnType) { // ============================================================================= describe('buildFootnotesInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + listMocks.allDefinitions = {}; + listMocks.definitionDetailsByKey = new Map(); + }); + describe('null/undefined inputs', () => { it('returns null when editorState is null', () => { const converter = createMockConverter([{ id: '1', content: [{ type: 'paragraph' }] }]); @@ -186,6 +227,73 @@ describe('buildFootnotesInput', () => { }); }); + it('adds listRendering to footnote paragraphs before layout conversion without mutating converter data', () => { + listMocks.allDefinitions = { 1: { 0: { start: '1' } } }; + listMocks.definitionDetailsByKey.set('1:0', { + lvlText: '%1.', + listNumberingType: 'decimal', + suffix: 'tab', + justification: 'left', + abstractId: 'abstract-1', + }); + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverterWithNumbering([ + { + id: '1', + content: [ + { + type: 'paragraph', + attrs: { paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } } }, + content: [{ type: 'text', text: 'Listed note' }], + }, + ], + }, + ]); + + buildFootnotesInput(editorState, converter, undefined, undefined); + + const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; + expect(docArg?.content?.[0]?.attrs?.listRendering).toEqual({ + markerText: '1.', + suffix: 'tab', + justification: 'left', + path: [1], + numberingType: 'decimal', + }); + expect((converter.footnotes?.[0].content?.[0] as { attrs?: Record })?.attrs?.listRendering).toBe( + undefined, + ); + }); + + it('returns footnote list blocks with wordLayout marker text', () => { + listMocks.allDefinitions = { 1: { 0: { start: '1' } } }; + listMocks.definitionDetailsByKey.set('1:0', { + lvlText: '%1.', + listNumberingType: 'decimal', + suffix: 'tab', + justification: 'left', + abstractId: 'abstract-1', + }); + const editorState = createMockEditorState([{ id: '1', pos: 10 }]); + const converter = createMockConverterWithNumbering([ + { + id: '1', + content: [ + { + type: 'paragraph', + attrs: { paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } } }, + content: [{ type: 'text', text: 'Listed note' }], + }, + ], + }, + ]); + + const result = buildFootnotesInput(editorState, converter, undefined, undefined); + const paragraphBlock = result?.blocksById.get('1')?.[0] as { attrs?: Record }; + + expect(paragraphBlock?.attrs?.wordLayout?.marker?.markerText).toBe('1.'); + }); + it('only includes footnotes that are referenced in the document', () => { const editorState = createMockEditorState([{ id: '1', pos: 10 }]); // Only ref 1 in doc const converter = createMockConverter([ diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.js b/packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.js new file mode 100644 index 0000000000..ef535401c9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.js @@ -0,0 +1,130 @@ +import { createNumberingManager } from './NumberingManager.js'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { generateOrderedListIndex } from '@helpers/orderedListUtils.js'; +import { docxNumberingHelpers } from '@core/super-converter/v2/importer/listImporter.js'; + +function getNodeTypeName(node) { + return typeof node?.type === 'string' ? node.type : node?.type?.name; +} + +function getNodeAttrs(node) { + return node?.attrs ?? {}; +} + +function getNumberingProperties(node, resolvedProperties) { + const attrs = getNodeAttrs(node); + return ( + resolvedProperties?.numberingProperties ?? + attrs.paragraphProperties?.numberingProperties ?? + attrs.numberingProperties ?? + null + ); +} + +function defaultResolveParagraphProperties(node) { + return getNodeAttrs(node).paragraphProperties || {}; +} + +function hasNumberingDefinitions(editor) { + return Boolean(editor?.converter?.numbering); +} + +export function applyStartSettingsFromDefinitions(numberingManager, definitionsMap) { + Object.entries(definitionsMap || {}).forEach(([numId, levels]) => { + Object.entries(levels || {}).forEach(([level, def]) => { + const start = parseInt(def?.start) || 1; + let restart = def?.restart; + if (restart != null) { + restart = parseInt(restart); + } + numberingManager.setStartSettings(numId, parseInt(level), start, restart, def.startOverridden); + }); + }); +} + +export function createListRenderingSync(editor, options = {}) { + const numberingManager = options.numberingManager ?? createNumberingManager(); + + const refreshStartSettings = () => { + const definitions = ListHelpers.getAllListDefinitions(editor); + applyStartSettingsFromDefinitions(numberingManager, definitions); + return definitions; + }; + + refreshStartSettings(); + + const calculateListRendering = ({ node, pos, resolvedProperties }) => { + const numberingProperties = getNumberingProperties(node, resolvedProperties); + if (!numberingProperties || !hasNumberingDefinitions(editor)) { + return null; + } + + const { numId, ilvl: level = 0 } = numberingProperties; + const definitionDetails = ListHelpers.getListDefinitionDetails({ numId, level, editor }); + + if (!definitionDetails || Object.keys(definitionDetails).length === 0) { + return null; + } + + let { lvlText, customFormat, listNumberingType, suffix, justification, abstractId } = definitionDetails; + listNumberingType = listNumberingType || 'decimal'; + const count = numberingManager.calculateCounter(numId, level, pos, abstractId); + numberingManager.setCounter(numId, level, pos, count, abstractId); + const path = numberingManager.calculatePath(numId, level, pos); + const markerText = + listNumberingType !== 'bullet' + ? (generateOrderedListIndex({ + listLevel: path, + lvlText, + listNumberingType, + customFormat, + }) ?? '') + : (docxNumberingHelpers.normalizeLvlTextChar(lvlText) ?? ''); + + return { + markerText, + suffix, + justification, + path, + numberingType: listNumberingType, + ...(customFormat ? { customFormat } : {}), + }; + }; + + const syncListRendering = ({ + visitNodes, + resolveParagraphProperties = defaultResolveParagraphProperties, + shouldPreserveParagraph = () => false, + updateListRendering, + }) => { + numberingManager.enableCache(); + try { + visitNodes((node, pos, context) => { + if (getNodeTypeName(node) !== 'paragraph') { + return; + } + + const resolvedProperties = resolveParagraphProperties(node, pos, context); + if (!getNumberingProperties(node, resolvedProperties)) { + return; + } + + if (shouldPreserveParagraph(node, pos, context)) { + return false; + } + + updateListRendering(node, pos, calculateListRendering({ node, pos, resolvedProperties }), context); + return false; + }); + } finally { + numberingManager.disableCache(); + } + }; + + return { + numberingManager, + refreshStartSettings, + calculateListRendering, + syncListRendering, + }; +} diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.test.js b/packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.test.js new file mode 100644 index 0000000000..90657f2e20 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/listRenderingSync.test.js @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createListRenderingSync } from './listRenderingSync.js'; +import { generateOrderedListIndex } from '@helpers/orderedListUtils.js'; + +const mocks = vi.hoisted(() => ({ + allDefinitions: {}, + definitionDetailsByKey: new Map(), +})); + +vi.mock('@helpers/list-numbering-helpers.js', () => ({ + ListHelpers: { + getAllListDefinitions: vi.fn(() => mocks.allDefinitions), + getListDefinitionDetails: vi.fn(({ numId, level }) => mocks.definitionDetailsByKey.get(`${numId}:${level}`)), + }, +})); + +vi.mock('@helpers/orderedListUtils.js', () => ({ + generateOrderedListIndex: vi.fn(({ listLevel }) => `${listLevel.at(-1)}.`), +})); + +vi.mock('@core/super-converter/v2/importer/listImporter.js', () => ({ + docxNumberingHelpers: { + normalizeLvlTextChar: vi.fn(() => '•'), + }, +})); + +const editor = { + converter: { + numbering: {}, + translatedNumbering: {}, + }, +}; + +const paragraph = (numId, ilvl = 0) => ({ + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + numberingProperties: { numId, ilvl }, + }, + }, +}); + +function setDefinition(numId, level, details = {}) { + mocks.definitionDetailsByKey.set(`${numId}:${level}`, { + lvlText: `%${Number(level) + 1}.`, + listNumberingType: 'decimal', + suffix: 'tab', + justification: 'left', + abstractId: 'abstract-1', + ...details, + }); +} + +function sync(paragraphs) { + const syncer = createListRenderingSync(editor); + const updates = []; + + syncer.syncListRendering({ + visitNodes: (visit) => { + paragraphs.forEach((node, index) => visit(node, index * 10 + 1)); + }, + updateListRendering: (node, pos, listRendering) => { + updates.push({ node, pos, listRendering }); + }, + }); + + return updates; +} + +describe('listRenderingSync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.allDefinitions = {}; + mocks.definitionDetailsByKey = new Map(); + }); + + it('calculates ordered list rendering in paragraph order', () => { + mocks.allDefinitions = { 1: { 0: { start: '1' } } }; + setDefinition(1, 0); + + const updates = sync([paragraph(1), paragraph(1)]); + + expect(updates.map((update) => update.listRendering.markerText)).toEqual(['1.', '2.']); + expect(updates.map((update) => update.listRendering.path)).toEqual([[1], [2]]); + expect(generateOrderedListIndex).toHaveBeenLastCalledWith({ + listLevel: [2], + lvlText: '%1.', + listNumberingType: 'decimal', + customFormat: undefined, + }); + }); + + it('uses bullet marker text from the numbering definition', () => { + mocks.allDefinitions = { 9: { 0: { start: '1' } } }; + setDefinition(9, 0, { lvlText: 'o', listNumberingType: 'bullet' }); + + const [update] = sync([paragraph(9)]); + + expect(update.listRendering).toMatchObject({ + markerText: '•', + numberingType: 'bullet', + path: [1], + }); + expect(generateOrderedListIndex).not.toHaveBeenCalled(); + }); + + it('honors start values from translated numbering definitions', () => { + mocks.allDefinitions = { 2: { 0: { start: '5' } } }; + setDefinition(2, 0); + + const [update] = sync([paragraph(2)]); + + expect(update.listRendering.markerText).toBe('5.'); + expect(update.listRendering.path).toEqual([5]); + }); + + it('restarts nested levels after a parent level appears', () => { + mocks.allDefinitions = { + 3: { + 0: { start: '1' }, + 1: { start: '1' }, + }, + }; + setDefinition(3, 0); + setDefinition(3, 1); + + const updates = sync([paragraph(3, 0), paragraph(3, 1), paragraph(3, 1), paragraph(3, 0), paragraph(3, 1)]); + + expect(updates.map((update) => update.listRendering.path)).toEqual([[1], [1, 1], [1, 2], [2], [2, 1]]); + }); + + it('returns null when definition details are missing', () => { + mocks.allDefinitions = { 4: { 0: { start: '1' } } }; + + const [update] = sync([paragraph(4)]); + + expect(update.listRendering).toBeNull(); + expect(generateOrderedListIndex).not.toHaveBeenCalled(); + }); + + it('calculates nested paths across multiple levels', () => { + mocks.allDefinitions = { + 5: { + 0: { start: '1' }, + 1: { start: '1' }, + 2: { start: '1' }, + }, + }; + setDefinition(5, 0); + setDefinition(5, 1); + setDefinition(5, 2); + + const updates = sync([paragraph(5, 0), paragraph(5, 1), paragraph(5, 2)]); + + expect(updates.at(-1).listRendering.path).toEqual([1, 1, 1]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/numberingPlugin.js b/packages/super-editor/src/editors/v1/extensions/paragraph/numberingPlugin.js index 40d261f9b1..009b4155bf 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/numberingPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/numberingPlugin.js @@ -1,10 +1,7 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { AddMarkStep, RemoveMarkStep, ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform'; -import { createNumberingManager } from './NumberingManager.js'; -import { ListHelpers } from '@helpers/list-numbering-helpers.js'; -import { generateOrderedListIndex } from '@helpers/orderedListUtils.js'; -import { docxNumberingHelpers } from '@core/super-converter/v2/importer/listImporter.js'; import { calculateResolvedParagraphProperties } from './resolvedPropertiesCache.js'; +import { createListRenderingSync } from './listRenderingSync.js'; function blockRevIsFreshForSlicePaste(rev) { return rev === 0 || rev === '0'; @@ -25,33 +22,14 @@ function shouldPreserveSlicePastedListRendering(node, transactions) { * @returns {import('prosemirror-state').Plugin} */ export function createNumberingPlugin(editor) { - const numberingManager = createNumberingManager(); - let forceFullRecompute = false; + const listRenderingSync = createListRenderingSync(editor); + let forceFullRecompute = true; - // Helpers to initialize and refresh start settings from definitions - const applyStartSettingsFromDefinitions = (definitionsMap) => { - Object.entries(definitionsMap || {}).forEach(([numId, levels]) => { - Object.entries(levels || {}).forEach(([level, def]) => { - const start = parseInt(def?.start) || 1; - let restart = def?.restart; - if (restart != null) { - restart = parseInt(restart); - } - numberingManager.setStartSettings(numId, parseInt(level), start, restart, def.startOverridden); - }); - }); - }; - - // Callback to refresh start settings when definitions change const refreshStartSettings = () => { - const definitions = ListHelpers.getAllListDefinitions(editor); - applyStartSettingsFromDefinitions(definitions); + listRenderingSync.refreshStartSettings(); forceFullRecompute = true; }; - // Initial setup - refreshStartSettings(); - // Listen for definition changes if (typeof editor?.on === 'function') { editor.on('list-definitions-change', refreshStartSettings); @@ -230,68 +208,14 @@ export function createNumberingPlugin(editor) { bumpBlockRev(node, pos); }; - // Generate new list properties - numberingManager.enableCache(); - try { - newState.doc.descendants((node, pos) => { - let resolvedProps = calculateResolvedParagraphProperties(editor, node, newState.doc.resolve(pos)); - if (node.type.name !== 'paragraph' || !resolvedProps.numberingProperties) { - return; - } - - // Lossless SuperDoc slice paste: keep markers/list type from the slice. Running - // definition lookup first would clear listRendering when numIds are absent in - // the target doc, or overwrite markers when the same doc continues counters. - if (shouldPreserveSlicePastedListRendering(node, transactions)) { - return false; - } - - // Retrieving numbering definition from docx - const { numId, ilvl: level = 0 } = resolvedProps.numberingProperties; - const definitionDetails = ListHelpers.getListDefinitionDetails({ numId, level, editor }); - - if (!definitionDetails || Object.keys(definitionDetails).length === 0) { - // Treat as normal paragraph if definition is missing - updateListRenderingIfNeeded(node, pos, null); - return false; - } - - let { lvlText, customFormat, listNumberingType, suffix, justification, abstractId } = definitionDetails; - // Defining the list marker - let markerText = ''; - listNumberingType = listNumberingType || 'decimal'; - const count = numberingManager.calculateCounter(numId, level, pos, abstractId); - numberingManager.setCounter(numId, level, pos, count, abstractId); - const path = numberingManager.calculatePath(numId, level, pos); - if (listNumberingType !== 'bullet') { - markerText = - generateOrderedListIndex({ - listLevel: path, - lvlText: lvlText, - listNumberingType, - customFormat, - }) ?? ''; - } else { - markerText = docxNumberingHelpers.normalizeLvlTextChar(lvlText) ?? ''; - } - - const newListRendering = { - markerText, - suffix, - justification, - path, - numberingType: listNumberingType, - ...(customFormat ? { customFormat } : {}), - }; - - // Updating rendering attrs for node view usage - updateListRenderingIfNeeded(node, pos, newListRendering); - - return false; // no need to descend into a paragraph - }); - } finally { - numberingManager.disableCache(); - } + listRenderingSync.syncListRendering({ + visitNodes: (visit) => + newState.doc.descendants((node, pos) => visit(node, pos, { $pos: newState.doc.resolve(pos) })), + resolveParagraphProperties: (node, _pos, context) => + calculateResolvedParagraphProperties(editor, node, context.$pos), + shouldPreserveParagraph: (node) => shouldPreserveSlicePastedListRendering(node, transactions), + updateListRendering: updateListRenderingIfNeeded, + }); return tr.docChanged ? tr : null; }, }); From 22ad71f8c00e3814261fb4350f97551e535f71ef Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 13:55:03 -0300 Subject: [PATCH 30/35] fix(painter-dom): keep hidden cell paragraphs out of border grouping --- .../dom/src/table/renderTableCell.test.ts | 50 +++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 4 ++ 2 files changed, 54 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 15b2a8e760..c6fe6e29a8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -2800,6 +2800,56 @@ describe('renderTableCell', () => { expect(firstBorder.style.borderBottomWidth).toBe('2px'); }); + it('does not group table-cell paragraph borders across hidden previous blocks', () => { + const borders = { + top: { width: 1, style: 'solid' as const, color: '#111111' }, + bottom: { width: 1, style: 'solid' as const, color: '#111111' }, + between: { width: 2, style: 'dashed' as const, color: '#222222' }, + }; + const para1: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-hidden-1', + runs: [{ text: 'Hidden', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders }, + }; + const para2: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-hidden-2', + runs: [{ text: 'Visible', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders: { ...borders } }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + fromLine: 1, + toLine: 2, + renderLine: (block) => { + const line = doc.createElement('div'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, + cellMeasure: { + blocks: [paragraphMeasure, paragraphMeasure], + width: 120, + height: 60, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-between-hidden', blocks: [para1, para2], attrs: {} }, + }); + + const hiddenWrapper = cellElement.querySelector('[data-block-id="cell-between-hidden-1"]') + ?.parentElement as HTMLElement | null; + const visibleWrapper = cellElement.querySelector('[data-block-id="cell-between-hidden-2"]') + ?.parentElement as HTMLElement | null; + expect(hiddenWrapper).toBeUndefined(); + expect(visibleWrapper?.dataset.suppressTopBorder).toBeUndefined(); + const visibleBorder = getParagraphBorderLayer(visibleWrapper!); + expect(visibleBorder.style.borderTopWidth).toBe('1px'); + expect(visibleBorder.style.borderTopStyle).toBe('solid'); + }); + it('groups table-cell paragraph borders across anchored out-of-flow blocks', () => { const borders = { top: { width: 1, style: 'solid' as const, color: '#111111' }, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 71b1d5b636..dfe9acbd9f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -813,7 +813,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const measure = blockMeasures[index]; const blockStartGlobal = borderContextSegmentStart; const blockLineCount = blockLineCounts[index] ?? 0; + const blockEndGlobal = blockStartGlobal + blockLineCount; borderContextSegmentStart += blockLineCount; + if (blockEndGlobal <= globalFromLine || blockStartGlobal >= globalToLine) { + return []; + } if (isAnchoredMediaBlock(block, measure) || isZeroHeightMediaBlock(block, measure)) { return []; } From 7617b4857761394dae8bfeaeecfd0b7ac3fe8e65 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 14:08:34 -0300 Subject: [PATCH 31/35] fix(painter-dom): extend table-cell between-borders through paragraph spacing gaps --- .../dom/src/table/renderTableCell.test.ts | 49 ++++++++++++++++++ .../painters/dom/src/table/renderTableCell.ts | 51 +++++++++++++++++-- 2 files changed, 97 insertions(+), 3 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 c6fe6e29a8..a484782dbb 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -2850,6 +2850,55 @@ describe('renderTableCell', () => { expect(visibleBorder.style.borderTopStyle).toBe('solid'); }); + it('extends table-cell between-border groups through paragraph spacing gaps', () => { + const borders = { + top: { width: 1, style: 'solid' as const, color: '#111111' }, + bottom: { width: 1, style: 'solid' as const, color: '#111111' }, + between: { width: 2, style: 'dashed' as const, color: '#222222' }, + }; + const para1: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-spacing-1', + runs: [{ text: 'First', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders, spacing: { after: 10 } }, + }; + const para2: ParagraphBlock = { + kind: 'paragraph', + id: 'cell-between-spacing-2', + runs: [{ text: 'Second', fontFamily: 'Arial', fontSize: 16 }], + attrs: { borders: { ...borders } }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + renderLine: (block) => { + const line = doc.createElement('div'); + line.dataset.blockId = (block as ParagraphBlock).id; + return line; + }, + cellMeasure: { + blocks: [paragraphMeasure, paragraphMeasure], + width: 120, + height: 70, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { id: 'cell-between-spacing', blocks: [para1, para2], attrs: {} }, + }); + + const firstWrapper = cellElement.querySelector('[data-block-id="cell-between-spacing-1"]') + ?.parentElement as HTMLElement | null; + const secondWrapper = cellElement.querySelector('[data-block-id="cell-between-spacing-2"]') + ?.parentElement as HTMLElement | null; + expect(firstWrapper?.dataset.betweenBorder).toBe('true'); + expect(firstWrapper?.dataset.gapBelow).toBe('10'); + expect(secondWrapper?.dataset.suppressTopBorder).toBe('true'); + const firstBorder = getParagraphBorderLayer(firstWrapper!); + expect(firstBorder.style.borderBottomStyle).toBe('dashed'); + expect(firstBorder.style.bottom).toBe('-12px'); + }); + it('groups table-cell paragraph borders across anchored out-of-flow blocks', () => { const borders = { top: { width: 1, style: 'solid' as const, color: '#111111' }, diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index dfe9acbd9f..8f8630f81e 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -15,8 +15,13 @@ import type { TableMeasure, WrapExclusion, WrapTextMode, + computeCellSliceContentHeight, + describeCellRenderBlocks, + getCellLines, + getCellSpacingPx, + normalizeZIndex, + CellRenderBlock, } from '@superdoc/contracts'; -import { getCellLines, getCellSpacingPx, normalizeZIndex } from '@superdoc/contracts'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { RenderedLineInfo } from '../renderer.js'; import type { FragmentRenderContext } from '../fragment-context.js'; @@ -464,6 +469,32 @@ const getMeasuredBlockHeight = (measure: Measure | undefined): number => { return 'height' in measure && typeof measure.height === 'number' ? measure.height : 0; }; +const getTableCellParagraphContextHeights = ({ + renderBlock, + blockStartGlobal, + blockLineCount, + globalFromLine, + globalToLine, +}: { + renderBlock: CellRenderBlock; + blockStartGlobal: number; + blockLineCount: number; + globalFromLine: number; + globalToLine: number; +}): { contentHeight: number; totalHeight: number } => { + const localStartLine = Math.max(0, globalFromLine - blockStartGlobal); + const localEndLine = Math.min(blockLineCount, globalToLine - blockStartGlobal); + const lineSum = renderBlock.lineHeights.slice(localStartLine, localEndLine).reduce((sum, height) => sum + height, 0); + const rendersEntireBlock = localStartLine === 0 && localEndLine >= renderBlock.lineHeights.length; + const contentHeight = rendersEntireBlock ? Math.max(lineSum, renderBlock.totalHeight) : lineSum; + const totalHeight = computeCellSliceContentHeight( + [renderBlock], + renderBlock.globalStartLine + localStartLine, + renderBlock.globalStartLine + localEndLine, + ); + return { contentHeight, totalHeight }; +}; + const getTableCellVisibleBlockIndexes = ( measures: Measure[], blocks: Array, @@ -805,6 +836,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const effectiveCellWidth = cellWidth ?? cellMeasure.width; const contentWidthPx = Math.max(0, effectiveCellWidth - paddingLeft - paddingRight); const contentHeightPx = Math.max(0, rowHeight - paddingTop - paddingBottom); + const cellRenderBlocks = describeCellRenderBlocks(cellMeasure, cell, { top: paddingTop, bottom: paddingBottom }); let paragraphContextY = 0; let borderContextSegmentStart = 0; const betweenEntryBlockIndexes: number[] = []; @@ -822,8 +854,21 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen return []; } const y = paragraphContextY; - const height = getMeasuredBlockHeight(measure); - paragraphContextY += height; + let height = getMeasuredBlockHeight(measure); + let totalHeight = height; + const renderBlock = cellRenderBlocks.find((entry) => entry.globalStartLine === blockStartGlobal); + if (block?.kind === 'paragraph' && measure?.kind === 'paragraph' && renderBlock?.kind === 'paragraph') { + const contextHeights = getTableCellParagraphContextHeights({ + renderBlock, + blockStartGlobal, + blockLineCount, + globalFromLine, + globalToLine, + }); + height = contextHeights.contentHeight; + totalHeight = contextHeights.totalHeight; + } + paragraphContextY += totalHeight; betweenEntryBlockIndexes.push(index); if (block?.kind !== 'paragraph' || measure?.kind !== 'paragraph' || !block.attrs?.borders) { return [ From 4263a9d0aa3cc49bfa0526b7ee16fe875e2d009e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 16:02:47 -0300 Subject: [PATCH 32/35] refactor(pm-adapter): resolve list rendering inside adapter via opt-in flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `resolveListRendering` option to `toFlowBlocks` that builds a numbering manager from `translatedNumbering` and lazily computes paragraph list marker metadata during PM JSON → FlowBlock conversion. The resolution flows through `computeParagraphAttrs` and the node handler context, so callers no longer have to pre-mutate node attrs with a `listRendering` field. FootnotesBuilder switches to the new flag and drops its local `applyFootnoteListRendering` helper, which traversed the footnote doc and mutated `attrs.listRendering` before handing it to the adapter. The new resolver runs against the original PM JSON without mutating it. --- .../pm-adapter/src/attributes/paragraph.ts | 6 +- .../src/converters/paragraph.test.ts | 5 +- .../pm-adapter/src/converters/paragraph.ts | 14 +- .../pm-adapter/src/index.test.ts | 66 ++++++++ .../layout-engine/pm-adapter/src/internal.ts | 3 + .../pm-adapter/src/list-rendering.ts | 147 ++++++++++++++++++ .../layout-engine/pm-adapter/src/types.ts | 25 +++ .../layout/FootnotesBuilder.ts | 62 +------- .../tests/FootnotesBuilder.test.ts | 23 +-- 9 files changed, 274 insertions(+), 77 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/list-rendering.ts diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts index f20e7967d5..a5e8fee201 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.ts @@ -17,7 +17,7 @@ import { type ParagraphFrame, ParagraphDirectionContext, } from '@superdoc/contracts'; -import type { PMNode, ParagraphFont } from '../types.js'; +import type { ListRenderingContext, PMNode, ParagraphFont } from '../types.js'; import type { ResolvedRunProperties } from '@superdoc/word-layout'; import { computeWordParagraphLayout } from '@superdoc/word-layout'; import { pickNumber, twipsToPx, isFiniteNumber, ptToPx } from '../utilities.js'; @@ -305,6 +305,7 @@ export const computeParagraphAttrs = ( para: PMNode, converterContext?: ConverterContext, previousParagraphFont?: ParagraphFont, + options?: { listRenderingContext?: ListRenderingContext; position?: number }, ): { paragraphAttrs: ParagraphAttrs; resolvedParagraphProperties: ParagraphProperties } => { const attrs = para.attrs ?? {}; const paragraphProperties = (attrs.paragraphProperties ?? {}) as ParagraphProperties; @@ -362,7 +363,8 @@ export const computeParagraphAttrs = ( const floatAlignment = normalizedFramePr?.xAlign; const normalizedNumberingProperties = normalizeNumberingProperties(resolvedParagraphProperties.numberingProperties); const dropCapDescriptor = normalizeDropCap(resolvedParagraphProperties.framePr, para, converterContext); - const normalizedListRendering = attrs.listRendering as { + const normalizedListRendering = (attrs.listRendering ?? + options?.listRenderingContext?.resolveListRendering(para, resolvedParagraphProperties, options.position ?? 0)) as { markerText: string; justification: 'left' | 'center' | 'right'; path: number[]; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 78d0862571..a10983d389 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -3320,7 +3320,10 @@ describe('paragraph converters', () => { converterContext, ); - expect(vi.mocked(computeParagraphAttrs)).toHaveBeenCalledWith(para, converterContext, undefined); + expect(vi.mocked(computeParagraphAttrs)).toHaveBeenCalledWith(para, converterContext, undefined, { + listRenderingContext: undefined, + position: 0, + }); }); describe('previousParagraphFont', () => { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index a3f5d964e8..c7d2bfd4d8 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -426,7 +426,10 @@ const updateGhostListMarkerOffsets = ( return; } - const { paragraphAttrs } = computeParagraphAttrs(node, context.converterContext); + const { paragraphAttrs } = computeParagraphAttrs(node, context.converterContext, undefined, { + listRenderingContext: context.listRenderingContext, + position: context.positions.get(node)?.start ?? 0, + }); const key = getParagraphListKeyFromAttrs(paragraphAttrs); if (!key) { return; @@ -440,7 +443,10 @@ const updateGhostListMarkerOffsets = ( }; const getNodeListKey = (node: PMNode, context: NodeHandlerContext): string | undefined => { - const { paragraphAttrs } = computeParagraphAttrs(node, context.converterContext); + const { paragraphAttrs } = computeParagraphAttrs(node, context.converterContext, undefined, { + listRenderingContext: context.listRenderingContext, + position: context.positions.get(node)?.start ?? 0, + }); return getParagraphListKeyFromAttrs(paragraphAttrs); }; @@ -556,6 +562,7 @@ export function paragraphToFlowBlocks({ themeColors, converters, converterContext, + listRenderingContext, enableComments = true, stableBlockId, previousParagraphFont, @@ -578,6 +585,7 @@ export function paragraphToFlowBlocks({ para, converterContext, previousParagraphFont, + { listRenderingContext, position: positions.get(para)?.start ?? 0 }, ); const blocks: FlowBlock[] = []; @@ -1162,6 +1170,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): themeColors, converters, converterContext, + listRenderingContext: context.listRenderingContext, enableComments, stableBlockId: prefixedStableId, previousParagraphFont, @@ -1191,6 +1200,7 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): themeColors, converters, converterContext, + listRenderingContext: context.listRenderingContext, enableComments, stableBlockId: prefixedStableId ?? undefined, previousParagraphFont, diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 6fe7e37f45..16414ccbfe 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -157,6 +157,72 @@ describe('toFlowBlocks', () => { expect(secondFont.fontFamily).toBe(firstFont.fontFamily); expect(secondFont.fontSize).toBe(firstFont.fontSize); }); + + it('can resolve missing listRendering from converter numbering without mutating PM JSON', () => { + const firstParagraph = { + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + content: [{ type: 'text', text: 'First' }], + }; + const secondParagraph = { + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { numId: 1, ilvl: 0 }, + }, + }, + content: [{ type: 'text', text: 'Second' }], + }; + const pmDoc = { + type: 'doc', + content: [firstParagraph, secondParagraph], + }; + + const { blocks } = toFlowBlocks(pmDoc, { + resolveListRendering: true, + converterContext: { + docx: {}, + translatedLinkedStyles: { + docDefaults: {}, + latentStyles: {}, + styles: {}, + }, + translatedNumbering: { + definitions: { + 1: { numId: 1, abstractNumId: 10 }, + }, + abstracts: { + 10: { + abstractNumId: 10, + levels: { + 0: { + ilvl: 0, + start: 1, + lvlText: '%1.', + suff: 'tab', + lvlJc: 'left', + numFmt: { val: 'decimal' }, + }, + }, + }, + }, + }, + }, + }); + + expect( + (blocks[0] as { attrs?: { wordLayout?: { marker?: { markerText?: string } } } }).attrs?.wordLayout?.marker, + ).toMatchObject({ markerText: '1.' }); + expect( + (blocks[1] as { attrs?: { wordLayout?: { marker?: { markerText?: string } } } }).attrs?.wordLayout?.marker, + ).toMatchObject({ markerText: '2.' }); + expect(firstParagraph.attrs).not.toHaveProperty('listRendering'); + expect(secondParagraph.attrs).not.toHaveProperty('listRendering'); + }); }); describe('mark mapping', () => { diff --git a/packages/layout-engine/pm-adapter/src/internal.ts b/packages/layout-engine/pm-adapter/src/internal.ts index f2e39387c1..c58cf4b762 100644 --- a/packages/layout-engine/pm-adapter/src/internal.ts +++ b/packages/layout-engine/pm-adapter/src/internal.ts @@ -12,6 +12,7 @@ import type { FlowBlock, ParagraphBlock } from '@superdoc/contracts'; import { resolveSectionDirection } from './direction/resolveSectionDirection.js'; +import { createListRenderingContext } from './list-rendering.js'; import { isValidTrackedMode } from './tracked-changes.js'; import { analyzeSectionRanges, @@ -172,6 +173,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): defaultFont, defaultSize, ); + const listRenderingContext = options?.resolveListRendering ? createListRenderingContext(converterContext) : undefined; if (options?.showBookmarks !== undefined) { converterContext.showBookmarks = options.showBookmarks; } @@ -243,6 +245,7 @@ export function toFlowBlocks(pmDoc: PMNode | object, options?: AdapterOptions): flowBlockCache, trackedListMarkerOffsets: new Map(), trackedListLastOrdinals: new Map(), + listRenderingContext, }; // Process nodes using handler dispatch pattern. Before each top-level node diff --git a/packages/layout-engine/pm-adapter/src/list-rendering.ts b/packages/layout-engine/pm-adapter/src/list-rendering.ts new file mode 100644 index 0000000000..53a4a8a50d --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/list-rendering.ts @@ -0,0 +1,147 @@ +import { createNumberingManager } from '@superdoc/word-layout'; +import type { NumberingProperties, ParagraphProperties } from '@superdoc/style-engine/ooxml'; +import type { ConverterContext } from './converter-context.js'; +import type { ListRenderingContext, PMNode, ResolvedListRendering } from './types.js'; +import { generateOrderedListIndex, normalizeLvlTextChar } from './list-helpers.js'; + +type NumberingDefinitionDetails = { + start?: number; + restart?: number; + lvlText?: string; + suffix?: string; + justification?: string; + listNumberingType?: string; + customFormat?: string; + abstractId?: string | number; +}; + +type LevelWithRestart = NonNullable[string]['levels']>[string] & { + lvlRestart?: number; +}; + +function getNumberingPropertiesFromNode( + node: PMNode, + resolvedParagraphProperties: ParagraphProperties, +): ParagraphProperties['numberingProperties'] | null { + const attrs = node.attrs ?? {}; + return ( + resolvedParagraphProperties.numberingProperties ?? + (attrs.paragraphProperties as ParagraphProperties | undefined)?.numberingProperties ?? + (attrs.numberingProperties as ParagraphProperties['numberingProperties'] | undefined) ?? + null + ); +} + +function getLevelDefinition( + numbering: NumberingProperties, + numId: string | number, + level: number, +): NumberingDefinitionDetails | null { + const definition = numbering.definitions?.[String(numId)]; + if (!definition) return null; + + const abstractId = definition.abstractNumId; + const abstract = abstractId != null ? numbering.abstracts?.[String(abstractId)] : undefined; + const levelDefinition = abstract?.levels?.[String(level)]; + if (!levelDefinition) return null; + + const override = definition.lvlOverrides?.[String(level)]; + const overrideLevel = override?.lvl as LevelWithRestart | undefined; + const baseLevel = levelDefinition as LevelWithRestart; + const start = override?.startOverride ?? override?.lvl?.start ?? levelDefinition.start ?? 1; + const levelFormat = override?.lvl?.numFmt ?? levelDefinition.numFmt; + + return { + start, + restart: overrideLevel?.lvlRestart ?? baseLevel.lvlRestart, + lvlText: overrideLevel?.lvlText ?? baseLevel.lvlText, + suffix: overrideLevel?.suff ?? baseLevel.suff, + justification: overrideLevel?.lvlJc ?? baseLevel.lvlJc, + listNumberingType: levelFormat?.val, + customFormat: levelFormat?.val === 'custom' ? levelFormat.format : undefined, + abstractId, + }; +} + +function seedStartSettings( + numberingManager: ReturnType, + numbering: NumberingProperties, +) { + Object.entries(numbering.definitions ?? {}).forEach(([numId, definition]) => { + const abstractId = definition?.abstractNumId; + const abstract = abstractId != null ? numbering.abstracts?.[String(abstractId)] : undefined; + Object.values(abstract?.levels ?? {}).forEach((levelDefinition) => { + const baseLevel = levelDefinition as LevelWithRestart; + const level = baseLevel.ilvl ?? 0; + const override = definition.lvlOverrides?.[String(level)]; + const overrideLevel = override?.lvl as LevelWithRestart | undefined; + const start = override?.startOverride ?? overrideLevel?.start ?? baseLevel.start ?? 1; + numberingManager.setStartSettings(numId, level, start, baseLevel.lvlRestart, override?.startOverride != null); + }); + }); +} + +export function createListRenderingContext(converterContext: ConverterContext): ListRenderingContext | undefined { + const numbering = converterContext.translatedNumbering; + if (!numbering?.definitions || !numbering.abstracts) { + return undefined; + } + + const numberingManager = createNumberingManager(); + seedStartSettings(numberingManager, numbering); + numberingManager.enableCache(); + + const cached = new WeakMap(); + + return { + resolveListRendering(node, resolvedParagraphProperties, pos) { + if (cached.has(node)) { + return cached.get(node) ?? null; + } + + if (node.type !== 'paragraph') { + cached.set(node, null); + return null; + } + + const numberingProperties = getNumberingPropertiesFromNode(node, resolvedParagraphProperties); + if (!numberingProperties || numberingProperties.numId == null) { + cached.set(node, null); + return null; + } + + const numId = numberingProperties.numId; + const level = numberingProperties.ilvl ?? 0; + const details = getLevelDefinition(numbering, numId, level); + if (!details) { + cached.set(node, null); + return null; + } + + const numberingType = details.listNumberingType || 'decimal'; + const count = numberingManager.calculateCounter(numId, level, pos, details.abstractId); + numberingManager.setCounter(numId, level, pos, count, details.abstractId); + const path = numberingManager.calculatePath(numId, level, pos); + const markerText = + numberingType !== 'bullet' + ? (generateOrderedListIndex({ + listLevel: path, + lvlText: details.lvlText, + listNumberingType: numberingType, + customFormat: details.customFormat, + }) ?? '') + : (normalizeLvlTextChar(details.lvlText) ?? ''); + + const listRendering: ResolvedListRendering = { + markerText, + suffix: details.suffix, + justification: details.justification, + path, + numberingType, + ...(details.customFormat ? { customFormat: details.customFormat } : {}), + }; + cached.set(node, listRendering); + return listRendering; + }, + }; +} diff --git a/packages/layout-engine/pm-adapter/src/types.ts b/packages/layout-engine/pm-adapter/src/types.ts index 0aed51dd1d..f3e8693cc1 100644 --- a/packages/layout-engine/pm-adapter/src/types.ts +++ b/packages/layout-engine/pm-adapter/src/types.ts @@ -4,6 +4,7 @@ import type { TrackedChangesMode, SectionMetadata, FlowBlock, TrackedChangeMeta } from '@superdoc/contracts'; import type { StyleContext as StyleEngineContext, ComputedParagraphStyle } from '@superdoc/style-engine'; +import type { ParagraphProperties as OoxmlParagraphProperties } from '@superdoc/style-engine/ooxml'; import type { SectionRange } from './sections/index.js'; import type { ConverterContext } from './converter-context.js'; import type { paragraphToFlowBlocks } from './converters/paragraph.js'; @@ -23,6 +24,23 @@ export type { ComputedParagraphStyle }; export type ThemeColorPalette = Record; +export type ResolvedListRendering = { + markerText: string; + suffix?: string; + justification?: string; + path: number[]; + numberingType: string; + customFormat?: string; +}; + +export type ListRenderingContext = { + resolveListRendering: ( + node: PMNode, + resolvedParagraphProperties: OoxmlParagraphProperties, + pos: number, + ) => ResolvedListRendering | null; +}; + /** * ProseMirror node shape (simplified interface for what we need) */ @@ -205,6 +223,11 @@ export interface AdapterOptions { * conversion settings change (tracked changes mode, comments enabled, etc.). */ flowBlockCache?: import('./cache.js').FlowBlockCache; + + /** + * When true, compute missing listRendering metadata during PM JSON to FlowBlock conversion. + */ + resolveListRendering?: boolean; } /** @@ -334,6 +357,7 @@ export interface NodeHandlerContext { trackedListMarkerOffsets?: Map; // Last seen source ordinal per list key for restart detection trackedListLastOrdinals?: Map; + listRenderingContext?: ListRenderingContext; } /** @@ -363,6 +387,7 @@ export type ParagraphToFlowBlocksParams = { converters: NestedConverters; enableComments: boolean; converterContext: ConverterContext; + listRenderingContext?: ListRenderingContext; stableBlockId?: string; /** When set, used as default/marker font for list paragraphs that have no explicit run properties (e.g. new list item after Enter). */ previousParagraphFont?: ParagraphFont; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts index 20012b3180..6bae2913fb 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/layout/FootnotesBuilder.ts @@ -23,14 +23,12 @@ import type { FlowBlock } from '@superdoc/contracts'; import { toFlowBlocks } from '@superdoc/pm-adapter'; import type { ConverterContext } from '@superdoc/pm-adapter/converter-context.js'; import { SUBSCRIPT_SUPERSCRIPT_SCALE } from '@superdoc/pm-adapter/constants.js'; -import { resolveParagraphProperties } from '@superdoc/style-engine/ooxml'; import type { ProseMirrorJSON } from '../../types/EditorTypes.js'; import type { FootnoteReference, FootnotesLayoutInput } from '../types.js'; import { findNoteEntryById } from '../../../document-api-adapters/helpers/note-entry-lookup.js'; import { normalizeNotePmJson } from '../../../document-api-adapters/helpers/note-pm-json.js'; import { buildStoryKey } from '../../../document-api-adapters/story-runtime/story-key.js'; -import { createListRenderingSync } from '../../../extensions/paragraph/listRenderingSync.js'; // Re-export types for consumers export type { FootnoteReference, FootnotesLayoutInput }; @@ -138,7 +136,6 @@ export function buildFootnotesInput( try { const footnoteDoc = resolveNoteDocJson(id, importedFootnotes, renderOverride); if (!footnoteDoc) return; - applyFootnoteListRendering(footnoteDoc, converter, converterContext); const result = toFlowBlocks(footnoteDoc, { blockIdPrefix: `footnote-${id}-`, @@ -146,6 +143,7 @@ export function buildFootnotesInput( enableRichHyperlinks: true, themeColors: themeColors as never, converterContext: converterContext as never, + resolveListRendering: true, }); if (result?.blocks?.length) { @@ -272,64 +270,6 @@ function resolveNoteDocJson( }); } -function isPmJsonObject(value: unknown): value is ProseMirrorJSON & { attrs?: Record } { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)); -} - -function traversePmJson(node: ProseMirrorJSON, visitor: (node: ProseMirrorJSON) => boolean | void): void { - const result = visitor(node); - if (result === false || !Array.isArray(node.content)) return; - node.content.forEach((child) => { - if (isPmJsonObject(child)) { - traversePmJson(child, visitor); - } - }); -} - -function resolveFootnoteParagraphProperties( - node: ProseMirrorJSON & { attrs?: Record }, - converterContext: ConverterContext | undefined, -): Record { - const paragraphProperties = - typeof node.attrs?.paragraphProperties === 'object' && node.attrs.paragraphProperties !== null - ? (node.attrs.paragraphProperties as Record) - : {}; - - if (!converterContext) { - return paragraphProperties; - } - - return resolveParagraphProperties( - converterContext as never, - paragraphProperties as never, - converterContext.tableInfo, - ) as Record; -} - -function applyFootnoteListRendering( - footnoteDoc: ProseMirrorJSON, - converter: ConverterLike | null | undefined, - converterContext: ConverterContext | undefined, -): void { - const listRenderingSync = createListRenderingSync({ converter }); - let pos = 0; - - listRenderingSync.syncListRendering({ - visitNodes: (visit: (node: ProseMirrorJSON, pos: number) => boolean | void) => { - traversePmJson(footnoteDoc, (node) => visit(node, pos++)); - }, - resolveParagraphProperties: (node: ProseMirrorJSON & { attrs?: Record }) => - resolveFootnoteParagraphProperties(node, converterContext), - updateListRendering: ( - node: ProseMirrorJSON & { attrs?: Record }, - _pos: number, - listRendering: unknown, - ) => { - node.attrs = { ...(node.attrs ?? {}), listRendering: listRendering ?? null }; - }, - }); -} - function syncMarkerRun(target: Run, source: Run): void { target.kind = source.kind; target.text = source.text; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts index a47a3ca2f4..7cdafd9661 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/FootnotesBuilder.test.ts @@ -15,19 +15,24 @@ vi.mock('@superdoc/pm-adapter', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - toFlowBlocks: vi.fn((doc: unknown, opts?: { blockIdPrefix?: string }) => { + toFlowBlocks: vi.fn((doc: unknown, opts?: { blockIdPrefix?: string; resolveListRendering?: boolean }) => { // Return mock blocks based on blockIdPrefix if (typeof opts?.blockIdPrefix === 'string') { const id = opts.blockIdPrefix.replace('footnote-', '').replace('-', ''); const firstParagraph = (doc as { content?: Array<{ type?: string; attrs?: Record }> }) ?.content?.[0]; const listRendering = firstParagraph?.attrs?.listRendering as { markerText?: string } | undefined; + const numberingProperties = ( + firstParagraph?.attrs?.paragraphProperties as { numberingProperties?: unknown } | undefined + )?.numberingProperties; + const markerText = + listRendering?.markerText ?? (opts.resolveListRendering && numberingProperties ? '1.' : undefined); return { blocks: [ { kind: 'paragraph', runs: [{ kind: 'text', text: `Footnote ${id} text`, pmStart: 0, pmEnd: 10 }], - ...(listRendering ? { attrs: { wordLayout: { marker: { markerText: listRendering.markerText } } } } : {}), + ...(markerText ? { attrs: { wordLayout: { marker: { markerText } } } } : {}), }, ], bookmarks: new Map(), @@ -227,7 +232,7 @@ describe('buildFootnotesInput', () => { }); }); - it('adds listRendering to footnote paragraphs before layout conversion without mutating converter data', () => { + it('asks the adapter to resolve footnote list rendering without mutating converter data', () => { listMocks.allDefinitions = { 1: { 0: { start: '1' } } }; listMocks.definitionDetailsByKey.set('1:0', { lvlText: '%1.', @@ -252,14 +257,10 @@ describe('buildFootnotesInput', () => { buildFootnotesInput(editorState, converter, undefined, undefined); - const docArg = (toFlowBlocks as unknown as { mock: { calls: Array<[any]> } }).mock.calls.at(-1)?.[0]; - expect(docArg?.content?.[0]?.attrs?.listRendering).toEqual({ - markerText: '1.', - suffix: 'tab', - justification: 'left', - path: [1], - numberingType: 'decimal', - }); + const [docArg, options] = + (toFlowBlocks as unknown as { mock: { calls: Array<[any, Record]> } }).mock.calls.at(-1) ?? []; + expect(docArg?.content?.[0]?.attrs?.listRendering).toBeUndefined(); + expect(options?.resolveListRendering).toBe(true); expect((converter.footnotes?.[0].content?.[0] as { attrs?: Record })?.attrs?.listRendering).toBe( undefined, ); From ac70cb109335917cd022debe2e46484db85a0aee Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 21 May 2026 17:04:43 -0300 Subject: [PATCH 33/35] fix: import issues --- .../layout-engine/painters/dom/src/table/renderTableCell.ts | 4 +++- 1 file changed, 3 insertions(+), 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 8f8630f81e..a90ae8e9de 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -15,12 +15,14 @@ import type { TableMeasure, WrapExclusion, WrapTextMode, + CellRenderBlock, +} from '@superdoc/contracts'; +import { computeCellSliceContentHeight, describeCellRenderBlocks, getCellLines, getCellSpacingPx, normalizeZIndex, - CellRenderBlock, } from '@superdoc/contracts'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { RenderedLineInfo } from '../renderer.js'; From c1f02a613cf9f7afac986cf4e1fbf0ba59ec9c04 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 22 May 2026 09:54:00 -0300 Subject: [PATCH 34/35] fix: failing table cell slice unit test --- packages/layout-engine/contracts/src/table-cell-slice.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts index ab24b634a4..8efbb734ff 100644 --- a/packages/layout-engine/contracts/src/table-cell-slice.test.ts +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -90,7 +90,7 @@ describe('table cell segment mapping', () => { }; const block: TableCell = { id: 'cell-anchored-tail', - blocks: [makeParagraphBlock(12), makeAnchoredImageBlock()], + blocks: [makeParagraphBlock('paragraph-before-anchor', { after: 12 }), makeAnchoredImageBlock()], }; const blocks = describeCellRenderBlocks(cell, block, { top: 0, bottom: 5 }); From adf7e516f8085420a4206e4fb096a74158f4430b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 22 May 2026 16:29:53 -0300 Subject: [PATCH 35/35] chore: fix broken paths in AGENTS.md --- packages/layout-engine/AGENTS.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index c1ce711803..1ba0e8fef8 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -12,13 +12,13 @@ ProseMirror Doc → pm-adapter → FlowBlock[] → layout-engine → Layout[] | Package | Purpose | Key Entry | |---------|---------|-----------| -| `contracts/` | Shared types (FlowBlock, Layout, etc.) | `src/index.ts` | -| `pm-adapter/` | PM document → FlowBlocks conversion | `src/internal.ts` | -| `layout-engine/` | Pagination algorithms | `src/index.ts` | -| `layout-bridge/` | Layout orchestration & bridge utilities | `src/incrementalLayout.ts` | -| `painters/dom/` | DOM rendering | `AGENTS.md`, `src/renderer.ts` | -| `style-engine/` | OOXML style resolution | `src/index.ts` | -| `geometry-utils/` | Math utilities for layout | `src/index.ts` | +| `contracts/` | Shared types (FlowBlock, Layout, etc.) | `contracts/src/index.ts` | +| `pm-adapter/` | PM document → FlowBlocks conversion | `pm-adapter/src/internal.ts` | +| `layout-engine/` | Pagination algorithms | `layout-engine/src/index.ts` | +| `layout-bridge/` | Layout orchestration & bridge utilities | `layout-bridge/src/incrementalLayout.ts` | +| `painters/dom/` | DOM rendering | `painters/dom/AGENTS.md`, `painters/dom/src/renderer.ts` | +| `style-engine/` | OOXML style resolution | `style-engine/src/index.ts` | +| `geometry-utils/` | Math utilities for layout | `geometry-utils/src/index.ts` | ## Key Insight: DomPainter Receives Paint-Ready Data @@ -105,26 +105,26 @@ feature and content rendering in concern-specific modules under `sdt/`, `notes/`, `textbox/`, `ruler/`, `features/`, or `utils/`). Read `painters/dom/AGENTS.md` before adding renderer code. -## DomPainter Feature Modules (`painters/dom/src/features/`) +## DomPainter Feature Registry Rendering logic for specific OOXML features belongs in **feature modules** under `painters/dom/src/features//` or the matching concern directory. This keeps `renderer.ts` focused on orchestration while feature-specific logic lives in discoverable, self-contained modules. ### How to find where an OOXML element renders -1. **Search `features/feature-registry.ts`** — maps OOXML element names (e.g., `w:pBdr`, `w:shd`) to their feature module +1. **Search `painters/dom/src/features/feature-registry.ts`** — maps OOXML element names (e.g., `w:pBdr`, `w:shd`) to their feature module 2. Each entry has: `feature` (folder name), `module` (import path), `handles` (OOXML elements), `spec` (ECMA-376 section) 3. Open the feature's `index.ts` for its public API and `@ooxml`/`@spec` annotations ### Adding a new rendering feature -1. **Add a registry entry** in `features/feature-registry.ts` first — this is the source of truth +1. **Add a registry entry** in `painters/dom/src/features/feature-registry.ts` first — this is the source of truth 2. **Create the feature folder** at `features//`: - `index.ts` — barrel exports with `@ooxml` and `@spec` JSDoc annotations - Split logic into focused files (e.g., `group-analysis.ts`, `border-layer.ts`) - `types.ts` — shared types if needed 3. **Import from the feature module** in `renderer.ts` — renderer calls feature functions, features don't import from renderer 4. **Remove extracted code** from `renderer.ts` — don't leave dead copies -5. **Update imports** in any other files that used the old renderer exports (e.g., `table/renderTableCell.ts`) +5. **Update imports** in any other files that used the old renderer exports (e.g., `painters/dom/src/table/renderTableCell.ts`) ### Feature module conventions @@ -138,7 +138,7 @@ Rendering logic for specific OOXML features belongs in **feature modules** under | Feature | OOXML elements | Folder | |---------|---------------|--------| -| Paragraph borders & shading | `w:pBdr`, `w:shd` | `features/paragraph-borders/` | +| Paragraph borders & shading | `w:pBdr`, `w:shd` | `painters/dom/src/paragraph/borders/` | ## Entry Points