diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 3d6a3a73de..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 | `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 @@ -97,26 +97,34 @@ 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 Feature Modules (`painters/dom/src/features/`) +## DomPainter Organization (`painters/dom/AGENTS.md`) -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. +`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 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 @@ -130,11 +138,12 @@ Rendering logic for specific OOXML features is extracted into **feature modules* | 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 -- `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/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts index 15b1215a2a..8efbb734ff 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,30 @@ 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', + }); + + 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)], @@ -47,6 +64,42 @@ 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('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('paragraph-before-anchor', { after: 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 61efcec615..5ea94d1571 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, blockDataArray); + 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, blockDataArray); + 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,26 @@ function resolveSpacingAfter(spacingAfter: number | undefined, isLastBlock: bool return typeof spacingAfter === 'number' && spacingAfter > 0 ? spacingAfter : 0; } +type TableCellMeasureBlock = NonNullable[number]; +type TableCellBlock = NonNullable[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], blockDataArray?.[i])) indexes.push(i); + } + return indexes; +} + +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; +} + 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/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/drawings/drawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts new file mode 100644 index 0000000000..e128ef2895 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/drawingFrame.ts @@ -0,0 +1,71 @@ +import type { DrawingBlock, SdtMetadata } from '@superdoc/contracts'; +import { applySdtDataset } from '../sdt/dataset.js'; +import { createDrawingPlaceholder } from './placeholder.js'; + +export type RenderDrawingContentForPlacement = ( + block: DrawingBlock, + options?: { clipContainer?: HTMLElement }, +) => HTMLElement; + +export type DrawingFramePlacement = + | { 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; + renderDrawingContent?: RenderDrawingContentForPlacement; +}; + +export const renderDrawingFrame = ({ + doc, + block, + width, + height, + placement, + className, + renderDrawingContent, +}: RenderDrawingFrameParams): HTMLElement => { + const wrapper = doc.createElement('div'); + 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'; + 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`; + if (placement.zIndex != null) { + wrapper.style.zIndex = String(placement.zIndex); + } + } else if (placement.mode === 'flowing-table-cell') { + 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%'; + 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/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/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts index 288d0bd929..a512dbd781 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 type { DrawingBlock } from '@superdoc/contracts'; +import { renderDrawingFrame, type RenderDrawingContentForPlacement } from './drawingFrame.js'; export type RenderTableDrawingFrameParams = { doc: Document; @@ -11,8 +11,7 @@ export type RenderTableDrawingFrameParams = { top?: number; zIndex?: number; flexShrink?: string; - renderDrawingContent?: (block: DrawingBlock, options?: { clipContainer?: HTMLElement }) => HTMLElement; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + renderDrawingContent?: RenderDrawingContentForPlacement; }; export const renderTableDrawingFrame = ({ @@ -26,43 +25,19 @@ export const renderTableDrawingFrame = ({ zIndex, flexShrink, 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', + renderDrawingContent, + }); }; + +export type { RenderDrawingContentForPlacement }; 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..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 '../renderer.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 new file mode 100644 index 0000000000..1faee2ac80 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/table-image-frame.ts @@ -0,0 +1,105 @@ +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'; + +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; + 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, + buildImageHyperlinkAnchor, +}: RenderTableImageFrameParams): HTMLElement => { + const wrapper = doc.createElement('div'); + 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`; + if (placement.zIndex != null) { + wrapper.style.zIndex = String(placement.zIndex); + } + } + + 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/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 { 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); }; 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 71ca0b8ba2..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, @@ -83,13 +83,9 @@ export type RenderParagraphContentParams = { betweenInfo?: BetweenBorderInfo; sdtBoundary?: SdtBoundaryOptions; spacingPolicy?: ParagraphSpacingPolicy; - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; - applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; renderLine: ParagraphRenderLine; renderDropCap?: ParagraphRenderDropCap; captureLineSnapshot?: ( @@ -124,13 +120,9 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re betweenInfo, sdtBoundary, spacingPolicy, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, - applySdtDataset, - applyContainerSdtDataset, renderDropCap, lineTopOffset = 0, } = params; @@ -149,8 +141,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/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 ea13a1e540..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, @@ -70,7 +64,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 { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; +import type { FragmentRenderContext } from './fragment-context.js'; import { applySourceAnchorDataset } from './utils/source-anchor.js'; import { renderDrawingFragment as renderDrawingFragmentElement } from './drawings/renderDrawingFragment.js'; import { isWordArtTextboxWatermarkBlock } from './textbox/wordArtWatermark.js'; @@ -205,25 +199,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; @@ -687,28 +662,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; @@ -2503,8 +2466,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, @@ -2583,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), }); @@ -2681,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 76edade860..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) { @@ -375,11 +370,11 @@ 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']; - 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/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..d03952ea1e 100644 --- a/packages/layout-engine/painters/dom/src/runs/text-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/text-run.ts @@ -1,13 +1,14 @@ 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'; 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 aff98713af..90518ee661 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; @@ -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/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts index 08d57980d8..bef55e1e18 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'; @@ -116,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); }); @@ -142,7 +143,7 @@ describe('SDT container chrome', () => { expect( shouldRenderSdtContainerChrome(childSdt, ancestorSdt, { - ancestorContainerSdt: ancestorSdt, + ancestorContainerSdts: [ancestorSdt], }), ).toBe(true); }); @@ -156,7 +157,7 @@ describe('SDT container chrome', () => { expect( shouldRenderSdtContainerChrome(null, sharedSdt, { - ancestorContainerSdt: sharedSdt, + ancestorContainerSdts: [sharedSdt], }), ).toBe(false); }); @@ -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..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; } @@ -165,5 +163,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; } 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..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 } from './embeddedTableFragment.js'; +import { + createEmbeddedTableFragment, + mapEmbeddedTableRowSlice, + mapEmbeddedTableRowSlices, +} from './embeddedTableFragment.js'; const makeNestedTableMeasure = (rowHeights: number[]): TableMeasure => ({ kind: 'table', @@ -84,6 +88,115 @@ 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('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('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 27d80ee8f7..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), @@ -117,6 +161,47 @@ 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; + const isPartial = rowSegmentCount > 1 && (rowStart < localFrom || rowEnd > localTo); + if (isPartial) { + partialRow = buildPartialRowInfo({ + blockRow: block.rows[r], + row: measure.rows[r], + rowIndex: r, + rowLocalFrom: Math.max(0, localFrom - rowStart), + rowLocalTo: Math.min(rowSegmentCount, localTo - rowStart), + }); + } + + 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; +} + function buildPartialRowInfo(params: { blockRow: TableRow | undefined; row: TableMeasure['rows'][number]; 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..2296307489 100644 --- a/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderResolvedTableFragment.ts @@ -13,9 +13,8 @@ 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'; 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 4d7f5bbf6c..a484782dbb 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', () => { @@ -241,6 +238,40 @@ 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(), + 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 +428,50 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.top).toBe('5px'); }); + it('does not stamp 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 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?.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', () => { const para: ParagraphBlock = { kind: 'paragraph', @@ -567,6 +642,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', @@ -680,6 +819,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', () => { @@ -1320,6 +1460,117 @@ 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 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', @@ -2501,6 +2752,315 @@ 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(), + 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-borders', blocks: [para1, para2], attrs: {} }, + }); + + 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'); + }); + + 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('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' }, + 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('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', + 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(), + 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-break', blocks: [para1, para2], attrs: {} }, + }); + + 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', () => { const para: ParagraphBlock = { kind: 'paragraph', @@ -4007,6 +4567,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', @@ -4029,7 +4591,6 @@ describe('renderTableCell', () => { height: 100, }; - const appliedMetadata: Array = []; const { cellElement } = renderTableCell({ ...createBaseDeps(), cellMeasure: { @@ -4046,15 +4607,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', () => { @@ -4332,7 +4890,7 @@ describe('renderTableCell', () => { ...createBaseDeps(), cellMeasure, cell, - ancestorContainerKey: 'structuredContent:table-sdt', + ancestorContainerKeys: ['structuredContent:table-sdt'], }); expect(cellElement.style.overflow).toBe('hidden'); @@ -4385,7 +4943,7 @@ describe('renderTableCell', () => { blocks: [para], attrs: {}, }, - ancestorContainerSdt: sharedSdt, + ancestorContainerSdts: [sharedSdt], }); expect(cellElement.style.overflow).toBe('hidden'); @@ -4582,6 +5140,126 @@ 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('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', @@ -4833,8 +5511,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; @@ -4926,8 +5604,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(); @@ -5024,8 +5702,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'); @@ -5116,8 +5794,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'); @@ -5895,7 +6573,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 e17c14b379..a90ae8e9de 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -7,20 +7,28 @@ import type { ImageHyperlink, ImageMeasure, Line, + Measure, ParagraphBlock, ParagraphMeasure, PartialRowInfo, - SdtMetadata, TableBlock, TableMeasure, WrapExclusion, WrapTextMode, + CellRenderBlock, +} from '@superdoc/contracts'; +import { + computeCellSliceContentHeight, + describeCellRenderBlocks, + getCellLines, + getCellSpacingPx, + normalizeZIndex, } 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 { createBlockImageContent } from '../images/image-block.js'; +import { renderTableImageFrame } from '../images/table-image-frame.js'; import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; import { getSdtContainerKeyForBlock, @@ -31,13 +39,15 @@ 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 { renderTableDrawingFrame } from '../drawings/tableDrawingFrame.js'; import { renderDrawingContent as renderSharedDrawingContent } from '../drawings/renderDrawingContent.js'; +import { applyStyles } from '../utils/apply-styles.js'; import { computeRenderedTableFragmentHeight, createEmbeddedTableFragment, getEmbeddedTableSegmentCount, - mapEmbeddedTableRowSlice, + mapEmbeddedTableRowSlices, } from './embeddedTableFragment.js'; type TableRowMeasure = TableMeasure['rows'][number]; @@ -47,24 +57,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. * @@ -100,20 +92,18 @@ 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) */ 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 */ - 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 */ @@ -145,7 +135,6 @@ type EmbeddedTableRenderParams = { * measure: nestedTableMeasure, * context, * renderLine, - * applySdtDataset, * }); * cellContent.appendChild(tableEl); * ``` @@ -162,13 +151,12 @@ const renderEmbeddedTable = ( renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, + continuesFromPrev, + continuesOnNext, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -181,6 +169,8 @@ const renderEmbeddedTable = ( fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, + continuesFromPrev, + continuesOnNext, }); const applyFragmentFrame = (el: HTMLElement, frag: Fragment): void => { @@ -203,11 +193,8 @@ const renderEmbeddedTable = ( captureLineSnapshot, renderDrawingContent, applyFragmentFrame, - applySdtDataset, - applyStyles: applyInlineStyles, + applyStyles, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome: () => { @@ -238,10 +225,7 @@ function renderPartialEmbeddedTable(params: { renderLine: EmbeddedTableRenderParams['renderLine']; captureLineSnapshot?: EmbeddedTableRenderParams['captureLineSnapshot']; renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; - applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; sdtBoundary?: SdtBoundaryOptions; - ancestorContainerKey?: string | null; - ancestorContainerSdt?: SdtMetadata | null; ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; onSdtContainerChrome?: () => void; @@ -258,10 +242,7 @@ function renderPartialEmbeddedTable(params: { renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -281,19 +262,27 @@ 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 internalSliceSpacingPx = tableMeasure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing); + const visibleHeight = rowSlices.reduce( + (height, rowSlice, index) => + height + + (index > 0 ? internalSliceSpacingPx : 0) + + computeRenderedTableFragmentHeight({ + block, + measure: tableMeasure, + fromRow: rowSlice.fromRow, + toRow: rowSlice.toRow, + partialRow: rowSlice.partialRow, + continuesFromPrev: localFrom > 0 || index > 0, + continuesOnNext: localTo < totalTableSegments || index < rowSlices.length - 1, + }), + 0, + ); const effectiveSdtBoundary = sdtBoundary ? { ...sdtBoundary, @@ -310,33 +299,60 @@ 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, + continuesFromPrev: localFrom > 0 || index > 0, + continuesOnNext: localTo < totalTableSegments || index < rowSlices.length - 1, + }); + const tableResult = renderEmbeddedTable({ + doc, + table: block, + measure: tableMeasure, + availableWidth: contentWidthPx, + context, + renderLine, + captureLineSnapshot, + renderDrawingContent, + 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 + ? { + ...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, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, + }); + tableResult.element.style.top = `${sliceTop}px`; + tableWrapper.appendChild(tableResult.element); + hasSdtContainerChrome ||= tableResult.hasSdtContainerChrome; + sliceTop += sliceHeight; + if (index < rowSlices.length - 1) sliceTop += internalSliceSpacingPx; }); - tableWrapper.appendChild(tableResult.element); return { element: tableWrapper, height: visibleHeight, nextCumulativeLineCount, - hasSdtContainerChrome: tableResult.hasSdtContainerChrome, + hasSdtContainerChrome, }; } @@ -389,12 +405,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 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 */ @@ -421,6 +431,207 @@ 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']; + 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 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, + 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); + } + } + return indexes; +}; + +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 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, + 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, + 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), + continuesFromPrev: localStartLine > 0, + continuesOnNext: localEndLine < blockLineCount, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome: () => { + cellEl.style.overflow = 'visible'; + onSdtContainerChrome?.(); + }, + 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. * @@ -471,7 +682,6 @@ export type TableCellRenderResult = { * return el; * }, * context, - * applySdtDataset * }); * container.appendChild(cellElement); * ``` @@ -490,9 +700,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot, renderDrawingContent, context, - applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -601,8 +808,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[], cellBlocks, 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); @@ -626,12 +838,72 @@ 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[] = []; + const betweenInfoByBlockIndex = computeBetweenBorderContext( + cellBlocks.slice(0, rawBlockCount).flatMap((block, index) => { + 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 []; + } + const y = paragraphContextY; + 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 [ + { + blockId: block?.id ?? `cell-block:${index}`, + x: 0, + y, + height, + }, + ]; + } + return [ + { + blockId: block?.id ?? `cell-block:${index}`, + x: 0, + y, + height, + borders: block.attrs.borders, + continuesFromPrev: blockStartGlobal < globalFromLine, + continuesOnNext: blockStartGlobal + blockLineCount > globalToLine, + }, + ]; + }), + ); + 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[] = []; 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]; @@ -648,10 +920,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, sdtBoundary: sdtBoundaries[i], - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -678,6 +947,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; @@ -686,25 +959,15 @@ 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, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }); content.appendChild(imageWrapper); flowCursorY += blockMeasure.height; continue; @@ -721,6 +984,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; @@ -737,7 +1004,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen position: 'relative', flexShrink: '0', renderDrawingContent: renderTableCellDrawingContent, - applySdtDataset, }); content.appendChild(drawingWrapper); flowCursorY += blockMeasure.height; @@ -745,82 +1011,31 @@ 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, - ancestorContainerKey, - ancestorContainerSdt, + paragraphMeasure: blockMeasure as ParagraphMeasure, + blockIndex: visibleBlockIndexByOriginalIndex.get(i) ?? i, + blockCount: visibleBlockIndexes.length, + cumulativeLineCount, + globalFromLine, + globalToLine, + contentWidthPx, + paddingTop, + flowCursorY, + sdtBoundary: sdtBoundaries[i], + betweenInfo: betweenInfoByOriginalBlockIndex.get(i), + context, + renderLine, 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 +1092,15 @@ 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, + buildImageHyperlinkAnchor: buildTableImageHyperlinkAnchor, + }); content.appendChild(imageWrapper); } else { const drawingWrapper = renderTableDrawingFrame({ @@ -910,7 +1113,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen top, zIndex, renderDrawingContent: renderTableCellDrawingContent, - applySdtDataset, }); content.appendChild(drawingWrapper); } 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..afcf5eb8ce 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 @@ -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), }); @@ -349,10 +345,9 @@ describe('renderTableFragment', () => { measure, cellSpacingPx: 0, effectiveColumnWidths: measure.columnWidths, - ancestorContainerKey: 'structuredContent:outer-sdt', + ancestorContainerKeys: ['structuredContent:outer-sdt'], renderLine: () => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: (el, styles) => Object.assign(el.style, styles), }); @@ -365,6 +360,75 @@ 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: () => {}, + 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('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: () => {}, + 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', @@ -484,9 +548,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -517,9 +578,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -559,9 +617,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -612,9 +667,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -655,9 +707,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -690,9 +739,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -721,9 +767,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -757,9 +800,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -791,9 +831,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -819,9 +856,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -854,9 +888,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -891,9 +922,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -936,9 +964,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1036,7 +1061,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }); @@ -1081,7 +1105,6 @@ describe('renderTableFragment', () => { effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }); @@ -1206,9 +1229,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1362,9 +1382,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1478,9 +1495,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1657,9 +1671,6 @@ describe('renderTableFragment', () => { applyFragmentFrame: () => { // Intentionally empty for test mock }, - applySdtDataset: () => { - // Intentionally empty for test mock - }, applyStyles: () => { // Intentionally empty for test mock }, @@ -1805,7 +1816,6 @@ describe('renderTableFragment', () => { renderLine: (_block: ParagraphBlock, _line: unknown, _ctx: unknown, _lineIndex: number, _isLastLine: boolean) => doc.createElement('div'), applyFragmentFrame: () => {}, - applySdtDataset: () => {}, applyStyles: () => {}, }; @@ -1933,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. @@ -2002,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 afd35449da..a2fc0a97ec 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, @@ -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'; @@ -48,10 +49,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 */ @@ -76,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; }; @@ -144,7 +137,6 @@ export type TableRenderDependencies = { * effectiveColumnWidths: tableMeasure.columnWidths, * renderLine, * applyFragmentFrame, - * applySdtDataset, * applyStyles * }); * container.appendChild(tableElement); @@ -160,8 +152,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement effectiveColumnWidths, context, sdtBoundary, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -169,8 +159,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applyFragmentFrame, - applySdtDataset, - applyContainerSdtDataset, applyStyles, } = deps; @@ -226,8 +214,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,19 +224,19 @@ 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); // 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 +279,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 +324,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } } - rowY += height + cellSpacingPx; + rowY += height + (i === renderedRows.length - 1 && !drawsSeparateBottom ? 0 : cellSpacingPx); } const metadata: Record = { @@ -380,11 +366,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 +386,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 @@ -427,9 +412,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, - ancestorContainerKey: nextAncestorContainerKey, - ancestorContainerSdt: nextAncestorContainerSdt, ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, @@ -439,7 +421,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); } } @@ -593,9 +578,6 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, - ancestorContainerKey: nextAncestorContainerKey, - ancestorContainerSdt: nextAncestorContainerSdt, ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, @@ -608,7 +590,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; 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 eeb751c718..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, @@ -18,7 +17,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]; @@ -172,12 +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 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 */ @@ -239,7 +232,6 @@ type TableRowRenderDependencies = { * tableBorders, * context, * renderLine, - * applySdtDataset * }); * // Appends all cell elements to container * ``` @@ -262,9 +254,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderLine, captureLineSnapshot, renderDrawingContent, - applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, @@ -438,9 +427,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { captureLineSnapshot, renderDrawingContent, context, - applySdtDataset, - ancestorContainerKey, - ancestorContainerSdt, ancestorContainerKeys, ancestorContainerSdts, onSdtContainerChrome, 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'; 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/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 = `
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..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 @@ -40,6 +40,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 = { @@ -139,6 +143,7 @@ export function buildFootnotesInput( enableRichHyperlinks: true, themeColors: themeColors as never, converterContext: converterContext as never, + resolveListRendering: true, }); if (result?.blocks?.length) { 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..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 @@ -1,24 +1,38 @@ -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; 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 }], + ...(markerText ? { attrs: { wordLayout: { marker: { markerText } } } } : {}), }, ], bookmarks: new Map(), @@ -29,6 +43,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 +82,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 +104,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 +232,69 @@ describe('buildFootnotesInput', () => { }); }); + 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.', + 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, 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, + ); + }); + + 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; }, });