diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 08b4f72446..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 @@ -57,6 +57,8 @@ reads. | Change style resolution | `style-engine/` | | Change text measurement | `measuring-dom/` | +AIDEV-NOTE: `pm-adapter` must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports. + ## Style Engine (`style-engine/`) Single source of truth for OOXML style cascade resolution. All property resolution flows through here. @@ -95,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`) + +`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 is extracted into **feature modules** under `painters/dom/src/features//`. This keeps `renderer.ts` focused on orchestration while feature-specific logic lives in discoverable, self-contained modules. +Rendering logic for specific OOXML features belongs in **feature modules** under `painters/dom/src/features//` or the matching concern directory. This keeps `renderer.ts` focused on orchestration while feature-specific logic lives in discoverable, self-contained modules. ### How to find where an OOXML element renders -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 @@ -128,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/index.ts b/packages/layout-engine/contracts/src/index.ts index 165b379d95..1bcd42c433 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -33,6 +33,17 @@ export { } from './engines/tables.js'; export { effectiveTableCellSpacing } from './table-cell-spacing.js'; +export { computeTableFragmentHeight } from './table-fragment-height.js'; +export { + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + describeCellRenderBlocks, + getCellLines, + getEmbeddedRowLines, + type CellRenderBlock, + type CellSliceCursor, +} from './table-cell-slice.js'; // Table column rescaling (moved from layout-engine for cross-stage use) export { rescaleColumnWidths } from './table-column-rescale.js'; @@ -101,6 +112,13 @@ export type { import type { LayoutSourceIdentity } from './layout-identity.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; +export { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, + isSdtContainerMetadata, +} from './sdt-container.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; diff --git a/packages/layout-engine/contracts/src/sdt-container.test.ts b/packages/layout-engine/contracts/src/sdt-container.test.ts new file mode 100644 index 0000000000..d0f8986479 --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { SdtMetadata } from './index.js'; +import { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, +} from './sdt-container.js'; + +describe('SDT container key helpers', () => { + it('uses the first renderable container metadata', () => { + const containerSdt: SdtMetadata = { type: 'documentSection', id: 'section-1' }; + + expect(getSdtContainerMetadata({ type: 'structuredContent', scope: 'inline', id: 'inline-1' }, containerSdt)).toBe( + containerSdt, + ); + }); + + it('derives explicit keys for block content controls and document sections', () => { + expect(getSdtContainerKey({ type: 'structuredContent', scope: 'block', id: 'sdt-1' })).toBe( + 'structuredContent:sdt-1', + ); + expect(getSdtContainerKey({ type: 'documentSection', sdBlockId: 'section-block-1' })).toBe( + 'documentSection:section-block-1', + ); + }); + + it('derives stable object keys for id-less containers', () => { + const sharedSdt: SdtMetadata = { type: 'structuredContent', scope: 'block', alias: 'Shared' }; + const firstKey = getSdtContainerKey(sharedSdt); + + expect(firstKey).toMatch(/^idlessSdt:/); + expect(getSdtContainerKey(sharedSdt)).toBe(firstKey); + expect(hasExplicitSdtContainerKey(sharedSdt)).toBe(false); + }); + + it('derives keys from any block-like object with SDT attrs', () => { + const sdt: SdtMetadata = { type: 'structuredContent', scope: 'block', id: 'media-sdt' }; + + expect(getSdtContainerKeyForBlock({ attrs: { sdt } })).toBe('structuredContent:media-sdt'); + }); +}); diff --git a/packages/layout-engine/contracts/src/sdt-container.ts b/packages/layout-engine/contracts/src/sdt-container.ts new file mode 100644 index 0000000000..8f650c197a --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.ts @@ -0,0 +1,78 @@ +import type { SdtMetadata } from './index.js'; + +type SdtBlockCandidate = { + attrs?: { + sdt?: SdtMetadata | null; + containerSdt?: SdtMetadata | null; + } | null; +}; + +const idlessSdtContainerKeys = new WeakMap(); +let nextIdlessSdtContainerKey = 0; + +function getIdlessSdtContainerKey(metadata: SdtMetadata): string { + const existingKey = idlessSdtContainerKeys.get(metadata); + if (existingKey) return existingKey; + + // AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same + // SdtMetadata object across sibling blocks in one container. Do not replace + // this with alias/title matching; separate controls can share display text. + const key = `idlessSdt:${++nextIdlessSdtContainerKey}`; + idlessSdtContainerKeys.set(metadata, key); + return key; +} + +export function isSdtContainerMetadata(sdt: SdtMetadata | null | undefined): boolean { + if (!sdt) return false; + if (sdt.type === 'documentSection') return true; + if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; + return false; +} + +export function getSdtContainerMetadata( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, +): SdtMetadata | null { + if (isSdtContainerMetadata(sdt)) return sdt ?? null; + if (isSdtContainerMetadata(containerSdt)) return containerSdt ?? null; + return null; +} + +export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (metadata.id) return `structuredContent:${metadata.id}`; + return getIdlessSdtContainerKey(metadata); + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (sectionId) return `documentSection:${sectionId}`; + return getIdlessSdtContainerKey(metadata); + } + + return null; +} + +export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; + + if (metadata.type === 'structuredContent') { + return metadata.scope === 'block' && Boolean(metadata.id); + } + + if (metadata.type === 'documentSection') { + return Boolean(metadata.id ?? metadata.sdBlockId); + } + + return false; +} + +export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { + if (!block) return null; + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); +} diff --git a/packages/layout-engine/contracts/src/table-cell-slice.test.ts b/packages/layout-engine/contracts/src/table-cell-slice.test.ts new file mode 100644 index 0000000000..8efbb734ff --- /dev/null +++ b/packages/layout-engine/contracts/src/table-cell-slice.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import type { ParagraphMeasure, TableBlock, TableCell, TableCellMeasure, TableMeasure } from './index.js'; +import { + computeCellSliceContentHeight, + computeFullCellContentHeight, + createCellSliceCursor, + describeCellRenderBlocks, + getCellLines, +} from './table-cell-slice.js'; + +describe('table cell segment mapping', () => { + const makeParagraph = (lineCount: number, lineHeight = 20): ParagraphMeasure => ({ + kind: 'paragraph', + lines: Array.from({ length: lineCount }, () => ({ + lineHeight, + width: 100, + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 1, + ascent: 14, + descent: 6, + })), + totalHeight: lineCount * lineHeight, + }); + + const makeImage = (height: number) => ({ + kind: 'image' as const, + width: 100, + height, + }); + + 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)], + width: 200, + height: 150, + }; + + 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), + width: 200, + height: 60, + }; + + expect(getCellLines(cell)).toHaveLength(3); + }); + + it('expands nested table rows recursively', () => { + const innermostTable: TableMeasure = { + kind: 'table', + rows: [ + { cells: [{ blocks: [makeParagraph(2)], width: 60, height: 40 }], height: 40 }, + { cells: [{ blocks: [makeParagraph(3)], width: 60, height: 60 }], height: 60 }, + ], + columnWidths: [60], + totalWidth: 60, + totalHeight: 100, + }; + const middleTable: TableMeasure = { + kind: 'table', + rows: [{ cells: [{ blocks: [innermostTable], width: 80, height: 100 }], height: 100 }], + columnWidths: [80], + totalWidth: 80, + totalHeight: 100, + }; + const outerTable: TableMeasure = { + kind: 'table', + rows: [{ cells: [{ blocks: [middleTable], width: 100, height: 100 }], height: 100 }], + columnWidths: [100], + totalWidth: 100, + totalHeight: 100, + }; + const cell: TableCellMeasure = { + blocks: [outerTable], + width: 200, + height: 100, + }; + + expect(getCellLines(cell)).toHaveLength(2); + }); + + it('uses embedded table total height for full table slices', () => { + const nestedTable: TableMeasure = { + kind: 'table', + rows: [{ cells: [{ blocks: [makeParagraph(1)], width: 80, height: 20 }], height: 20 }], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + cellSpacingPx: 2, + }; + const cell: TableCellMeasure = { + blocks: [nestedTable], + width: 100, + height: 24, + }; + const blocks = describeCellRenderBlocks(cell, undefined, { top: 0, bottom: 0 }); + + expect(computeCellSliceContentHeight(blocks, 0, 1)).toBe(24); + expect(computeFullCellContentHeight(cell, undefined, { top: 0, bottom: 0 })).toBe(24); + expect(createCellSliceCursor(blocks, 0).advanceLine(0)).toBe(24); + expect(createCellSliceCursor(blocks, 0).minSegmentCost(0)).toBe(24); + }); + + it('includes embedded table fragment spacing for partial row-boundary slices', () => { + const nestedTable: TableMeasure = { + kind: 'table', + rows: [ + { cells: [{ blocks: [makeParagraph(1)], width: 80, height: 20 }], height: 20 }, + { cells: [{ blocks: [makeParagraph(1)], width: 80, height: 20 }], height: 20 }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 46, + cellSpacingPx: 2, + }; + const cell: TableCellMeasure = { + blocks: [nestedTable], + width: 100, + height: 46, + }; + const blocks = describeCellRenderBlocks(cell, undefined, { top: 0, bottom: 0 }); + + expect(computeCellSliceContentHeight(blocks, 0, 1)).toBe(24); + expect(createCellSliceCursor(blocks, 0).advanceLine(0)).toBe(24); + expect(createCellSliceCursor(blocks, 0).minSegmentCost(0)).toBe(24); + }); + + it('includes embedded partial row cell padding and block spacing', () => { + const innerTableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [{ blocks: [makeParagraph(1, 10)], width: 40, height: 10 }], height: 10 }, + { cells: [{ blocks: [makeParagraph(1, 10)], width: 40, height: 10 }], height: 10 }, + ], + columnWidths: [40], + totalWidth: 40, + totalHeight: 20, + }; + const nestedTableMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { blocks: [innerTableMeasure], width: 50, height: 20 }, + { blocks: [makeParagraph(2, 10)], width: 50, height: 28 }, + ], + height: 20, + }, + ], + columnWidths: [50, 50], + totalWidth: 100, + totalHeight: 20, + }; + const innerTableBlock: TableBlock = { + kind: 'table', + id: 'inner-table', + rows: [ + { id: 'inner-row-1', cells: [{ id: 'inner-cell-1', blocks: [makeParagraphBlock('inner-p-1')] }] }, + { id: 'inner-row-2', cells: [{ id: 'inner-cell-2', blocks: [makeParagraphBlock('inner-p-2')] }] }, + ], + }; + const nestedTableBlock: TableBlock = { + kind: 'table', + id: 'nested-table', + rows: [ + { + id: 'nested-row', + cells: [ + { id: 'nested-cell-1', blocks: [innerTableBlock], attrs: { padding: { top: 3, bottom: 4 } } }, + { + id: 'nested-cell-2', + blocks: [makeParagraphBlock('nested-p', { before: 12 })], + attrs: { padding: { top: 5, bottom: 6 } }, + }, + ], + }, + ], + }; + const cell: TableCellMeasure = { + blocks: [nestedTableMeasure], + width: 100, + height: 28, + }; + const cellBlock = { id: 'outer-cell', blocks: [nestedTableBlock] }; + const blocks = describeCellRenderBlocks(cell, cellBlock, { top: 0, bottom: 0 }); + + expect(computeCellSliceContentHeight(blocks, 0, 1)).toBe(28); + expect(createCellSliceCursor(blocks, 0).advanceLine(0)).toBe(28); + expect(createCellSliceCursor(blocks, 0).minSegmentCost(0)).toBe(28); + }); +}); diff --git a/packages/layout-engine/contracts/src/table-cell-slice.ts b/packages/layout-engine/contracts/src/table-cell-slice.ts new file mode 100644 index 0000000000..5ea94d1571 --- /dev/null +++ b/packages/layout-engine/contracts/src/table-cell-slice.ts @@ -0,0 +1,558 @@ +import type { + ParagraphMeasure, + TableCell, + TableCellMeasure, + TableMeasure, + TableRow, + TableRowMeasure, +} from './index.js'; +import { effectiveTableCellSpacing } from './table-cell-spacing.js'; + +/** + * Shared cell-slice helpers for table pagination and rendering. + * + * These descriptors are consumed by layout pagination, layout-bridge selection + * geometry, and DomPainter nested-table rendering. Keep their height semantics + * aligned with the actual table-cell renderer. + */ +export type CellRenderBlock = { + kind: 'paragraph' | 'table' | 'other'; + globalStartLine: number; + globalEndLine: number; + lineHeights: number[]; + totalHeight: number; + visibleHeight: number; + isFirstBlock: boolean; + isLastBlock: boolean; + spacingBefore: number; + spacingAfter: number; + tableRows?: TableRenderRow[]; + cellSpacingPx?: number; + tableBorderVerticalPx?: number; +}; + +export interface CellSliceCursor { + advanceLine(globalLineIndex: number): number; + minSegmentCost(globalLineIndex: number): number; +} + +type TableRenderRow = { + localStartLine: number; + localEndLine: number; + height: number; + lineHeights: number[]; + cells: TableCellMeasure[]; + blockRow?: TableRow; +}; + +/** @internal */ +export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { + const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((block) => block.kind === 'table')); + + if (!hasNestedTable) { + return [{ lineHeight: row.height || 0 }]; + } + + let tallestLines: Array<{ lineHeight: number }> = []; + for (const cell of row.cells) { + const cellLines = getCellLines(cell); + if (cellLines.length > tallestLines.length) { + tallestLines = cellLines; + } + } + + return tallestLines.length > 0 ? tallestLines : [{ lineHeight: row.height || 0 }]; +} + +/** @internal */ +export function getCellLines(cell: TableCellMeasure): Array<{ lineHeight: number }> { + if (cell.blocks && cell.blocks.length > 0) { + const allLines: Array<{ lineHeight: number }> = []; + for (const block of cell.blocks) { + if (block.kind === 'paragraph') { + allLines.push(...((block as ParagraphMeasure).lines ?? [])); + } else if (block.kind === 'table') { + const table = block as TableMeasure; + for (const row of table.rows) { + allLines.push(...getEmbeddedRowLines(row)); + } + } else { + const blockHeight = 'height' in block ? (block as { height: number }).height : 0; + if (blockHeight > 0) { + allLines.push({ lineHeight: blockHeight }); + } + } + } + return allLines; + } + + return cell.paragraph?.lines ?? []; +} + +export function describeCellRenderBlocks( + cellMeasure: TableCellMeasure, + cellBlock: TableCell | undefined, + cellPadding: { top: number; bottom: number }, +): CellRenderBlock[] { + const measuredBlocks = cellMeasure.blocks; + const blockDataArray = cellBlock?.blocks; + + if (!measuredBlocks || measuredBlocks.length === 0) { + if (cellMeasure.paragraph) { + return buildSingleParagraphBlock(cellMeasure.paragraph, cellBlock?.paragraph, cellPadding); + } + return []; + } + + 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 === firstVisibleBlockIndex; + const isLastBlock = i === lastVisibleBlockIndex; + + if (measure.kind === 'paragraph') { + const paraMeasure = measure as ParagraphMeasure; + const paraData = data?.kind === 'paragraph' ? data : undefined; + const lineHeights = (paraMeasure.lines ?? []).map((line) => line.lineHeight); + const sumLines = sumArray(lineHeights); + const startLine = globalLine; + globalLine += lineHeights.length; + + result.push({ + kind: 'paragraph', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights, + totalHeight: paraMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock, + isLastBlock, + spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top), + spacingAfter: resolveSpacingAfter(paraData?.attrs?.spacing?.after, isLastBlock), + }); + } else if (measure.kind === 'table') { + const tableMeasure = measure as TableMeasure; + const tableData = data?.kind === 'table' ? data : undefined; + const lineHeights: number[] = []; + const tableRows: TableRenderRow[] = []; + let tableLocalLine = 0; + for (let rowIndex = 0; rowIndex < tableMeasure.rows.length; rowIndex += 1) { + const row = tableMeasure.rows[rowIndex]; + const rowLineHeights: number[] = []; + for (const segment of getEmbeddedRowLines(row)) { + rowLineHeights.push(segment.lineHeight); + } + lineHeights.push(...rowLineHeights); + tableRows.push({ + localStartLine: tableLocalLine, + localEndLine: tableLocalLine + rowLineHeights.length, + height: row.height, + lineHeights: rowLineHeights, + cells: row.cells, + blockRow: tableData?.rows?.[rowIndex], + }); + tableLocalLine += rowLineHeights.length; + } + + const startLine = globalLine; + globalLine += lineHeights.length; + const sumLines = sumArray(lineHeights); + const borderCollapse = + tableData?.attrs?.borderCollapse ?? (tableData?.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + const tableBorderVerticalPx = + borderCollapse === 'separate' && tableMeasure.tableBorderWidths + ? tableMeasure.tableBorderWidths.top + tableMeasure.tableBorderWidths.bottom + : 0; + + result.push({ + kind: 'table', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights, + totalHeight: tableMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock, + isLastBlock, + spacingBefore: 0, + spacingAfter: 0, + tableRows, + cellSpacingPx: tableMeasure.cellSpacingPx ?? 0, + tableBorderVerticalPx, + }); + } else { + const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; + if (blockHeight <= 0) continue; + + const outOfFlow = isAnchoredOutOfFlow(data); + const startLine = globalLine; + globalLine += 1; + + result.push({ + kind: 'other', + globalStartLine: startLine, + globalEndLine: globalLine, + lineHeights: [blockHeight], + totalHeight: outOfFlow ? 0 : blockHeight, + visibleHeight: outOfFlow ? 0 : blockHeight, + isFirstBlock, + isLastBlock, + spacingBefore: 0, + spacingAfter: 0, + }); + } + } + + return result; +} + +export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLine: number, toLine: number): number { + let height = 0; + + for (const block of blocks) { + if (block.globalEndLine <= fromLine || block.globalStartLine >= toLine) continue; + + const localStart = Math.max(0, fromLine - block.globalStartLine); + const localEnd = Math.min(block.lineHeights.length, toLine - block.globalStartLine); + const rendersEntireBlock = localStart === 0 && localEnd >= block.lineHeights.length; + + if (block.kind === 'paragraph') { + if (localStart === 0) { + height += block.spacingBefore; + } + + const sliceLineSum = sumArray(block.lineHeights.slice(localStart, localEnd)); + if (rendersEntireBlock) { + height += Math.max(sliceLineSum, block.totalHeight); + height += block.spacingAfter; + } else { + height += sliceLineSum; + } + } else if (block.visibleHeight > 0) { + if (block.kind === 'table') { + const tableSliceHeight = computeTableBlockSliceHeight(block, localStart, localEnd); + if (rendersEntireBlock) { + height += Math.max(tableSliceHeight, block.totalHeight); + } else { + height += tableSliceHeight; + } + continue; + } + + for (let i = localStart; i < localEnd; i += 1) { + height += block.lineHeights[i] ?? 0; + } + } + } + + return height; +} + +export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: number): CellSliceCursor { + let blockIdx = 0; + let startedFromLine0 = false; + let blockLineSum = 0; + let tableSliceStartLocal = 0; + let tableSliceHeight = 0; + + while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= startLine) { + blockIdx += 1; + } + if (blockIdx < blocks.length) { + const block = blocks[blockIdx]; + startedFromLine0 = startLine <= block.globalStartLine; + tableSliceStartLocal = Math.max(0, startLine - block.globalStartLine); + if (!startedFromLine0) { + for (let li = 0; li < startLine - block.globalStartLine; li += 1) { + blockLineSum += block.lineHeights[li] ?? 0; + } + } + } + + return { + advanceLine(globalLineIndex: number): number { + while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= globalLineIndex) { + blockIdx += 1; + startedFromLine0 = true; + blockLineSum = 0; + tableSliceStartLocal = 0; + tableSliceHeight = 0; + } + if (blockIdx >= blocks.length) return 0; + + const block = blocks[blockIdx]; + const localLine = globalLineIndex - block.globalStartLine; + const lineHeight = block.lineHeights[localLine] ?? 0; + let cost = 0; + + if (block.kind === 'table') { + const nextTableSliceHeight = computeTableBlockSliceHeight(block, tableSliceStartLocal, localLine + 1); + cost = Math.max(0, nextTableSliceHeight - tableSliceHeight); + tableSliceHeight = nextTableSliceHeight; + + const isBlockComplete = localLine === block.lineHeights.length - 1; + if (isBlockComplete) { + if (startedFromLine0) { + cost += Math.max(0, block.totalHeight - tableSliceHeight); + } + blockIdx += 1; + startedFromLine0 = true; + blockLineSum = 0; + tableSliceStartLocal = 0; + tableSliceHeight = 0; + } + + return cost; + } + + if (localLine === 0 && startedFromLine0 && block.kind === 'paragraph') { + cost += block.spacingBefore; + } + if (block.kind === 'paragraph' || block.visibleHeight > 0) { + cost += lineHeight; + } + + blockLineSum += lineHeight; + + const isBlockComplete = localLine === block.lineHeights.length - 1; + if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { + cost += Math.max(0, block.totalHeight - blockLineSum); + } + if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { + cost += block.spacingAfter; + } + if (isBlockComplete) { + blockIdx += 1; + startedFromLine0 = true; + blockLineSum = 0; + tableSliceStartLocal = 0; + tableSliceHeight = 0; + } + + return cost; + }, + + minSegmentCost(globalLineIndex: number): number { + const block = findBlockForLine(blocks, globalLineIndex); + if (!block) return 0; + + const localLine = globalLineIndex - block.globalStartLine; + const lineHeight = block.lineHeights[localLine] ?? 0; + let cost = 0; + + if (localLine === 0 && block.kind === 'paragraph') { + cost += block.spacingBefore; + } + if (block.kind === 'paragraph' || block.visibleHeight > 0) { + cost += lineHeight; + } + if (block.kind === 'table') { + return computeTableBlockSliceHeight(block, localLine, localLine + 1); + } + if (block.lineHeights.length === 1 && block.kind === 'paragraph') { + cost += Math.max(0, block.totalHeight - lineHeight); + } + if (block.lineHeights.length === 1 && block.kind === 'paragraph') { + cost += block.spacingAfter; + } + + return cost; + }, + }; +} + +export function computeFullCellContentHeight( + cellMeasure: TableCellMeasure, + cellBlock: TableCell | undefined, + cellPadding: { top: number; bottom: number }, +): number { + const measuredBlocks = cellMeasure.blocks; + const blockDataArray = cellBlock?.blocks; + + if (!measuredBlocks || measuredBlocks.length === 0) { + if (!cellMeasure.paragraph) return 0; + + const lineSum = sumArray(cellMeasure.paragraph.lines.map((line) => line.lineHeight)); + const paraData = cellBlock?.paragraph; + const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top); + const spacingAfter = effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); + return spacingBefore + Math.max(lineSum, cellMeasure.paragraph.totalHeight ?? lineSum) + spacingAfter; + } + + // This function uses measurement semantics: the final paragraph's spacing.after + // contributes only when it exceeds cell padding. Renderer-slice helpers skip + // last-block spacing.after because DomPainter positions the visible content + // inside the already padded cell. Keeping that distinction explicit prevents + // 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 === firstVisibleBlockIndex; + const isLastBlock = i === lastVisibleBlockIndex; + + if (measure.kind === 'paragraph') { + const paraMeasure = measure as ParagraphMeasure; + const paraData = data?.kind === 'paragraph' ? data : undefined; + const lineSum = sumArray((paraMeasure.lines ?? []).map((line) => line.lineHeight)); + + height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); + height += Math.max(lineSum, paraMeasure.totalHeight ?? lineSum); + if (isLastBlock) { + height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); + } else { + height += resolveSpacingAfter(paraData?.attrs?.spacing?.after, false); + } + } else if (measure.kind === 'table') { + const table = measure as TableMeasure; + height += table.totalHeight; + } else { + const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; + if (blockHeight > 0 && !isAnchoredOutOfFlow(data)) { + height += blockHeight; + } + } + } + + return height; +} + +function buildSingleParagraphBlock( + paraMeasure: ParagraphMeasure, + paraData: { attrs?: { spacing?: { before?: number; after?: number } } } | undefined, + cellPadding: { top: number; bottom: number }, +): CellRenderBlock[] { + const lines = paraMeasure.lines ?? []; + if (lines.length === 0) return []; + + const lineHeights = lines.map((line) => line.lineHeight); + const sumLines = sumArray(lineHeights); + + return [ + { + kind: 'paragraph', + globalStartLine: 0, + globalEndLine: lines.length, + lineHeights, + totalHeight: paraMeasure.totalHeight ?? sumLines, + visibleHeight: sumLines, + isFirstBlock: true, + isLastBlock: true, + spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top), + spacingAfter: 0, + }, + ]; +} + +function resolveSpacingAfter(spacingAfter: number | undefined, isLastBlock: boolean): number { + if (isLastBlock) return 0; + 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; + const anchor = b.anchor as Record | undefined; + if (!anchor?.isAnchored) return false; + const wrap = b.wrap as Record | undefined; + return (wrap?.type ?? 'Inline') !== 'Inline'; +} + +function findBlockForLine(blocks: CellRenderBlock[], globalLineIndex: number): CellRenderBlock | undefined { + return blocks.find((block) => globalLineIndex >= block.globalStartLine && globalLineIndex < block.globalEndLine); +} + +function computeTableBlockSliceHeight(block: CellRenderBlock, localStart: number, localEnd: number): number { + if (!block.tableRows) { + return sumArray(block.lineHeights.slice(localStart, localEnd)); + } + + let height = 0; + let rowCount = 0; + + for (const row of block.tableRows) { + if (row.localEndLine <= localStart || row.localStartLine >= localEnd) continue; + + rowCount += 1; + const rowLocalStart = Math.max(0, localStart - row.localStartLine); + const rowLocalEnd = Math.min(row.lineHeights.length, localEnd - row.localStartLine); + const rendersFullRow = rowLocalStart === 0 && rowLocalEnd >= row.lineHeights.length; + + if (rendersFullRow) { + height += row.height; + } else { + height += computePartialTableRowSliceHeight(row, rowLocalStart, rowLocalEnd); + } + } + + if (rowCount > 0) { + height += (rowCount + 1) * (block.cellSpacingPx ?? 0); + height += block.tableBorderVerticalPx ?? 0; + } + + return height; +} + +function computePartialTableRowSliceHeight(row: TableRenderRow, rowLocalStart: number, rowLocalEnd: number): number { + let partialHeight = 0; + + for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) { + const cellMeasure = row.cells[cellIndex]; + const cellLineCount = getCellLines(cellMeasure).length; + const cellFrom = Math.min(rowLocalStart, cellLineCount); + const cellTo = Math.min(rowLocalEnd, cellLineCount); + const padding = getCellPadding(row.blockRow, cellIndex); + const blocks = describeCellRenderBlocks(cellMeasure, row.blockRow?.cells?.[cellIndex], padding); + + partialHeight = Math.max( + partialHeight, + computeCellSliceContentHeight(blocks, cellFrom, cellTo) + padding.top + padding.bottom, + ); + } + + return partialHeight; +} + +function getCellPadding(blockRow: TableRow | undefined, cellIndex: number): { top: number; bottom: number } { + const padding = blockRow?.cells?.[cellIndex]?.attrs?.padding; + return { + top: padding?.top ?? 0, + bottom: padding?.bottom ?? 0, + }; +} + +function sumArray(arr: number[]): number { + let total = 0; + for (const value of arr) total += value; + return total; +} diff --git a/packages/layout-engine/contracts/src/table-fragment-height.test.ts b/packages/layout-engine/contracts/src/table-fragment-height.test.ts new file mode 100644 index 0000000000..a1340c69d9 --- /dev/null +++ b/packages/layout-engine/contracts/src/table-fragment-height.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import type { PartialRowInfo, TableMeasure } from './index.js'; +import { computeTableFragmentHeight } from './table-fragment-height.js'; + +describe('computeTableFragmentHeight', () => { + const measure: TableMeasure = { + kind: 'table', + rows: [ + { height: 10, cells: [] }, + { height: 20, cells: [] }, + { height: 30, cells: [] }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 72, + cellSpacingPx: 2, + tableBorderWidths: { top: 3, right: 0, bottom: 5, left: 0 }, + }; + + it('includes repeated headers, body rows, spacing, and separate borders', () => { + expect( + computeTableFragmentHeight({ + measure, + fromRow: 1, + toRow: 3, + repeatHeaderCount: 1, + borderCollapse: 'separate', + }), + ).toBe(10 + 20 + 30 + 4 * 2 + 3 + 5); + }); + + it('substitutes partial row height', () => { + const partialRow: PartialRowInfo = { + rowIndex: 1, + fromLineByCell: [0], + toLineByCell: [1], + isFirstPart: true, + isLastPart: false, + partialHeight: 12, + }; + + expect( + computeTableFragmentHeight({ + measure, + fromRow: 1, + toRow: 2, + partialRow, + }), + ).toBe(12 + 2 * 2); + }); +}); diff --git a/packages/layout-engine/contracts/src/table-fragment-height.ts b/packages/layout-engine/contracts/src/table-fragment-height.ts new file mode 100644 index 0000000000..94ebc6d5e5 --- /dev/null +++ b/packages/layout-engine/contracts/src/table-fragment-height.ts @@ -0,0 +1,36 @@ +import type { PartialRowInfo, TableMeasure } from './index.js'; + +export function computeTableFragmentHeight(params: { + measure: TableMeasure; + fromRow: number; + toRow: number; + repeatHeaderCount?: number; + borderCollapse?: 'collapse' | 'separate'; + partialRow?: PartialRowInfo | null; + cellSpacingPx?: number; +}): number { + const { measure, fromRow, toRow, repeatHeaderCount = 0, borderCollapse, partialRow } = params; + const cellSpacingPx = params.cellSpacingPx ?? measure.cellSpacingPx ?? 0; + let height = 0; + let rowCount = 0; + + for (let r = 0; r < repeatHeaderCount && r < measure.rows.length; r += 1) { + height += measure.rows[r].height; + rowCount += 1; + } + + for (let r = fromRow; r < toRow && r < measure.rows.length; r += 1) { + height += partialRow?.rowIndex === r ? partialRow.partialHeight : measure.rows[r].height; + rowCount += 1; + } + + if (rowCount > 0 && cellSpacingPx > 0) { + height += (rowCount + 1) * cellSpacingPx; + } + + if (rowCount > 0 && borderCollapse === 'separate' && measure.tableBorderWidths) { + height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; + } + + return height; +} diff --git a/packages/layout-engine/dom-contract/src/index.test.ts b/packages/layout-engine/dom-contract/src/index.test.ts index 2b95e56e38..6fe795cabe 100644 --- a/packages/layout-engine/dom-contract/src/index.test.ts +++ b/packages/layout-engine/dom-contract/src/index.test.ts @@ -15,6 +15,8 @@ import { DRAGGABLE_SELECTOR, encodeLayoutStoryDataset, decodeLayoutStoryDataset, + getNoteStoryKind, + isNonBodyStoryBlockId, } from './index.js'; describe('@superdoc/dom-contract', () => { @@ -129,4 +131,22 @@ describe('@superdoc/dom-contract', () => { it('exports DRAGGABLE_SELECTOR constant', () => { expect(DRAGGABLE_SELECTOR).toBe('[data-draggable="true"]'); }); + + it.each([ + ['footnote-1-abc', 'footnote'], + ['endnote-1-abc', 'endnote'], + ['__sd_semantic_footnote-1-abc', 'semantic-footnote'], + ['__sd_semantic_endnote-1-abc', 'semantic-endnote'], + ] as const)('detects %s as a non-body %s story block', (blockId, kind) => { + expect(getNoteStoryKind(blockId)).toBe(kind); + expect(isNonBodyStoryBlockId(blockId)).toBe(true); + }); + + it.each(['body-paragraph-1', 'footnotes-heading', '__sd_semantic_footnotes_heading', undefined])( + 'does not treat %s as a note body fragment', + (blockId) => { + expect(getNoteStoryKind(blockId)).toBeUndefined(); + expect(isNonBodyStoryBlockId(blockId)).toBe(false); + }, + ); }); diff --git a/packages/layout-engine/dom-contract/src/index.ts b/packages/layout-engine/dom-contract/src/index.ts index 0ae8cd80b1..b751a448ed 100644 --- a/packages/layout-engine/dom-contract/src/index.ts +++ b/packages/layout-engine/dom-contract/src/index.ts @@ -29,3 +29,6 @@ export { SDT_BLOCK_WITH_ID_SELECTOR, DRAGGABLE_SELECTOR, } from './selectors.js'; + +export { getNoteStoryKind, isNonBodyStoryBlockId } from './note-story.js'; +export type { NoteStoryKind } from './note-story.js'; diff --git a/packages/layout-engine/dom-contract/src/note-story.ts b/packages/layout-engine/dom-contract/src/note-story.ts new file mode 100644 index 0000000000..5520e121c7 --- /dev/null +++ b/packages/layout-engine/dom-contract/src/note-story.ts @@ -0,0 +1,24 @@ +export type NoteStoryKind = 'footnote' | 'endnote' | 'semantic-footnote' | 'semantic-endnote'; + +export const getNoteStoryKind = (blockId: string | undefined): NoteStoryKind | undefined => { + if (typeof blockId !== 'string') { + return undefined; + } + + if (blockId.startsWith('footnote-')) { + return 'footnote'; + } + if (blockId.startsWith('endnote-')) { + return 'endnote'; + } + if (blockId.startsWith('__sd_semantic_footnote-')) { + return 'semantic-footnote'; + } + if (blockId.startsWith('__sd_semantic_endnote-')) { + return 'semantic-endnote'; + } + + return undefined; +}; + +export const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => getNoteStoryKind(blockId) !== undefined; diff --git a/packages/layout-engine/layout-bridge/src/index.ts b/packages/layout-engine/layout-bridge/src/index.ts index 08e2e752b3..2f3f2d512a 100644 --- a/packages/layout-engine/layout-bridge/src/index.ts +++ b/packages/layout-engine/layout-bridge/src/index.ts @@ -15,11 +15,13 @@ import type { } from '@superdoc/contracts'; import { adjustAvailableWidthForTextIndent, + computeCellSliceContentHeight, + describeCellRenderBlocks, computeLinePmRange as computeLinePmRangeUnified, effectiveTableCellSpacing, getFirstLineIndentOffset, + getEmbeddedRowLines, } from '@superdoc/contracts'; -import { describeCellRenderBlocks, computeCellSliceContentHeight, getEmbeddedRowLines } from '@superdoc/layout-engine'; import { measureCharacterX } from './text-measurement.js'; import { clickToPositionDom, findPageElement } from './dom-mapping.js'; import { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 2d4d1d3c62..4cc0a9d1e9 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -3409,7 +3409,6 @@ export { resolvePageNumberTokens } from './resolvePageTokens.js'; export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js'; // Table utilities consumed by layout-bridge and cross-package sync tests -export { getCellLines, getEmbeddedRowLines, resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; -export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; +export { resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 3f30d140cc..1a4361ea36 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -13,13 +13,18 @@ import type { } from '@superdoc/contracts'; import { OOXML_PCT_DIVISOR, + computeTableFragmentHeight, + describeCellRenderBlocks, + createCellSliceCursor, + computeFullCellContentHeight, + getCellLines, + getEmbeddedRowLines, + getTableVisualDirection, rescaleColumnWidths, resolveTableWidthAttr, - getTableVisualDirection, } from '@superdoc/contracts'; import type { PageState } from './paginator.js'; import { computeFragmentPmRange, extractBlockPmRange } from './layout-utils.js'; -import { describeCellRenderBlocks, createCellSliceCursor, computeFullCellContentHeight } from './table-cell-slice.js'; /** * Ratio of column width (0..1). An anchored table with totalWidth >= columnWidth * this value @@ -440,37 +445,7 @@ function computeFragmentHeight( borderCollapse?: 'collapse' | 'separate', partialRow?: PartialRowInfo | null, ): number { - let height = 0; - let rowCount = 0; - - // Repeated headers - if (repeatHeaderCount > 0) { - height += sumRowHeights(measure.rows, 0, repeatHeaderCount); - rowCount += repeatHeaderCount; - } - - // Body rows — substitute partialRow height when applicable - for (let i = fromRow; i < toRow && i < measure.rows.length; i++) { - if (partialRow && partialRow.rowIndex === i) { - height += partialRow.partialHeight; - } else { - height += measure.rows[i].height; - } - rowCount++; - } - - // Cell spacing: gaps before first row, between rows, and after last row - const cellSpacingPx = measure.cellSpacingPx ?? 0; - if (rowCount > 0 && cellSpacingPx > 0) { - height += (rowCount + 1) * cellSpacingPx; - } - - // Outer border height when border-collapse is separate - if (rowCount > 0 && measure.tableBorderWidths && borderCollapse === 'separate') { - height += measure.tableBorderWidths.top + measure.tableBorderWidths.bottom; - } - - return height; + return computeTableFragmentHeight({ measure, fromRow, toRow, repeatHeaderCount, borderCollapse, partialRow }); } type SplitPointResult = { @@ -486,77 +461,6 @@ type SplitPointResult = { */ const MIN_PARTIAL_ROW_HEIGHT = 20; -/** - * Get the line segments for a single embedded table row. - * - * If any cell in the row contains nested tables, recursively expand using - * the tallest cell's segments. This enables the layout engine to split at - * sub-row boundaries even for deeply nested tables (table-in-table-in-table). - * Otherwise, return the row as a single segment with its measured height. - */ -export function getEmbeddedRowLines(row: TableRowMeasure): Array<{ lineHeight: number }> { - // Check if any cell has nested table blocks - const hasNestedTable = row.cells.some((cell) => cell.blocks?.some((b) => b.kind === 'table')); - - if (!hasNestedTable) { - // Simple case: no nested tables, row is one segment - return [{ lineHeight: row.height || 0 }]; - } - - // Recursive case: find the cell with the most segments (tallest content) - let tallestLines: Array<{ lineHeight: number }> = []; - for (const cell of row.cells) { - const cellLines = getCellLines(cell); - if (cellLines.length > tallestLines.length) { - tallestLines = cellLines; - } - } - - return tallestLines.length > 0 ? tallestLines : [{ lineHeight: row.height || 0 }]; -} - -export function getCellLines(cell: TableRowMeasure['cells'][number]): Array<{ lineHeight: number }> { - // Multi-block cells use the `blocks` array - if (cell.blocks && cell.blocks.length > 0) { - const allLines: Array<{ lineHeight: number }> = []; - for (const block of cell.blocks) { - if (block.kind === 'paragraph') { - if ('lines' in block) { - const paraBlock = block as ParagraphMeasure; - if (paraBlock.lines) { - allLines.push(...paraBlock.lines); - } - } - } else if (block.kind === 'table') { - // Embedded tables: expand individual rows as separate segments so the - // outer table splitter can break at embedded-table row boundaries, - // matching MS Word behavior where nested tables paginate across pages. - // Recursively expand rows that contain further nested tables. - const tableBlock = block as TableMeasure; - for (const row of tableBlock.rows) { - allLines.push(...getEmbeddedRowLines(row)); - } - } else { - // Non-paragraph blocks (images, drawings) are represented as a single - // unsplittable segment with their full height. This ensures computePartialRow - // accounts for their height when splitting rows across pages. - const blockHeight = 'height' in block ? (block as { height: number }).height : 0; - if (blockHeight > 0) { - allLines.push({ lineHeight: blockHeight }); - } - } - } - return allLines; - } - - // Fallback to single paragraph (backward compatibility) - if (cell.paragraph?.lines) { - return cell.paragraph.lines; - } - - return []; -} - type CellPadding = { top: number; bottom: number; left: number; right: number }; function getCellPadding(cellIdx: number, blockRow?: TableRow): CellPadding { diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts index da222c7e67..f12972fb3f 100644 --- a/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts +++ b/packages/layout-engine/layout-engine/src/table-cell-slice.test.ts @@ -7,7 +7,7 @@ import { computeFullCellContentHeight, createCellSliceCursor, type CellRenderBlock, -} from './table-cell-slice.js'; +} from '@superdoc/contracts'; // ─── Test helpers ──────────────────────────────────────────────────────────── diff --git a/packages/layout-engine/layout-engine/src/table-cell-slice.ts b/packages/layout-engine/layout-engine/src/table-cell-slice.ts deleted file mode 100644 index c084e4ed26..0000000000 --- a/packages/layout-engine/layout-engine/src/table-cell-slice.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * Shared cell-slice-height module for table pagination. - * - * Provides a single source of truth for computing rendered cell-slice heights - * that match the DOM painter's actual rendering semantics (spacing.before, - * totalHeight promotion, spacing.after). Used by: - * - * - `computePartialRow()` — fitting loop via incremental cursor (Layer 2) - * - `getRowContentHeight()` — one-shot full-row height (Layer 1) - * - `layout-bridge` — selection-rect vertical positioning (Layer 1) - * - * Lives in `@superdoc/layout-engine` because it depends on layout-engine - * internals (`getEmbeddedRowLines`) that are not part of the contract surface. - */ - -import type { TableCellMeasure, TableCell, ParagraphMeasure, TableMeasure, TableRowMeasure } from '@superdoc/contracts'; -import { effectiveTableCellSpacing } from '@superdoc/contracts'; -import { getEmbeddedRowLines } from './layout-table.js'; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/** - * Describes one block in a table cell with renderer-semantic height values. - * - * Maps each measured block to its global line index range and the spacing / - * height values the DOM painter actually applies. Layout decisions that use - * these descriptors stay synchronized with `renderTableCell.ts`. - */ -export type CellRenderBlock = { - kind: 'paragraph' | 'table' | 'other'; - /** First global line index (inclusive). */ - globalStartLine: number; - /** Past-the-end global line index (exclusive). */ - globalEndLine: number; - /** Per-segment heights matching `getCellLines()` output. */ - lineHeights: number[]; - /** `ParagraphMeasure.totalHeight ?? sum(lineHeights)`. */ - totalHeight: number; - /** Height contributing to content flow. 0 for anchored out-of-flow blocks. */ - visibleHeight: number; - isFirstBlock: boolean; - isLastBlock: boolean; - /** Effective spacing.before (first block: excess over padding.top; others: full). */ - spacingBefore: number; - /** Raw spacing.after; always 0 for last block (renderer skips it). */ - spacingAfter: number; -}; - -/** - * Stateful cursor for the `computePartialRow()` fitting loop. - * - * Advances one line at a time and reports the rendered cost of each line - * including block-boundary spacing and totalHeight promotion. O(1) per step. - */ -export interface CellSliceCursor { - /** - * Compute the rendered cost of including the line at `globalLineIndex`. - * Advances internal state — call exactly once per line, in ascending order. - * After calling, if the line doesn't fit, break; the cursor state no longer - * matters since it won't be used again for this cell. - */ - advanceLine(globalLineIndex: number): number; - - /** - * Minimum rendered cost of the segment at `globalLineIndex`, for - * force-progress checks. Pure peek — does not modify cursor state. - */ - minSegmentCost(globalLineIndex: number): number; -} - -// ─── Builder ───────────────────────────────────────────────────────────────── - -/** - * Build an ordered array of block descriptors from a cell's measurement and - * block data. Descriptors carry all renderer-semantic information needed by - * `computeCellSliceContentHeight` and the fitting cursor. - * - * **Iteration rule**: driven by measured blocks (source of truth for line - * counts). Block data is attached by index when available; missing data - * degrades to zero spacing and `totalHeight = sum(lineHeights)`. - */ -export function describeCellRenderBlocks( - cellMeasure: TableCellMeasure, - cellBlock: TableCell | undefined, - cellPadding: { top: number; bottom: number }, -): CellRenderBlock[] { - const measuredBlocks = cellMeasure.blocks; - const blockDataArray = cellBlock?.blocks; - - // Backward-compat: single-paragraph cells - if (!measuredBlocks || measuredBlocks.length === 0) { - if (cellMeasure.paragraph) { - return buildSingleParagraphBlock(cellMeasure.paragraph, cellBlock?.paragraph, cellPadding); - } - return []; - } - - const result: CellRenderBlock[] = []; - let globalLine = 0; - const blockCount = measuredBlocks.length; - - for (let i = 0; i < blockCount; i++) { - const measure = measuredBlocks[i]; - const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; - const isFirstBlock = i === 0; - const isLastBlock = i === blockCount - 1; - - if (measure.kind === 'paragraph') { - const paraMeasure = measure as ParagraphMeasure; - const paraData = data?.kind === 'paragraph' ? data : undefined; - - const lines = paraMeasure.lines ?? []; - const lineHeights = lines.map((l) => l.lineHeight); - const sumLines = sumArray(lineHeights); - - const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); - const rawAfter = paraData?.attrs?.spacing?.after; - const spacingAfter = isLastBlock ? 0 : typeof rawAfter === 'number' && rawAfter > 0 ? rawAfter : 0; - - const startLine = globalLine; - globalLine += lines.length; - - result.push({ - kind: 'paragraph', - globalStartLine: startLine, - globalEndLine: globalLine, - lineHeights, - totalHeight: paraMeasure.totalHeight ?? sumLines, - visibleHeight: sumLines, - isFirstBlock, - isLastBlock, - spacingBefore, - spacingAfter, - }); - } else if (measure.kind === 'table') { - // Embedded table — expand rows the same way getCellLines() does - const tableMeasure = measure as TableMeasure; - const lineHeights: number[] = []; - for (const row of tableMeasure.rows) { - for (const seg of getEmbeddedRowLines(row)) { - lineHeights.push(seg.lineHeight); - } - } - - const startLine = globalLine; - globalLine += lineHeights.length; - const sumLines = sumArray(lineHeights); - - result.push({ - kind: 'table', - globalStartLine: startLine, - globalEndLine: globalLine, - lineHeights, - totalHeight: sumLines, - visibleHeight: sumLines, - isFirstBlock, - isLastBlock, - spacingBefore: 0, - spacingAfter: 0, - }); - } else { - // Image, drawing, or other non-paragraph block. - // getCellLines() only adds a segment when height > 0. - const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; - if (blockHeight > 0) { - const outOfFlow = isAnchoredOutOfFlow(data); - const startLine = globalLine; - globalLine += 1; - - result.push({ - kind: 'other', - globalStartLine: startLine, - globalEndLine: globalLine, - lineHeights: [blockHeight], - totalHeight: outOfFlow ? 0 : blockHeight, - visibleHeight: outOfFlow ? 0 : blockHeight, - isFirstBlock, - isLastBlock, - spacingBefore: 0, - spacingAfter: 0, - }); - } - // height === 0 → getCellLines() skips it, no line index consumed - } - } - - return result; -} - -// ─── Layer 1: Pure full-slice function ─────────────────────────────────────── - -/** - * Content-area height of a cell slice `[fromLine, toLine)`. - * - * Matches the DOM painter's rendering semantics: - * - `spacing.before` when rendering from the start of a block - * - `totalHeight` promotion for fully rendered paragraphs - * - `spacing.after` for fully rendered non-last paragraphs - * - * Returns content height only — cell padding is NOT included. - * O(blocks) per call. - */ -export function computeCellSliceContentHeight(blocks: CellRenderBlock[], fromLine: number, toLine: number): number { - let height = 0; - - for (const block of blocks) { - if (block.globalEndLine <= fromLine || block.globalStartLine >= toLine) continue; - - const localStart = Math.max(0, fromLine - block.globalStartLine); - const localEnd = Math.min(block.lineHeights.length, toLine - block.globalStartLine); - const rendersEntireBlock = localStart === 0 && localEnd >= block.lineHeights.length; - - if (block.kind === 'paragraph') { - // spacing.before when rendering from line 0 — matches renderTableCell.ts:1386-1394 - if (localStart === 0) { - height += block.spacingBefore; - } - - let sliceLineSum = 0; - for (let i = localStart; i < localEnd; i++) { - sliceLineSum += block.lineHeights[i]; - } - - if (rendersEntireBlock) { - // Promote to totalHeight — matches renderTableCell.ts:1478-1482 - height += Math.max(sliceLineSum, block.totalHeight); - // spacing.after for non-last blocks — matches renderTableCell.ts:1492-1500 - // (block.spacingAfter is already 0 for the last block) - height += block.spacingAfter; - } else { - height += sliceLineSum; - } - } else { - // Table / other blocks — contribute overlapped visible heights - if (block.visibleHeight === 0) continue; // anchored out-of-flow - for (let i = localStart; i < localEnd; i++) { - height += block.lineHeights[i]; - } - } - } - - return height; -} - -// ─── Layer 2: Incremental cursor ───────────────────────────────────────────── - -/** - * Create a stateful cursor for the `computePartialRow()` fitting loop. - * - * The cursor tracks block boundaries and accumulates spacing / promotion costs - * so that each `advanceLine()` call is O(1). If the fitting loop starts from a - * continuation (mid-block), the cursor correctly skips spacing.before and - * totalHeight promotion for the partially consumed block. - */ -export function createCellSliceCursor(blocks: CellRenderBlock[], startLine: number): CellSliceCursor { - let blockIdx = 0; - let startedFromLine0 = false; - let blockLineSum = 0; - - // Advance to the block containing startLine - while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= startLine) { - blockIdx++; - } - if (blockIdx < blocks.length) { - const block = blocks[blockIdx]; - startedFromLine0 = startLine <= block.globalStartLine; - // Pre-accumulate line heights for lines already consumed in this block - if (!startedFromLine0) { - for (let li = 0; li < startLine - block.globalStartLine; li++) { - blockLineSum += block.lineHeights[li] ?? 0; - } - } - } - - return { - advanceLine(globalLineIndex: number): number { - // Handle block transitions - while (blockIdx < blocks.length && blocks[blockIdx].globalEndLine <= globalLineIndex) { - blockIdx++; - startedFromLine0 = true; - blockLineSum = 0; - } - if (blockIdx >= blocks.length) return 0; - - const block = blocks[blockIdx]; - const localLine = globalLineIndex - block.globalStartLine; - const lineHeight = block.lineHeights[localLine] ?? 0; - let cost = 0; - - // spacing.before when entering a paragraph block at its first line - if (localLine === 0 && startedFromLine0 && block.kind === 'paragraph') { - cost += block.spacingBefore; - } - - // Line's visible contribution - if (block.kind === 'paragraph' || block.visibleHeight > 0) { - cost += lineHeight; - } - - // Track line height within the block (before block-completion check) - blockLineSum += lineHeight; - - // Block completion: totalHeight promotion + spacingAfter - const isBlockComplete = localLine === block.lineHeights.length - 1; - if (isBlockComplete && startedFromLine0 && block.kind === 'paragraph') { - cost += Math.max(0, block.totalHeight - blockLineSum); - cost += block.spacingAfter; - } - - // Advance to next block if this one is complete - if (isBlockComplete) { - blockIdx++; - startedFromLine0 = true; - blockLineSum = 0; - } - - return cost; - }, - - minSegmentCost(globalLineIndex: number): number { - // Pure peek — does not modify cursor state - const block = findBlockForLine(blocks, globalLineIndex); - if (!block) return 0; - - const localLine = globalLineIndex - block.globalStartLine; - const lineHeight = block.lineHeights[localLine] ?? 0; - let cost = 0; - - // Include spacing.before if this is the first line of a paragraph block - if (localLine === 0 && block.kind === 'paragraph') { - cost += block.spacingBefore; - } - - // Include visible line height - if (block.kind === 'paragraph' || block.visibleHeight > 0) { - cost += lineHeight; - } - - // For single-line blocks, include completion costs - if (block.lineHeights.length === 1 && block.kind === 'paragraph') { - cost += Math.max(0, block.totalHeight - lineHeight); - cost += block.spacingAfter; - } - - return cost; - }, - }; -} - -// ─── Hot-path: allocation-free full-cell height ────────────────────────────── - -/** - * Content height of a fully rendered cell, using **measurement** semantics. - * - * Unlike `describeCellRenderBlocks` + `computeCellSliceContentHeight` (which - * use renderer semantics and skip last-block spacing.after), this function - * includes last-block spacing.after via `effectiveTableCellSpacing` to match - * how `rowMeasure.height` was computed by the measurer. This keeps - * `getRowContentHeight()` aligned with `rowMeasure.height` so that - * `hasExplicitRowHeightSlack()` compares like-for-like. - * - * Computes in a single pass without allocating intermediate arrays. - * Returns content height only — cell padding is NOT included. - */ -export function computeFullCellContentHeight( - cellMeasure: TableCellMeasure, - cellBlock: TableCell | undefined, - cellPadding: { top: number; bottom: number }, -): number { - const measuredBlocks = cellMeasure.blocks; - const blockDataArray = cellBlock?.blocks; - - // Single paragraph fallback (first + last block) - if (!measuredBlocks || measuredBlocks.length === 0) { - if (cellMeasure.paragraph) { - const pm = cellMeasure.paragraph; - let sumLines = 0; - for (const l of pm.lines) sumLines += l.lineHeight; - const paraData = cellBlock?.paragraph; - const spacingBefore = effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top); - // Measurement semantics: last-block spacing.after is absorbed into - // paddingBottom, but excess still contributes to measured height. - const spacingAfter = effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); - return spacingBefore + Math.max(sumLines, pm.totalHeight ?? sumLines) + spacingAfter; - } - return 0; - } - - let height = 0; - const blockCount = measuredBlocks.length; - - for (let i = 0; i < blockCount; i++) { - const measure = measuredBlocks[i]; - const data = i < (blockDataArray?.length ?? 0) ? blockDataArray![i] : undefined; - const isFirstBlock = i === 0; - const isLastBlock = i === blockCount - 1; - - if (measure.kind === 'paragraph') { - const pm = measure as ParagraphMeasure; - const paraData = data?.kind === 'paragraph' ? data : undefined; - let sumLines = 0; - for (const l of pm.lines ?? []) sumLines += l.lineHeight; - - height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, isFirstBlock, cellPadding.top); - height += Math.max(sumLines, pm.totalHeight ?? sumLines); - if (!isLastBlock) { - const rawAfter = paraData?.attrs?.spacing?.after; - if (typeof rawAfter === 'number' && rawAfter > 0) height += rawAfter; - } else { - // Measurement semantics: last-block spacing.after is absorbed into - // paddingBottom, but excess still contributes to measured height. - // This keeps getRowContentHeight aligned with rowMeasure.height. - height += effectiveTableCellSpacing(paraData?.attrs?.spacing?.after, true, cellPadding.bottom); - } - } else if (measure.kind === 'table') { - // Sum row heights directly — avoids getEmbeddedRowLines() expansion. - // For a fully rendered table this equals the sum of all segments. - const tm = measure as TableMeasure; - for (const row of tm.rows) height += row.height; - } else { - // Image, drawing: contribute height only when inline (not anchored out-of-flow) - const blockHeight = 'height' in measure ? (measure as { height: number }).height : 0; - if (blockHeight > 0 && !isAnchoredOutOfFlow(data)) { - height += blockHeight; - } - } - } - - return height; -} - -// ─── Private helpers ───────────────────────────────────────────────────────── - -function buildSingleParagraphBlock( - paraMeasure: ParagraphMeasure, - paraData: { attrs?: { spacing?: { before?: number; after?: number } } } | undefined, - cellPadding: { top: number; bottom: number }, -): CellRenderBlock[] { - const lines = paraMeasure.lines ?? []; - if (lines.length === 0) return []; - - const lineHeights = lines.map((l) => l.lineHeight); - const sumLines = sumArray(lineHeights); - - return [ - { - kind: 'paragraph', - globalStartLine: 0, - globalEndLine: lines.length, - lineHeights, - totalHeight: paraMeasure.totalHeight ?? sumLines, - visibleHeight: sumLines, - isFirstBlock: true, - isLastBlock: true, - spacingBefore: effectiveTableCellSpacing(paraData?.attrs?.spacing?.before, true, cellPadding.top), - spacingAfter: 0, // Last block → renderer skips spacing.after - }, - ]; -} - -/** - * Detect anchored out-of-flow blocks (images/drawings positioned outside - * the normal content flow). These consume a line index in `getCellLines()` - * but contribute zero visible height in the renderer. - */ -function isAnchoredOutOfFlow(block: unknown): boolean { - if (!block || typeof block !== 'object') return false; - const b = block as Record; - const anchor = b.anchor as Record | undefined; - if (!anchor?.isAnchored) return false; - const wrap = b.wrap as Record | undefined; - return (wrap?.type ?? 'Inline') !== 'Inline'; -} - -function findBlockForLine(blocks: CellRenderBlock[], globalLineIndex: number): CellRenderBlock | undefined { - for (const block of blocks) { - if (globalLineIndex >= block.globalStartLine && globalLineIndex < block.globalEndLine) { - return block; - } - } - return undefined; -} - -function sumArray(arr: number[]): number { - let total = 0; - for (const v of arr) total += v; - return total; -} diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index d817fd0d5f..0b5272d4e9 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2793,7 +2793,7 @@ describe('resolveLayout', () => { expect(drItem.sdtContainerKey).toBeUndefined(); }); - it('returns null (omits key) for structuredContent block scope with no id', () => { + it('sets an object-stable key for structuredContent block scope with no id', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2815,10 +2815,10 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); - it('returns null (omits key) for documentSection with no id or sdBlockId', () => { + it('sets an object-stable key for documentSection with no id or sdBlockId', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2840,7 +2840,7 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 900d38ae13..6e1a8dd4d5 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -22,12 +22,12 @@ import type { ParagraphMeasure, LayoutStoryLocator, } from '@superdoc/contracts'; +import { getSdtContainerKey } from '@superdoc/contracts'; import { resolveParagraphContent } from './resolveParagraph.js'; import { resolveTableItem } from './resolveTable.js'; import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; -import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; import { deriveBlockVersion, @@ -162,17 +162,17 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map listItem.id === fragment.itemId); - return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + return getSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); } if (fragment.kind === 'table' && block.kind === 'table') { - return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); } // image, drawing — no SDT container keys diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts deleted file mode 100644 index 4cee08673f..0000000000 --- a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SdtMetadata } from '@superdoc/contracts'; - -/** - * Returns a stable key for grouping consecutive fragments in the same SDT container. - * - * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts` - * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package. - * Only the key derivation is needed; DOM styling helpers are not. - */ -export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (!metadata.id) return null; - return `structuredContent:${metadata.id}`; - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) return null; - return `documentSection:${sectionId}`; - } - - return null; -} - -function isSdtContainer(sdt?: SdtMetadata | null): boolean { - if (!sdt) return false; - if (sdt.type === 'documentSection') return true; - if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; - return false; -} - -function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null { - if (isSdtContainer(sdt)) return sdt ?? null; - if (isSdtContainer(containerSdt)) return containerSdt ?? null; - return null; -} diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 4385b9453d..485e126da2 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -66,3 +66,142 @@ describe('deriveBlockVersion - bidi', () => { expect(a).toBe(b); }); }); + +describe('deriveBlockVersion - table image content', () => { + const makeTableWithImage = (image: ImageBlock): TableBlock => ({ + kind: 'table', + id: 'table-with-image', + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [image], + }, + ], + }, + ], + }); + + const baseImage: ImageBlock = { + kind: 'image', + id: 'image-1', + src: 'data:image/png;base64,AAA', + width: 40, + height: 20, + }; + + it('changes when a table image filter changes', () => { + const plain = deriveBlockVersion(makeTableWithImage(baseImage)); + const filtered = deriveBlockVersion(makeTableWithImage({ ...baseImage, grayscale: true })); + + expect(filtered).not.toBe(plain); + }); + + it('changes when a table image hyperlink changes', () => { + const unlinked = deriveBlockVersion(makeTableWithImage(baseImage)); + const linked = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' }, + }), + ); + + expect(linked).not.toBe(unlinked); + }); + + it('does not collide when image hyperlink URL and tooltip contain separators', () => { + const first = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/a', tooltip: 'b:c' }, + }), + ); + const second = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/a:b', tooltip: 'c' }, + }), + ); + + expect(second).not.toBe(first); + }); +}); + +describe('deriveBlockVersion - inline image runs', () => { + const baseImageRun: ImageRun = { + kind: 'image', + src: 'data:image/png;base64,AAA', + width: 40, + height: 20, + }; + + const makeParagraphWithImageRun = (image: ImageRun): FlowBlock => ({ + kind: 'paragraph', + id: 'paragraph-with-image-run', + runs: [image], + }); + + const makeTableWithImageRun = (image: ImageRun): TableBlock => ({ + kind: 'table', + id: 'table-with-inline-image-run', + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [makeParagraphWithImageRun(image)], + }, + ], + }, + ], + }); + + it('changes when an inline image filter changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const filtered = deriveBlockVersion( + makeParagraphWithImageRun({ ...baseImageRun, grayscale: true, lum: { bright: 25000 } }), + ); + + expect(filtered).not.toBe(plain); + }); + + it('changes when an inline image transform changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const transformed = deriveBlockVersion(makeParagraphWithImageRun({ ...baseImageRun, rotation: 45, flipH: true })); + + expect(transformed).not.toBe(plain); + }); + + it('changes when an inline image hyperlink changes', () => { + const unlinked = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const linked = deriveBlockVersion( + makeParagraphWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/inline-image' } }), + ); + + expect(linked).not.toBe(unlinked); + }); + + it('changes when an inline image raw clip path changes', () => { + const clipA = { ...baseImageRun, clipPath: 'url(#clip-a)' }; + const clipB = { ...baseImageRun, clipPath: 'url(#clip-b)' }; + + expect(deriveBlockVersion(makeParagraphWithImageRun(clipA))).not.toBe( + deriveBlockVersion(makeParagraphWithImageRun(clipB)), + ); + expect(deriveBlockVersion(makeTableWithImageRun(clipA))).not.toBe(deriveBlockVersion(makeTableWithImageRun(clipB))); + }); + + it('changes when a table-cell inline image visual property changes', () => { + const plain = deriveBlockVersion(makeTableWithImageRun(baseImageRun)); + const filtered = deriveBlockVersion(makeTableWithImageRun({ ...baseImageRun, grayscale: true })); + const linked = deriveBlockVersion( + makeTableWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/table-inline-image' } }), + ); + + expect(filtered).not.toBe(plain); + expect(linked).not.toBe(plain); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 7d1f223147..bb8cbc5d8d 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -81,6 +81,60 @@ const resolveBlockClipPath = (block: unknown): string => { return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); }; +const imageHyperlinkVersion = (hyperlink: ImageBlock['hyperlink'] | undefined): string => { + if (!hyperlink) return ''; + return JSON.stringify([hyperlink.url ?? '', hyperlink.tooltip ?? '']); +}; + +const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { + if (!lum) return ''; + return [lum.bright ?? '', lum.contrast ?? ''].join(':'); +}; + +const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string => + [ + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + image.objectFit ?? '', + image.display ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + resolveBlockClipPath(image), + ].join('|'); + +const renderedInlineImageRunVersion = (image: ImageRun): string => + [ + 'img', + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + typeof image.clipPath === 'string' ? image.clipPath.trim() : '', + image.distTop ?? '', + image.distBottom ?? '', + image.distLeft ?? '', + image.distRight ?? '', + image.verticalAlign ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + ].join('|'); + // --------------------------------------------------------------------------- // List marker validation // --------------------------------------------------------------------------- @@ -203,9 +257,8 @@ export const resolveFragmentLayoutIdentity = (fragment: Fragment, story?: Layout * This version string is used for cache invalidation. When any visual property of the block * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements. * - * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to - * pre-compute block versions without depending on painter-dom. Keep the two copies in sync - * until the painter fully migrates to resolved versions. + * Kept in layout-resolved so the resolved layout stage can pre-compute block + * versions without depending on painter-dom. */ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'paragraph') { @@ -216,21 +269,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { const runsVersion = block.runs .map((run) => { if (run.kind === 'image') { - const imgRun = run as ImageRun; - return [ - 'img', - imgRun.src, - imgRun.width, - imgRun.height, - imgRun.alt ?? '', - imgRun.title ?? '', - imgRun.clipPath ?? '', - imgRun.distTop ?? '', - imgRun.distBottom ?? '', - imgRun.distLeft ?? '', - imgRun.distRight ?? '', - readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), - ].join(','); + return renderedInlineImageRunVersion(run as ImageRun); } if (run.kind === 'lineBreak') { @@ -329,28 +368,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'image') { const imgSdt = (block as ImageBlock).attrs?.sdt; const imgSdtVersion = getSdtMetadataVersion(imgSdt); - return [ - block.src ?? '', - block.width ?? '', - block.height ?? '', - block.alt ?? '', - block.title ?? '', - resolveBlockClipPath(block), - imgSdtVersion, - ].join('|'); + return [renderedBlockImageVersion(block), imgSdtVersion].join('|'); } if (block.kind === 'drawing') { if (block.drawingKind === 'image') { const imageLike = block as ImageDrawing; - return [ - 'drawing:image', - imageLike.src ?? '', - imageLike.width ?? '', - imageLike.height ?? '', - imageLike.alt ?? '', - resolveBlockClipPath(imageLike), - ].join('|'); + return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|'); } if (block.drawingKind === 'vectorShape') { const vector = block as VectorShapeDrawing; @@ -461,6 +485,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => { } for (const run of runs) { + if (run.kind === 'image') { + hash = hashString(hash, renderedInlineImageRunVersion(run as ImageRun)); + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + continue; + } + if ('text' in run && typeof run.text === 'string') { hash = hashString(hash, run.text); } @@ -482,6 +513,8 @@ export const deriveBlockVersion = (block: FlowBlock): string => { const bidi = (run as { bidi?: unknown }).bidi; hash = hashString(hash, bidi ? JSON.stringify(bidi) : ''); } + } else if (cellBlock?.kind) { + hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock)); } } } diff --git a/packages/layout-engine/painters/dom/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/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts index 9030fe8f47..12f33cf124 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -6,7 +6,7 @@ import { getParagraphBorderBox, computeBorderSpaceExpansion, type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; +} from './paragraph/borders/index.js'; import { hashParagraphBorders } from './paragraph-hash-utils.js'; /** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */ @@ -27,13 +27,11 @@ import type { ParagraphBorders, ParagraphBorder, ParagraphBlock, - ListBlock, Fragment, FlowBlock, Layout, Measure, ParaFragment, - ListItemFragment, ImageFragment, ResolvedPaintItem, ResolvedFragmentItem, @@ -50,35 +48,19 @@ const makeParagraphBlock = (id: string, borders?: ParagraphBorders): ParagraphBl attrs: borders ? { borders } : undefined, }); -const makeListBlock = (id: string, items: { itemId: string; borders?: ParagraphBorders }[]): ListBlock => ({ - kind: 'list', - id, - listType: 'bullet', - items: items.map((item) => ({ - id: item.itemId, - marker: { text: '•' }, - paragraph: { - kind: 'paragraph', - id: `${id}-p-${item.itemId}`, - runs: [], - attrs: item.borders ? { borders: item.borders } : undefined, - }, - })), -}); - /** * Test surrogate for the old BlockLookup — a list of blocks keyed by id that * `buildResolvedItems` consumes to synthesize per-fragment ResolvedPaintItems. */ -type TestBlockList = ReadonlyArray; +type TestBlockList = ReadonlyArray; -const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): TestBlockList => +const buildLookup = (entries: { block: ParagraphBlock; measure?: unknown }[]): TestBlockList => entries.map((e) => e.block); /** * Build resolved items aligned 1:1 with the given fragments. - * Looks up each fragment's block (+ list item) to extract paragraph borders, - * then produces a ResolvedFragmentItem carrying the borders and a border hash. + * Looks up each fragment's block to extract paragraph borders, then produces a + * ResolvedFragmentItem carrying the borders and a border hash. */ const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockList): ResolvedPaintItem[] => { const byId = new Map(blocks.map((b) => [b.id, b])); @@ -88,9 +70,6 @@ const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockLis if (fragment.kind === 'para' && block?.kind === 'paragraph') { borders = block.attrs?.borders; - } else if (fragment.kind === 'list-item' && block?.kind === 'list') { - const item = block.items.find((listItem) => listItem.id === fragment.itemId); - borders = item?.paragraph.attrs?.borders; } const item: ResolvedFragmentItem = { @@ -127,23 +106,6 @@ const paraFragment = (blockId: string, overrides?: Partial): ParaF ...overrides, }); -const listItemFragment = ( - blockId: string, - itemId: string, - overrides?: Partial, -): ListItemFragment => ({ - kind: 'list-item', - blockId, - itemId, - fromLine: 0, - toLine: 1, - x: 0, - y: 0, - width: 100, - markerWidth: 20, - ...overrides, -}); - const imageFragment = (blockId: string): ImageFragment => ({ kind: 'image', blockId, @@ -517,29 +479,6 @@ describe('computeBetweenBorderFlags', () => { expect(runFlags(fragments, lookup).size).toBe(0); }); - it('does not flag same blockId + same itemId list-item fragments', () => { - const block = makeListBlock('l1', [{ itemId: 'i1', borders: MATCHING_BORDERS }]); - const lookup = buildLookup([{ block }]); - const fragments: Fragment[] = [ - listItemFragment('l1', 'i1', { fromLine: 0, toLine: 2 }), - listItemFragment('l1', 'i1', { fromLine: 2, toLine: 4 }), - ]; - - expect(runFlags(fragments, lookup).size).toBe(0); - }); - - it('flags different itemIds in same list block', () => { - const block = makeListBlock('l1', [ - { itemId: 'i1', borders: MATCHING_BORDERS }, - { itemId: 'i2', borders: MATCHING_BORDERS }, - ]); - const lookup = buildLookup([{ block }]); - const fragments: Fragment[] = [listItemFragment('l1', 'i1'), listItemFragment('l1', 'i2')]; - - const flags = runFlags(fragments, lookup); - expect(flags.has(0)).toBe(true); - }); - // --- non-paragraph fragments --- it('skips image fragments', () => { const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); @@ -555,25 +494,6 @@ describe('computeBetweenBorderFlags', () => { expect(flags.size).toBe(0); }); - // --- mixed para + list-item --- - it('flags para followed by list-item with matching borders', () => { - const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); - const block = makeListBlock('l1', [{ itemId: 'i1', borders: MATCHING_BORDERS }]); - const lookup = buildLookup([{ block: b1 }, { block }]); - const fragments: Fragment[] = [paraFragment('b1'), listItemFragment('l1', 'i1')]; - - expect(runFlags(fragments, lookup).has(0)).toBe(true); - }); - - it('flags list-item followed by para with matching borders', () => { - const block = makeListBlock('l1', [{ itemId: 'i1', borders: MATCHING_BORDERS }]); - const b2 = makeParagraphBlock('b2', MATCHING_BORDERS); - const lookup = buildLookup([{ block }, { block: b2 }]); - const fragments: Fragment[] = [listItemFragment('l1', 'i1'), paraFragment('b2')]; - - expect(runFlags(fragments, lookup).has(0)).toBe(true); - }); - // --- multiple consecutive --- it('flags all boundaries in a chain of three matching paragraphs', () => { const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); 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/placeholder.ts b/packages/layout-engine/painters/dom/src/drawings/placeholder.ts new file mode 100644 index 0000000000..e3a9cc255e --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/placeholder.ts @@ -0,0 +1,12 @@ +export const createDrawingPlaceholder = (doc: Document): HTMLElement => { + const placeholder = doc.createElement('div'); + placeholder.classList.add('superdoc-drawing-placeholder'); + placeholder.style.width = '100%'; + placeholder.style.height = '100%'; + const stripePattern = + 'repeating-linear-gradient(45deg, rgba(15,23,42,0.1), rgba(15,23,42,0.1) 6px, rgba(15,23,42,0.2) 6px, rgba(15,23,42,0.2) 12px)'; + placeholder.style.background = stripePattern; + placeholder.style.backgroundImage = stripePattern; + placeholder.style.border = '1px dashed rgba(15, 23, 42, 0.3)'; + return placeholder; +}; diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts new file mode 100644 index 0000000000..5a44268dd4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import type { DrawingBlock } from '@superdoc/contracts'; +import { buildImageHyperlinkAnchor } from '../images/hyperlink.js'; +import { renderDrawingContent } from './renderDrawingContent.js'; + +describe('renderDrawingContent', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('drawing-content'); + + it('renders vector shapes through the shared drawing content path', () => { + const doc = createDoc(); + const block: DrawingBlock = { + kind: 'drawing', + id: 'shape-1', + drawingKind: 'vectorShape', + geometry: { width: 100, height: 50 }, + shapeKind: 'rect', + fillColor: '#ff0000', + strokeColor: '#000000', + }; + + const el = renderDrawingContent({ + doc, + block, + geometry: block.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + expect(el.classList.contains('superdoc-vector-shape')).toBe(true); + expect(el.querySelector('svg')).toBeTruthy(); + }); + + it('renders vector shape text through the textbox renderer', () => { + const doc = createDoc(); + const block: DrawingBlock = { + kind: 'drawing', + id: 'shape-text-1', + drawingKind: 'vectorShape', + geometry: { width: 120, height: 60 }, + shapeKind: 'rect', + fillColor: '#ffffff', + strokeColor: '#000000', + textAlign: 'left', + textVerticalAlign: 'center', + textInsets: { top: 2, right: 4, bottom: 6, left: 8 }, + textContent: { + parts: [{ text: 'Shape text', formatting: { bold: true } }], + }, + }; + + const el = renderDrawingContent({ + doc, + block, + geometry: block.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + const textOverlay = el.querySelector('div[style*="display: flex"]') as HTMLElement | null; + const span = textOverlay?.querySelector('span') as HTMLSpanElement | null; + expect(textOverlay).toBeTruthy(); + expect(textOverlay?.style.justifyContent).toBe('center'); + expect(textOverlay?.style.padding).toBe('2px 4px 6px 8px'); + expect(span?.textContent).toBe('Shape text'); + expect(span?.style.fontWeight).toBe('bold'); + }); + + it('renders shape groups and charts through the shared drawing content path', () => { + const doc = createDoc(); + const shapeGroup: DrawingBlock = { + kind: 'drawing', + id: 'group-1', + drawingKind: 'shapeGroup', + geometry: { width: 100, height: 100 }, + shapes: [{ shapeType: 'image', attrs: { x: 0, y: 0, width: 40, height: 30, src: 'data:image/png;base64,AAA' } }], + }; + const chart: DrawingBlock = { + kind: 'drawing', + id: 'chart-1', + drawingKind: 'chart', + geometry: { width: 120, height: 80 }, + chartData: undefined, + }; + + const groupEl = renderDrawingContent({ + doc, + block: shapeGroup, + geometry: shapeGroup.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + const chartEl = renderDrawingContent({ + doc, + block: chart, + geometry: chart.geometry, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + expect(groupEl.classList.contains('superdoc-shape-group')).toBe(true); + expect(groupEl.querySelector('img')).toBeTruthy(); + expect(chartEl.classList.contains('superdoc-chart')).toBe(true); + expect(chartEl.querySelector('svg')).toBeFalsy(); + expect(chartEl.style.display).toBe('flex'); + }); + + it('renders fallback placeholders through the shared drawing content path', () => { + const doc = createDoc(); + const block = { + kind: 'drawing', + id: 'unknown-1', + drawingKind: 'unsupported', + } as unknown as DrawingBlock; + + const el = renderDrawingContent({ + doc, + block, + buildImageHyperlinkAnchor: (imageEl) => imageEl, + }); + + expect(el.classList.contains('superdoc-drawing-placeholder')).toBe(true); + expect(el.style.border).toContain('dashed'); + }); + + it('uses shared image behavior for filters, hyperlinks, and clip containers', () => { + const doc = createDoc(); + const clipContainer = doc.createElement('div'); + const block: DrawingBlock = { + kind: 'drawing', + id: 'image-1', + drawingKind: 'image', + src: 'data:image/png;base64,AAA', + clipPath: 'inset(10% 20% 30% 40%)', + grayscale: true, + hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' }, + }; + + const el = renderDrawingContent({ + doc, + block, + clipContainer, + buildImageHyperlinkAnchor: (imageEl, hyperlink, display) => + buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display), + }); + + const anchor = el as HTMLAnchorElement; + const img = anchor.querySelector('img.superdoc-drawing-image') as HTMLImageElement | null; + expect(anchor.tagName).toBe('A'); + expect(anchor.href).toBe('https://example.com/image'); + expect(img?.style.filter).toContain('grayscale(100%)'); + expect(img?.style.clipPath).toBe('inset(10% 20% 30% 40%)'); + expect(clipContainer.style.overflow).toBe('hidden'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts new file mode 100644 index 0000000000..3f749069f1 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingContent.ts @@ -0,0 +1,623 @@ +import type { + ChartDrawing, + CustomGeometryData, + DrawingBlock, + DrawingGeometry, + GradientFill, + PositionedDrawingGeometry, + ShapeGroupChild, + ShapeGroupDrawing, + ShapeTextContent, + SolidFillWithAlpha, + VectorShapeDrawing, + VectorShapeStyle, +} from '@superdoc/contracts'; +import { getPresetShapeSvg } from '@superdoc/preset-geometry'; +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 '../fragment-context.js'; +import { hasShapeTextContent, renderTextboxContent } from '../textbox/renderTextboxContent.js'; +import { createDrawingPlaceholder } from './placeholder.js'; + +type LineEnd = { + type?: string; + width?: string; + length?: string; +}; + +type LineEnds = { + head?: LineEnd; + tail?: LineEnd; +}; + +type EffectExtent = { + left: number; + top: number; + right: number; + bottom: number; +}; + +type VectorShapeDrawingWithEffects = VectorShapeDrawing & { + lineEnds?: LineEnds; + effectExtent?: EffectExtent; +}; + +export type RenderDrawingContentParams = { + doc: Document; + block: DrawingBlock; + geometry?: DrawingGeometry; + context?: FragmentRenderContext; + clipContainer?: HTMLElement; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; +}; + +export const renderDrawingContent = ({ + doc, + block, + geometry, + context, + clipContainer, + buildImageHyperlinkAnchor, +}: RenderDrawingContentParams): HTMLElement => { + return renderDrawingBlock({ doc, buildImageHyperlinkAnchor }, block, geometry, context, clipContainer); +}; + +type DrawingRenderContext = { + doc: Document; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; +}; + +const renderDrawingBlock = ( + renderer: DrawingRenderContext, + block: DrawingBlock, + geometry?: DrawingGeometry, + context?: FragmentRenderContext, + clipContainer?: HTMLElement, +): HTMLElement => { + if (block.drawingKind === 'image') { + if (!block.src) { + return createDrawingPlaceholder(renderer.doc); + } + return createDrawingImageElement(renderer.doc, block, renderer.buildImageHyperlinkAnchor, clipContainer); + } + if (block.drawingKind === 'vectorShape') { + return createVectorShapeElement(renderer, block, geometry ?? block.geometry, false, context); + } + if (block.drawingKind === 'shapeGroup') { + return createShapeGroupElement(renderer, block, context); + } + if (block.drawingKind === 'chart') { + return createChartElement(renderer, block); + } + return createDrawingPlaceholder(renderer.doc); +}; + +const createVectorShapeElement = ( + renderer: DrawingRenderContext, + block: VectorShapeDrawingWithEffects, + geometry?: DrawingGeometry, + applyTransforms = false, + context?: FragmentRenderContext, +): HTMLElement => { + const container = renderer.doc.createElement('div'); + container.classList.add('superdoc-vector-shape'); + container.style.width = '100%'; + container.style.height = '100%'; + container.style.position = 'relative'; + container.style.overflow = 'hidden'; + + const { offsetX, offsetY, innerWidth, innerHeight } = getEffectExtentMetrics(block, geometry); + const contentContainer = renderer.doc.createElement('div'); + contentContainer.style.position = 'absolute'; + contentContainer.style.left = `${offsetX}px`; + contentContainer.style.top = `${offsetY}px`; + contentContainer.style.width = `${innerWidth}px`; + contentContainer.style.height = `${innerHeight}px`; + if (applyTransforms && geometry) { + applyVectorShapeTransforms(contentContainer, geometry); + } + + const customGeomSvg = block.customGeometry ? tryCreateCustomGeometrySvg(block, innerWidth, innerHeight) : null; + const svgMarkup = !customGeomSvg && block.shapeKind ? tryCreatePresetSvg(block, innerWidth, innerHeight) : null; + const resolvedSvgMarkup = customGeomSvg || svgMarkup; + + if (resolvedSvgMarkup) { + const svgElement = parseSafeSvg(renderer, resolvedSvgMarkup); + if (svgElement) { + svgElement.setAttribute('width', '100%'); + svgElement.setAttribute('height', '100%'); + svgElement.style.display = 'block'; + + if (block.fillColor && typeof block.fillColor === 'object') { + if ('type' in block.fillColor && block.fillColor.type === 'gradient') { + applyGradientToSVG(svgElement, block.fillColor as GradientFill); + } else if ('type' in block.fillColor && block.fillColor.type === 'solidWithAlpha') { + applyAlphaToSVG(svgElement, block.fillColor as SolidFillWithAlpha); + } + } + + applyLineEnds(renderer, svgElement, block); + contentContainer.appendChild(svgElement); + + if (hasShapeTextContent(block.textContent)) { + const textElement = createShapeTextElement(renderer, block, innerWidth, innerHeight, context); + contentContainer.appendChild(textElement); + } + + container.appendChild(contentContainer); + return container; + } + } + + applyFallbackShapeStyle(contentContainer, block); + + if (hasShapeTextContent(block.textContent)) { + const textElement = createShapeTextElement(renderer, block, innerWidth, innerHeight, context); + contentContainer.appendChild(textElement); + } + + container.appendChild(contentContainer); + return container; +}; + +const applyFallbackShapeStyle = (container: HTMLElement, block: VectorShapeDrawing): void => { + if (block.fillColor === null) { + container.style.background = 'none'; + } else if (typeof block.fillColor === 'string') { + container.style.background = block.fillColor; + } else if (typeof block.fillColor === 'object' && 'type' in block.fillColor) { + if (block.fillColor.type === 'solidWithAlpha') { + const alpha = (block.fillColor as SolidFillWithAlpha).alpha; + const color = (block.fillColor as SolidFillWithAlpha).color; + container.style.background = color; + container.style.opacity = alpha.toString(); + } else if (block.fillColor.type === 'gradient') { + container.style.background = 'rgba(15, 23, 42, 0.1)'; + } + } else { + container.style.background = 'rgba(15, 23, 42, 0.1)'; + } + + if (block.strokeColor === null) { + container.style.border = 'none'; + } else if (typeof block.strokeColor === 'string') { + const strokeWidth = block.strokeWidth ?? 1; + container.style.border = `${strokeWidth}px solid ${block.strokeColor}`; + } else { + container.style.border = '1px solid rgba(15, 23, 42, 0.3)'; + } +}; + +const createShapeTextElement = ( + renderer: DrawingRenderContext, + block: VectorShapeDrawing, + width: number, + height: number, + context?: FragmentRenderContext, +): Element => { + const textContent = block.textContent; + if (!hasShapeTextContent(textContent)) { + return renderer.doc.createElement('div'); + } + + return renderTextboxContent({ + doc: renderer.doc, + textContent, + textAlign: block.textAlign ?? 'center', + textVerticalAlign: block.textVerticalAlign, + textInsets: block.textInsets, + isWordArt: block.attrs?.isWordArt === true, + width, + height, + context, + }); +}; + +const tryCreatePresetSvg = ( + block: VectorShapeDrawing, + widthOverride?: number, + heightOverride?: number, +): string | null => { + try { + let fillColor: string | undefined; + if (block.fillColor === null) { + fillColor = 'none'; + } else if (typeof block.fillColor === 'string') { + fillColor = block.fillColor; + } + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : undefined; + + if (block.shapeKind === 'line' || block.shapeKind === 'straightConnector1') { + const width = widthOverride ?? block.geometry.width; + const height = heightOverride ?? block.geometry.height; + const stroke = strokeColor ?? '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + + return ` + +`; + } + + return getPresetShapeSvg({ + preset: block.shapeKind ?? '', + styleOverrides: () => ({ + fill: fillColor, + stroke: strokeColor, + strokeWidth: block.strokeWidth ?? undefined, + }), + width: widthOverride ?? block.geometry.width, + height: heightOverride ?? block.geometry.height, + }); + } catch (error) { + console.warn(`[DomPainter] Unable to render preset shape "${block.shapeKind}":`, error); + return null; + } +}; + +const tryCreateCustomGeometrySvg = (block: VectorShapeDrawing, width: number, height: number): string | null => { + const custGeom = block.customGeometry; + if (!custGeom?.paths?.length) return null; + + let fillColor: string; + if (block.fillColor === null) { + fillColor = 'none'; + } else if (typeof block.fillColor === 'string') { + fillColor = block.fillColor; + } else { + fillColor = '#000000'; + } + const strokeColor = + block.strokeColor === null ? 'none' : typeof block.strokeColor === 'string' ? block.strokeColor : 'none'; + const strokeWidth = block.strokeColor === null ? 0 : (block.strokeWidth ?? 0); + + const firstPath = custGeom.paths[0]; + const viewW = firstPath.w || width; + const viewH = firstPath.h || height; + + if (viewW === 0 || viewH === 0) return null; + + const needsEdgeStroke = fillColor !== 'none' && strokeColor === 'none'; + const edgeStroke = needsEdgeStroke + ? ` stroke="${fillColor}" stroke-width="0.5" vector-effect="non-scaling-stroke"` + : ''; + + const pathElements = custGeom.paths + .map((p) => { + const pathW = p.w || viewW; + const pathH = p.h || viewH; + const needsTransform = pathW !== viewW || pathH !== viewH; + const scaleX = viewW / pathW; + const scaleY = viewH / pathH; + const transform = needsTransform ? ` transform="scale(${scaleX}, ${scaleY})"` : ''; + const strokeAttr = strokeColor !== 'none' ? ` stroke="${strokeColor}" stroke-width="${strokeWidth}"` : edgeStroke; + return ``; + }) + .join('\n '); + + return ` + ${pathElements} +`; +}; + +const parseSafeSvg = (renderer: DrawingRenderContext, markup: string): SVGElement | null => { + const DOMParserCtor = renderer.doc.defaultView?.DOMParser ?? (typeof DOMParser !== 'undefined' ? DOMParser : null); + if (!DOMParserCtor) { + return null; + } + const parser = new DOMParserCtor(); + const parsed = parser.parseFromString(markup, 'image/svg+xml'); + if (!parsed || parsed.getElementsByTagName('parsererror').length > 0) { + return null; + } + const svgElement = parsed.documentElement as unknown as SVGElement | null; + if (!svgElement) return null; + stripUnsafeSvgContent(svgElement); + const imported = renderer.doc.importNode(svgElement, true); + return imported ? (imported as unknown as SVGElement) : null; +}; + +const stripUnsafeSvgContent = (element: Element): void => { + element.querySelectorAll('script').forEach((script) => script.remove()); + const sanitize = (node: Element) => { + Array.from(node.attributes).forEach((attr) => { + if (attr.name.toLowerCase().startsWith('on')) { + node.removeAttribute(attr.name); + } + }); + Array.from(node.children).forEach((child) => { + sanitize(child as Element); + }); + }; + sanitize(element); +}; + +const getEffectExtentMetrics = ( + block: VectorShapeDrawingWithEffects, + geometry?: DrawingGeometry, +): { + offsetX: number; + offsetY: number; + innerWidth: number; + innerHeight: number; +} => { + const left = block.effectExtent?.left ?? 0; + const top = block.effectExtent?.top ?? 0; + const right = block.effectExtent?.right ?? 0; + const bottom = block.effectExtent?.bottom ?? 0; + const sourceGeometry = geometry ?? block.geometry; + const width = sourceGeometry.width ?? 0; + const height = sourceGeometry.height ?? 0; + const innerWidth = Math.max(0, width - left - right); + const innerHeight = Math.max(0, height - top - bottom); + return { offsetX: left, offsetY: top, innerWidth, innerHeight }; +}; + +const applyLineEnds = ( + renderer: DrawingRenderContext, + svgElement: SVGElement, + block: VectorShapeDrawingWithEffects, +): void => { + const lineEnds = block.lineEnds; + if (!lineEnds) return; + if (block.strokeColor === null) return; + const strokeColor = typeof block.strokeColor === 'string' ? block.strokeColor : '#000000'; + const strokeWidth = block.strokeWidth ?? 1; + if (strokeWidth <= 0) return; + + const target = findLineEndTarget(svgElement); + if (!target) return; + + const defs = ensureSvgDefs(renderer, svgElement); + const baseId = sanitizeSvgId(`sd-line-${block.id}`); + + if (lineEnds.tail) { + const id = `${baseId}-tail`; + appendLineEndMarker(renderer, defs, id, lineEnds.tail, strokeColor, true, block.effectExtent ?? undefined); + target.setAttribute('marker-start', `url(#${id})`); + } + + if (lineEnds.head) { + const id = `${baseId}-head`; + appendLineEndMarker(renderer, defs, id, lineEnds.head, strokeColor, false, block.effectExtent ?? undefined); + target.setAttribute('marker-end', `url(#${id})`); + } +}; + +const findLineEndTarget = (svgElement: SVGElement): SVGElement | null => { + const line = svgElement.querySelector('line'); + if (line) return line as SVGElement; + const path = svgElement.querySelector('path'); + if (path) return path as SVGElement; + const polyline = svgElement.querySelector('polyline'); + return polyline as SVGElement | null; +}; + +const ensureSvgDefs = (renderer: DrawingRenderContext, svgElement: SVGElement): SVGDefsElement => { + const existing = svgElement.querySelector('defs'); + if (existing) return existing as SVGDefsElement; + const defs = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'defs'); + svgElement.insertBefore(defs, svgElement.firstChild); + return defs; +}; + +const appendLineEndMarker = ( + renderer: DrawingRenderContext, + defs: SVGDefsElement, + id: string, + lineEnd: LineEnd, + strokeColor: string, + isStart: boolean, + effectExtent?: EffectExtent, +): void => { + if (defs.querySelector(`#${id}`)) return; + + const marker = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'marker'); + marker.setAttribute('id', id); + marker.setAttribute('viewBox', '0 0 10 10'); + marker.setAttribute('orient', 'auto'); + + const sizeScale = (value?: string): number => { + if (value === 'sm') return 0.75; + if (value === 'lg') return 1.25; + return 1; + }; + const effectMax = effectExtent + ? Math.max(effectExtent.left ?? 0, effectExtent.right ?? 0, effectExtent.top ?? 0, effectExtent.bottom ?? 0) + : 0; + const useEffectExtent = Number.isFinite(effectMax) && effectMax > 0; + const markerWidth = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.length); + const markerHeight = useEffectExtent ? effectMax * 2 : 4 * sizeScale(lineEnd.width); + marker.setAttribute('markerUnits', useEffectExtent ? 'userSpaceOnUse' : 'strokeWidth'); + marker.setAttribute('markerWidth', markerWidth.toString()); + marker.setAttribute('markerHeight', markerHeight.toString()); + marker.setAttribute('refX', isStart ? '0' : '10'); + marker.setAttribute('refY', '5'); + + const shape = createLineEndShape(renderer, lineEnd.type ?? 'triangle', strokeColor, isStart); + marker.appendChild(shape); + defs.appendChild(marker); +}; + +const createLineEndShape = ( + renderer: DrawingRenderContext, + type: string, + strokeColor: string, + isStart: boolean, +): SVGElement => { + const normalized = type.toLowerCase(); + if (normalized === 'diamond') { + const path = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M 0 5 L 5 0 L 10 5 L 5 10 Z'); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; + } + if (normalized === 'oval') { + const circle = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', '5'); + circle.setAttribute('cy', '5'); + circle.setAttribute('r', '5'); + circle.setAttribute('fill', strokeColor); + circle.setAttribute('stroke', 'none'); + return circle; + } + + const path = renderer.doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + const d = isStart ? 'M 10 0 L 0 5 L 10 10 Z' : 'M 0 0 L 10 5 L 0 10 Z'; + path.setAttribute('d', d); + path.setAttribute('fill', strokeColor); + path.setAttribute('stroke', 'none'); + return path; +}; + +const sanitizeSvgId = (value: string): string => { + return value.replace(/[^a-zA-Z0-9_-]/g, ''); +}; + +const applyVectorShapeTransforms = (target: HTMLElement | SVGElement, geometry: DrawingGeometry): void => { + const transforms: string[] = []; + if (geometry.rotation) { + transforms.push(`rotate(${geometry.rotation}deg)`); + } + if (geometry.flipH) { + transforms.push('scaleX(-1)'); + } + if (geometry.flipV) { + transforms.push('scaleY(-1)'); + } + if (transforms.length > 0) { + target.style.transformOrigin = 'center'; + target.style.transform = transforms.join(' '); + } else { + target.style.removeProperty('transform'); + target.style.removeProperty('transform-origin'); + } +}; + +const createShapeGroupElement = ( + renderer: DrawingRenderContext, + block: ShapeGroupDrawing, + context?: FragmentRenderContext, +): HTMLElement => { + const groupEl = renderer.doc.createElement('div'); + groupEl.classList.add('superdoc-shape-group'); + groupEl.style.position = 'relative'; + groupEl.style.width = '100%'; + groupEl.style.height = '100%'; + + const groupTransform = block.groupTransform; + let contentContainer: HTMLElement = groupEl; + + const visibleWidth = groupTransform?.width ?? block.geometry.width ?? 0; + const visibleHeight = groupTransform?.height ?? block.geometry.height ?? 0; + + if (groupTransform) { + const inner = renderer.doc.createElement('div'); + inner.style.position = 'absolute'; + inner.style.left = '0'; + inner.style.top = '0'; + inner.style.width = `${Math.max(1, visibleWidth)}px`; + inner.style.height = `${Math.max(1, visibleHeight)}px`; + groupEl.appendChild(inner); + contentContainer = inner; + } + + block.shapes.forEach((child) => { + const childContent = createGroupChildContent(renderer, child, context); + if (!childContent) return; + const attrs = (child as ShapeGroupChild).attrs ?? {}; + const wrapper = renderer.doc.createElement('div'); + wrapper.classList.add('superdoc-shape-group__child'); + wrapper.style.position = 'absolute'; + + wrapper.style.left = `${Number(attrs.x ?? 0)}px`; + wrapper.style.top = `${Number(attrs.y ?? 0)}px`; + + const childW = typeof attrs.width === 'number' ? attrs.width : block.geometry.width; + const childH = typeof attrs.height === 'number' ? attrs.height : block.geometry.height; + wrapper.style.width = `${Math.max(1, childW)}px`; + wrapper.style.height = `${Math.max(1, childH)}px`; + + wrapper.style.transformOrigin = 'center'; + const transforms: string[] = []; + if (attrs.rotation) { + transforms.push(`rotate(${attrs.rotation}deg)`); + } + if (attrs.flipH) { + transforms.push('scaleX(-1)'); + } + if (attrs.flipV) { + transforms.push('scaleY(-1)'); + } + if (transforms.length > 0) { + wrapper.style.transform = transforms.join(' '); + } + childContent.style.width = '100%'; + childContent.style.height = '100%'; + wrapper.appendChild(childContent); + contentContainer.appendChild(wrapper); + }); + + return groupEl; +}; + +const createGroupChildContent = ( + renderer: DrawingRenderContext, + child: ShapeGroupChild, + context?: FragmentRenderContext, +): HTMLElement | null => { + if (child.shapeType === 'vectorShape' && 'fillColor' in child.attrs) { + const attrs = child.attrs as PositionedDrawingGeometry & + VectorShapeStyle & { + kind?: string; + customGeometry?: CustomGeometryData; + shapeId?: string; + shapeName?: string; + textContent?: ShapeTextContent; + textAlign?: string; + lineEnds?: LineEnds; + }; + const childGeometry = { + width: attrs.width ?? 0, + height: attrs.height ?? 0, + rotation: attrs.rotation ?? 0, + flipH: attrs.flipH ?? false, + flipV: attrs.flipV ?? false, + }; + const vectorChild: VectorShapeDrawingWithEffects = { + drawingKind: 'vectorShape', + kind: 'drawing', + id: `${attrs.shapeId ?? child.shapeType}`, + geometry: childGeometry, + padding: undefined, + margin: undefined, + anchor: undefined, + wrap: undefined, + attrs: child.attrs, + drawingContentId: undefined, + drawingContent: undefined, + shapeKind: attrs.kind, + customGeometry: attrs.customGeometry, + fillColor: attrs.fillColor, + strokeColor: attrs.strokeColor, + strokeWidth: attrs.strokeWidth, + lineEnds: attrs.lineEnds, + textContent: attrs.textContent, + textAlign: attrs.textAlign, + textVerticalAlign: attrs.textVerticalAlign, + textInsets: attrs.textInsets, + }; + return createVectorShapeElement(renderer, vectorChild, childGeometry, false, context); + } + if (child.shapeType === 'image' && 'src' in child.attrs) { + return createShapeGroupImageElement(renderer.doc, child); + } + return createDrawingPlaceholder(renderer.doc); +}; + +const createChartElement = (renderer: DrawingRenderContext, block: ChartDrawing): HTMLElement => { + return renderChartToElement(renderer.doc, block.chartData, block.geometry); +}; diff --git a/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts new file mode 100644 index 0000000000..fe86322e6e --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/renderDrawingFragment.ts @@ -0,0 +1,92 @@ +import type { DrawingBlock, DrawingFragment, ResolvedDrawingItem } from '@superdoc/contracts'; +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'; +import { renderDrawingContent } from './renderDrawingContent.js'; + +type RenderDrawingFragmentOptions = { + doc: Document | null; + fragment: DrawingFragment; + context: FragmentRenderContext; + resolvedItem?: ResolvedDrawingItem; + applyResolvedFragmentFrame: ( + el: HTMLElement, + item: ResolvedDrawingItem, + fragment: DrawingFragment, + section?: 'body' | 'header' | 'footer', + ) => void; + applyFragmentFrame: (el: HTMLElement, fragment: DrawingFragment, section?: 'body' | 'header' | 'footer') => void; + applyFragmentWrapperZIndex: (el: HTMLElement, fragment: DrawingFragment) => void; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +export const renderDrawingFragment = ({ + doc, + fragment, + context, + resolvedItem, + applyResolvedFragmentFrame, + applyFragmentFrame, + applyFragmentWrapperZIndex, + buildImageHyperlinkAnchor, + createErrorPlaceholder, +}: RenderDrawingFragmentOptions): HTMLElement => { + try { + if (resolvedItem?.block?.kind !== 'drawing') { + throw new Error(`DomPainter: missing resolved drawing block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as DrawingBlock; + + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment, 'superdoc-drawing-fragment'); + applyStyles(fragmentEl, fragmentStyles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + } else { + applyFragmentFrame(fragmentEl, fragment, context.section); + fragmentEl.style.height = `${fragment.height}px`; + applyFragmentWrapperZIndex(fragmentEl, fragment); + } + fragmentEl.style.position = 'absolute'; + fragmentEl.style.overflow = 'hidden'; + + const innerWrapper = doc.createElement('div'); + innerWrapper.classList.add('superdoc-drawing-inner'); + innerWrapper.style.position = 'absolute'; + innerWrapper.style.left = '50%'; + innerWrapper.style.top = '50%'; + innerWrapper.style.width = `${fragment.geometry.width}px`; + innerWrapper.style.height = `${fragment.geometry.height}px`; + innerWrapper.style.transformOrigin = 'center'; + + const scale = fragment.scale ?? 1; + const transforms: string[] = ['translate(-50%, -50%)']; + transforms.push(`rotate(${fragment.geometry.rotation ?? 0}deg)`); + transforms.push(`scaleX(${fragment.geometry.flipH ? -1 : 1})`); + transforms.push(`scaleY(${fragment.geometry.flipV ? -1 : 1})`); + transforms.push(`scale(${scale})`); + innerWrapper.style.transform = transforms.join(' '); + + innerWrapper.appendChild( + renderDrawingContent({ + doc, + block, + geometry: fragment.geometry, + context, + buildImageHyperlinkAnchor, + }), + ); + fragmentEl.appendChild(innerWrapper); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Drawing fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; diff --git a/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts new file mode 100644 index 0000000000..a512dbd781 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/drawings/tableDrawingFrame.ts @@ -0,0 +1,43 @@ +import type { DrawingBlock } from '@superdoc/contracts'; +import { renderDrawingFrame, type RenderDrawingContentForPlacement } from './drawingFrame.js'; + +export type RenderTableDrawingFrameParams = { + doc: Document; + block: DrawingBlock; + width: number; + height: number; + position: 'relative' | 'absolute'; + left?: number; + top?: number; + zIndex?: number; + flexShrink?: string; + renderDrawingContent?: RenderDrawingContentForPlacement; +}; + +export const renderTableDrawingFrame = ({ + doc, + block, + width, + height, + position, + left, + top, + zIndex, + flexShrink, + renderDrawingContent, +}: RenderTableDrawingFrameParams): HTMLElement => { + 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/features/feature-registry.ts b/packages/layout-engine/painters/dom/src/features/feature-registry.ts index 831c2e3eaf..f5fe64343f 100644 --- a/packages/layout-engine/painters/dom/src/features/feature-registry.ts +++ b/packages/layout-engine/painters/dom/src/features/feature-registry.ts @@ -17,8 +17,8 @@ export const RENDERING_FEATURES = { // ─── Paragraph Borders ─────────────────────────────────────────── // @spec ECMA-376 §17.3.1.24 (pBdr) 'w:pBdr': { - feature: 'paragraph-borders', - module: './paragraph-borders', + feature: 'paragraph/borders', + module: '../paragraph/borders', handles: ['w:pBdr/w:top', 'w:pBdr/w:bottom', 'w:pBdr/w:left', 'w:pBdr/w:right', 'w:pBdr/w:between', 'w:pBdr/w:bar'], spec: '§17.3.1.24', }, @@ -26,8 +26,8 @@ export const RENDERING_FEATURES = { // ─── Paragraph Shading ─────────────────────────────────────────── // @spec ECMA-376 §17.3.1.31 (shd) 'w:shd': { - feature: 'paragraph-borders', // shading shares the border layer module - module: './paragraph-borders', + feature: 'paragraph/borders', // shading shares the border layer module + module: '../paragraph/borders', handles: ['w:shd/@w:fill', 'w:shd/@w:val', 'w:shd/@w:color'], spec: '§17.3.1.31', }, 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/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts new file mode 100644 index 0000000000..a05187662a --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -0,0 +1,53 @@ +import type { + DrawingBlock, + ImageDrawing, + PositionedDrawingGeometry, + ShapeGroupChild, + TextPart, +} from '@superdoc/contracts'; +import { applyImageClipPath } from './image-clip-path.js'; +import { createBlockImageContent } from './image-block.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +export const createDrawingImageElement = ( + doc: Document, + block: DrawingBlock, + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor, + clipContainer?: HTMLElement, +): HTMLElement => { + const drawing = block as ImageDrawing; + return createBlockImageContent({ + doc, + block: drawing, + className: 'superdoc-drawing-image', + clipContainer, + imageDisplay: 'block', + buildImageHyperlinkAnchor, + }); +}; + +export const createShapeGroupImageElement = (doc: Document, child: ShapeGroupChild): HTMLElement => { + const attrs = child.attrs as PositionedDrawingGeometry & { + src: string; + alt?: string; + clipPath?: string; + }; + const img = doc.createElement('img'); + img.src = attrs.src; + img.alt = attrs.alt ?? ''; + img.style.objectFit = 'contain'; + img.style.display = 'block'; + applyImageClipPath(img, attrs.clipPath); + return img; +}; + +export const createShapeTextImageElement = (doc: Document, part: TextPart): HTMLElement => { + const img = doc.createElement('img'); + img.src = part.src!; + img.alt = part.alt ?? ''; + if (typeof part.width === 'number') img.style.width = `${part.width}px`; + if (typeof part.height === 'number') img.style.height = `${part.height}px`; + img.style.display = 'inline-block'; + img.style.verticalAlign = 'bottom'; + return img; +}; diff --git a/packages/layout-engine/painters/dom/src/images/hyperlink.ts b/packages/layout-engine/painters/dom/src/images/hyperlink.ts new file mode 100644 index 0000000000..2ebb26c4da --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/hyperlink.ts @@ -0,0 +1,49 @@ +import type { ImageHyperlink } from '@superdoc/contracts'; +import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; + +export const buildImageHyperlinkAnchor = ( + doc: Document, + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +): HTMLElement => { + if (!hyperlink?.url) return imageEl; + + const sanitized = sanitizeHref(hyperlink.url); + if (!sanitized?.href) return imageEl; + + const anchor = doc.createElement('a'); + anchor.href = sanitized.href; + anchor.classList.add('superdoc-link'); + + if (sanitized.protocol === 'http' || sanitized.protocol === 'https') { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + + const tooltipSource = + typeof hyperlink.tooltip === 'string' && hyperlink.tooltip.trim().length > 0 ? hyperlink.tooltip : hyperlink.url; + const tooltipResult = encodeTooltip(tooltipSource); + if (tooltipResult?.text) { + anchor.title = tooltipResult.text; + } + + for (const titledElement of [imageEl, ...Array.from(imageEl.querySelectorAll('[title]'))]) { + titledElement.removeAttribute('title'); + } + + anchor.setAttribute('role', 'link'); + anchor.setAttribute('tabindex', '0'); + + if (display === 'block') { + anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;'; + } else { + anchor.style.display = 'inline-block'; + anchor.style.lineHeight = '0'; + anchor.style.cursor = 'pointer'; + anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom'; + } + + anchor.appendChild(imageEl); + return anchor; +}; diff --git a/packages/layout-engine/painters/dom/src/images/image-block.test.ts b/packages/layout-engine/painters/dom/src/images/image-block.test.ts new file mode 100644 index 0000000000..30f63e7da7 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-block.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import type { DrawingBlock } from '@superdoc/contracts'; +import { createDrawingImageElement } from './drawing-image.js'; +import { buildImageHyperlinkAnchor } from './hyperlink.js'; +import { resolveBlockImageClipPath } from './image-block.js'; + +describe('resolveBlockImageClipPath', () => { + it('prefers a top-level clipPath over attrs.clipPath', () => { + expect( + resolveBlockImageClipPath({ + clipPath: 'inset(1% 2% 3% 4%)', + attrs: { clipPath: 'inset(5% 6% 7% 8%)' }, + }), + ).toBe('inset(1% 2% 3% 4%)'); + }); + + it('falls back to attrs.clipPath when top-level clipPath is absent', () => { + expect(resolveBlockImageClipPath({ attrs: { clipPath: 'inset(5% 6% 7% 8%)' } })).toBe('inset(5% 6% 7% 8%)'); + }); + + it('ignores unsupported clip-path values', () => { + expect(resolveBlockImageClipPath({ clipPath: 'url(#clip)' })).toBe(''); + }); +}); + +describe('createDrawingImageElement', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('drawing-image'); + + it('applies unified image filters to drawing images', () => { + const doc = createDoc(); + const drawing = { + kind: 'drawing', + drawingKind: 'image', + id: 'drawing-image-filtered', + src: 'data:image/png;base64,AAA', + grayscale: true, + gain: 2, + } as DrawingBlock; + + const imgEl = createDrawingImageElement(doc, drawing, (imageEl) => imageEl) as HTMLImageElement; + + expect(imgEl.style.display).toBe('block'); + expect(imgEl.style.filter).toContain('grayscale(100%)'); + expect(imgEl.style.filter).toContain('contrast(2)'); + }); + + it('wraps drawing images with unified hyperlink anchors', () => { + const doc = createDoc(); + const drawing = { + kind: 'drawing', + drawingKind: 'image', + id: 'drawing-image-linked', + src: 'data:image/png;base64,AAA', + hyperlink: { url: 'https://example.com/drawing-image', tooltip: 'Open drawing image' }, + } as DrawingBlock; + + const anchor = createDrawingImageElement(doc, drawing, (imageEl, hyperlink, display) => + buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display), + ) as HTMLAnchorElement; + + expect(anchor.tagName).toBe('A'); + expect(anchor.classList.contains('superdoc-link')).toBe(true); + expect(anchor.href).toBe('https://example.com/drawing-image'); + expect(anchor.style.display).toBe('block'); + expect(anchor.querySelector('img.superdoc-drawing-image')).toBeTruthy(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts new file mode 100644 index 0000000000..0541294831 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -0,0 +1,62 @@ +import type { ImageBlock, ImageDrawing } from '@superdoc/contracts'; +import { buildImageFilters } from '../runs/image-run.js'; +import { applyImageClipPath, readImageClipPathValue } from './image-clip-path.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +type BlockImageSource = ImageBlock | ImageDrawing; + +export type CreateBlockImageContentOptions = { + doc: Document; + block: BlockImageSource; + className?: string; + clipContainer?: HTMLElement; + imageDisplay?: 'block' | 'inline-block'; + hyperlinkDisplay?: 'block' | 'inline-block'; + buildImageHyperlinkAnchor?: BuildImageHyperlinkAnchor; +}; + +const resolveClipPathFromAttrs = (attrs: unknown): string => { + if (!attrs || typeof attrs !== 'object') return ''; + const record = attrs as Record; + return readImageClipPathValue(record.clipPath); +}; + +export const resolveBlockImageClipPath = (block: unknown): string => { + if (!block || typeof block !== 'object') return ''; + const record = block as Record; + return readImageClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); +}; + +export const createBlockImageContent = ({ + doc, + block, + className, + clipContainer, + imageDisplay, + hyperlinkDisplay = 'block', + buildImageHyperlinkAnchor, +}: CreateBlockImageContentOptions): HTMLElement => { + const img = doc.createElement('img'); + if (className) { + img.classList.add(className); + } + if (block.src) { + img.src = block.src; + } + img.alt = block.alt ?? ''; + img.style.width = '100%'; + img.style.height = '100%'; + img.style.objectFit = block.objectFit ?? 'contain'; + if (block.objectFit === 'cover') { + img.style.objectPosition = 'left top'; + } + applyImageClipPath(img, resolveBlockImageClipPath(block), clipContainer ? { clipContainer } : undefined); + img.style.display = imageDisplay ?? (block.display === 'inline' ? 'inline-block' : 'block'); + + const filters = buildImageFilters(block); + if (filters.length > 0) { + img.style.filter = filters.join(' '); + } + + return buildImageHyperlinkAnchor?.(img, block.hyperlink, hyperlinkDisplay) ?? img; +}; diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts b/packages/layout-engine/painters/dom/src/images/image-clip-path.test.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts rename to packages/layout-engine/painters/dom/src/images/image-clip-path.test.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts b/packages/layout-engine/painters/dom/src/images/image-clip-path.ts similarity index 75% rename from packages/layout-engine/painters/dom/src/utils/image-clip-path.ts rename to packages/layout-engine/painters/dom/src/images/image-clip-path.ts index f8468cfcc3..59949205ee 100644 --- a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts +++ b/packages/layout-engine/painters/dom/src/images/image-clip-path.ts @@ -1,5 +1,16 @@ import { parseInsetClipPathForScale } from '@superdoc/contracts'; +const SUPPORTED_IMAGE_CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +export const readImageClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!SUPPORTED_IMAGE_CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + /** * Resolves a clip-path value to a trimmed non-empty string, or undefined if invalid. */ diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts new file mode 100644 index 0000000000..f32134aa3b --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -0,0 +1,147 @@ +import type { ImageBlock, ImageFragment, ResolvedImageItem } from '@superdoc/contracts'; +import { DOM_CLASS_NAMES } from '../constants.js'; +import type { FragmentRenderContext } from '../fragment-context.js'; +import { applyContainerSdtDataset, applySdtDataset } from '../sdt/dataset.js'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { applyStyles } from '../utils/apply-styles.js'; +import { createBlockImageContent } from './image-block.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +type RenderImageFragmentOptions = { + doc: Document | null; + fragment: ImageFragment; + context: FragmentRenderContext; + resolvedItem?: ResolvedImageItem; + applyResolvedFragmentFrame: ( + el: HTMLElement, + item: ResolvedImageItem, + fragment: ImageFragment, + section?: 'body' | 'header' | 'footer', + ) => void; + applyFragmentFrame: (el: HTMLElement, fragment: ImageFragment, section?: 'body' | 'header' | 'footer') => void; + applyFragmentWrapperZIndex: (el: HTMLElement, fragment: ImageFragment) => void; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +export const buildImageGeometryTransform = (attrs: { + width: number; + height: number; + rotation?: number; + flipH?: boolean; + flipV?: boolean; +}): string => { + const transforms: string[] = []; + if (attrs.rotation != null && attrs.rotation !== 0) { + const angleRad = (attrs.rotation * Math.PI) / 180; + const cosA = Math.cos(angleRad); + const sinA = Math.sin(angleRad); + const newTopLeftX = (attrs.width / 2) * (1 - cosA) + (attrs.height / 2) * sinA; + const newTopLeftY = (attrs.width / 2) * sinA + (attrs.height / 2) * (1 - cosA); + transforms.push(`translate(${-newTopLeftX}px, ${-newTopLeftY}px)`); + transforms.push(`rotate(${attrs.rotation}deg)`); + } + if (attrs.flipH) { + transforms.push('scaleX(-1)'); + } + if (attrs.flipV) { + transforms.push('scaleY(-1)'); + } + return transforms.join(' '); +}; + +export const applyImageGeometryTransform = ( + target: HTMLElement, + attrs: { + width: number; + height: number; + rotation?: number; + flipH?: boolean; + flipV?: boolean; + }, +): void => { + const transform = buildImageGeometryTransform(attrs); + if (!transform) { + return; + } + target.style.transform = transform; + target.style.transformOrigin = 'center'; +}; + +export const renderImageFragment = ({ + doc, + fragment, + context, + resolvedItem, + applyResolvedFragmentFrame, + applyFragmentFrame, + applyFragmentWrapperZIndex, + buildImageHyperlinkAnchor, + createErrorPlaceholder, +}: RenderImageFragmentOptions): HTMLElement => { + try { + if (resolvedItem?.block?.kind !== 'image') { + throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ImageBlock; + + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment, DOM_CLASS_NAMES.IMAGE_FRAGMENT); + applyStyles(fragmentEl, fragmentStyles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + } else { + applyFragmentFrame(fragmentEl, fragment, context.section); + fragmentEl.style.height = `${fragment.height}px`; + applyFragmentWrapperZIndex(fragmentEl, fragment); + } + applySdtDataset(fragmentEl, block.attrs?.sdt); + applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); + + if (block.id) { + fragmentEl.setAttribute('data-sd-block-id', block.id); + } + + const imgPmStart = resolvedItem?.pmStart; + if (imgPmStart != null) { + fragmentEl.dataset.pmStart = String(imgPmStart); + } + const imgPmEnd = resolvedItem?.pmEnd; + if (imgPmEnd != null) { + fragmentEl.dataset.pmEnd = String(imgPmEnd); + } + + const imgMetadata = resolvedItem?.metadata; + if (imgMetadata && !block.attrs?.vmlWatermark) { + fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); + } + + // AIDEV-NOTE: Keep srcRect crop/zoom transforms on the image element via + // applyImageClipPath, and geometry transforms on the fragment wrapper. + // Putting both on the same element overwrites clip-path scaling. + applyImageGeometryTransform(fragmentEl, { + width: block.width ?? fragment.width, + height: block.height ?? fragment.height, + rotation: block.rotation, + flipH: block.flipH, + flipV: block.flipV, + }); + + const imageChild = createBlockImageContent({ + doc, + block, + clipContainer: fragmentEl, + buildImageHyperlinkAnchor, + }); + fragmentEl.appendChild(imageChild); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Image fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; diff --git a/packages/layout-engine/painters/dom/src/utils/image-selectors.test.ts b/packages/layout-engine/painters/dom/src/images/image-selectors.test.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-selectors.test.ts rename to packages/layout-engine/painters/dom/src/images/image-selectors.test.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-selectors.ts b/packages/layout-engine/painters/dom/src/images/image-selectors.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-selectors.ts rename to packages/layout-engine/painters/dom/src/images/image-selectors.ts diff --git a/packages/layout-engine/painters/dom/src/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/images/types.ts b/packages/layout-engine/painters/dom/src/images/types.ts new file mode 100644 index 0000000000..87ecda51b4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/types.ts @@ -0,0 +1,7 @@ +import type { ImageHyperlink } from '@superdoc/contracts'; + +export type BuildImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +) => HTMLElement; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index dfb4f2470a..fffa7bb1b2 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; -import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; +import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes, isNonBodyStoryBlockId } from './index.js'; import { DomPainter } from './renderer.js'; import { resolveLayout } from '@superdoc/layout-resolved'; import type { DomPainterOptions, DomPainterInput, PaintSnapshot } from './index.js'; @@ -20,6 +20,12 @@ import type { const emptyResolved: ResolvedLayout = { version: 1, flowMode: 'paginated', pageGap: 0, pages: [] }; +describe('painter-dom exports', () => { + it('exports note story block detection for editor DOM indexing', () => { + expect(isNonBodyStoryBlockId('endnote-1-abc')).toBe(true); + }); +}); + /** * Test-only bridge: accepts old-style `{ blocks, measures, ...options }` and * returns a painter whose `paint()` automatically builds a `DomPainterInput`. @@ -249,24 +255,6 @@ const withFallbackFragment = ( const fromLine = 'fromLine' in item && typeof item.fromLine === 'number' ? item.fromLine : 0; const toLine = 'toLine' in item && typeof item.toLine === 'number' ? item.toLine : fromLine + 1; - if (item.fragmentKind === 'list-item') { - return { - ...item, - fragment: { - kind: 'list-item', - blockId: item.blockId, - itemId: item.itemId, - markerText: item.markerText ?? '', - markerWidth: item.markerWidth ?? 0, - fromLine, - toLine, - x: item.x, - y: item.y, - width: item.width, - }, - }; - } - return { ...item, fragment: { @@ -5532,224 +5520,6 @@ describe('DomPainter', () => { }); }); - it('renders list fragments with markers', () => { - const listBlock: FlowBlock = { - kind: 'list', - id: 'list-1', - listType: 'number', - items: [ - { - id: 'item-1', - marker: { kind: 'number', text: '1.', level: 0, order: 1 }, - paragraph: block, - }, - ], - }; - - const listMeasure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-1', - markerWidth: 30, - markerTextWidth: 18, - indentLeft: 0, - paragraph: measure as ParagraphMeasure, - }, - ], - totalHeight: measure.totalHeight, - }; - - const listLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-1', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 100, - y: 40, - width: 260, - markerWidth: 30, - }, - ], - }, - ], - }; - - const painter = createTestPainter({ blocks: [listBlock], measures: [listMeasure] }); - painter.paint(listLayout, mount); - - const marker = mount.querySelector('.superdoc-list-marker'); - expect(marker?.textContent).toBe('1.'); - }); - - it('preserves marker-adjusted list-item wrapper geometry during resolved incremental updates', () => { - const listBlock: FlowBlock = { - kind: 'list', - id: 'list-1', - listType: 'number', - items: [ - { - id: 'item-1', - marker: { kind: 'number', text: '1.', level: 0, order: 1 }, - paragraph: block, - }, - ], - }; - - const listMeasure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-1', - markerWidth: 30, - markerTextWidth: 18, - indentLeft: 0, - paragraph: measure as ParagraphMeasure, - }, - ], - totalHeight: measure.totalHeight, - }; - - const initialLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-1', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 100, - y: 40, - width: 260, - markerWidth: 30, - }, - ], - }, - ], - }; - - const updatedLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-1', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 120, - y: 55, - width: 280, - markerWidth: 30, - }, - ], - }, - ], - }; - - const initialResolvedLayout: ResolvedLayout = { - version: 1, - flowMode: 'paginated', - pageGap: 0, - pages: [ - { - id: 'page-0', - index: 0, - number: 1, - width: 400, - height: 500, - items: [ - { - kind: 'fragment', - id: 'list-item:list-1:item-1:0:1', - pageIndex: 0, - x: 100, - y: 40, - width: 260, - height: 20, - fragmentKind: 'list-item', - fragment: initialLayout.pages[0].fragments[0], - blockId: 'list-1', - fragmentIndex: 0, - markerWidth: 30, - block: listBlock as import('@superdoc/contracts').ListBlock, - measure: listMeasure as import('@superdoc/contracts').ListMeasure, - }, - ], - }, - ], - }; - - const updatedResolvedLayout: ResolvedLayout = { - version: 1, - flowMode: 'paginated', - pageGap: 0, - pages: [ - { - id: 'page-0', - index: 0, - number: 1, - width: 400, - height: 500, - items: [ - { - kind: 'fragment', - id: 'list-item:list-1:item-1:0:1', - pageIndex: 0, - x: 120, - y: 55, - width: 280, - height: 20, - fragmentKind: 'list-item', - fragment: updatedLayout.pages[0].fragments[0], - blockId: 'list-1', - fragmentIndex: 0, - markerWidth: 30, - block: listBlock as import('@superdoc/contracts').ListBlock, - measure: listMeasure as import('@superdoc/contracts').ListMeasure, - }, - ], - }, - ], - }; - - const painter = createTestPainter({ blocks: [listBlock], measures: [listMeasure] }); - - painter.setResolvedLayout(initialResolvedLayout); - painter.paint(initialLayout, mount); - - const initialWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement; - expect(initialWrapper.style.left).toBe('70px'); - expect(initialWrapper.style.top).toBe('40px'); - expect(initialWrapper.style.width).toBe('290px'); - - painter.setResolvedLayout(updatedResolvedLayout); - painter.paint(updatedLayout, mount); - - const updatedWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement; - const updatedLine = updatedWrapper.querySelector('.superdoc-line') as HTMLElement; - expect(updatedWrapper).not.toBe(initialWrapper); - expect(updatedWrapper.style.left).toBe('90px'); - expect(updatedWrapper.style.top).toBe('55px'); - expect(updatedWrapper.style.width).toBe('310px'); - expect(updatedWrapper.dataset.layoutEpoch).toBeTruthy(); - expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch); - }); - it('applies resolved zIndex only to anchored media fragments', () => { const anchoredDrawingBlock: FlowBlock = { kind: 'drawing', @@ -6823,85 +6593,6 @@ describe('DomPainter', () => { expectCssColor(shadingLayer.style.backgroundColor, '#ffeeaa'); }); - it('strips indent padding when rendering list content', () => { - const listBlock: FlowBlock = { - kind: 'list', - id: 'list-indent', - listType: 'number', - items: [ - { - id: 'item-1', - marker: { kind: 'number', text: '1.', level: 1, order: 1 }, - paragraph: { - kind: 'paragraph', - id: 'paragraph-list', - runs: [{ text: 'Indented body', fontFamily: 'Arial', fontSize: 16 }], - attrs: { indent: { left: 36, hanging: 18 } }, - }, - }, - ], - }; - - const paragraphMeasure: ParagraphMeasure = { - kind: 'paragraph', - lines: [ - { - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 13, - width: 140, - ascent: 12, - descent: 4, - lineHeight: 18, - }, - ], - totalHeight: 18, - }; - - const listMeasure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-1', - markerWidth: 30, - markerTextWidth: 14, - indentLeft: 36, - paragraph: paragraphMeasure, - }, - ], - totalHeight: 18, - }; - - const listLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-indent', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 80, - y: 40, - width: 180, - markerWidth: 30, - }, - ], - }, - ], - }; - - const painter = createTestPainter({ blocks: [listBlock], measures: [listMeasure] }); - painter.paint(listLayout, mount); - - const content = mount.querySelector('.superdoc-list-content') as HTMLElement; - expect(content.style.paddingLeft).toBe(''); - }); - describe('line-level paragraph indent handling', () => { it('applies paragraph left/right indent to each line element', () => { const indentBlock: FlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 5d91414acb..28d4575e92 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -40,13 +40,15 @@ export type { PaintSnapshotImageEntity, PaintSnapshotEntities, } from './renderer.js'; -export type { DomPainterInput, PositionMapping, RenderedLineInfo } from './renderer.js'; +export type { DomPainterInput, PositionMapping } from './renderer.js'; +export type { RenderedLineInfo } from './runs/index.js'; // Re-export utility functions for testing -export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './renderer.js'; +export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './runs/index.js'; export { applySquareWrapExclusionsToLines } from './utils/anchor-helpers'; -export { buildImagePmSelector, buildInlineImagePmSelector } from './utils/image-selectors.js'; +export { buildImagePmSelector, buildInlineImagePmSelector } from './images/image-selectors.js'; +export { isNonBodyStoryBlockId } from './notes/story.js'; // Re-export PM position validation utilities export { diff --git a/packages/layout-engine/painters/dom/src/notes/frame.test.ts b/packages/layout-engine/painters/dom/src/notes/frame.test.ts new file mode 100644 index 0000000000..96b0705cd9 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/frame.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { applyNoteStoryFrameAttributes } from './frame.js'; + +describe('applyNoteStoryFrameAttributes', () => { + it.each([ + ['footnote-1-abc', 'false'], + ['endnote-1-abc', null], + ['__sd_semantic_footnote-1-abc', null], + ['__sd_semantic_endnote-1-abc', null], + ] as const)('sets frame contenteditable for %s to %s', (blockId, expected) => { + const el = document.createElement('div'); + + applyNoteStoryFrameAttributes(el, blockId); + + expect(el.getAttribute('contenteditable')).toBe(expected); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/notes/frame.ts b/packages/layout-engine/painters/dom/src/notes/frame.ts new file mode 100644 index 0000000000..2dbc5cd8b9 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/frame.ts @@ -0,0 +1,7 @@ +import { shouldApplyPlainFootnotePainterReadOnly } from './story.js'; + +export const applyNoteStoryFrameAttributes = (el: HTMLElement, blockId: string | undefined): void => { + if (shouldApplyPlainFootnotePainterReadOnly(blockId)) { + el.setAttribute('contenteditable', 'false'); + } +}; diff --git a/packages/layout-engine/painters/dom/src/notes/story.test.ts b/packages/layout-engine/painters/dom/src/notes/story.test.ts new file mode 100644 index 0000000000..785cef9cb5 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/story.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { getNoteStoryKind, isNonBodyStoryBlockId, shouldApplyPlainFootnotePainterReadOnly } from './story.js'; + +describe('note story block ids', () => { + it.each([ + ['footnote-1-abc', 'footnote'], + ['endnote-1-abc', 'endnote'], + ['__sd_semantic_footnote-1-abc', 'semantic-footnote'], + ['__sd_semantic_endnote-1-abc', 'semantic-endnote'], + ] as const)('detects %s as a non-body %s story block', (blockId, kind) => { + expect(getNoteStoryKind(blockId)).toBe(kind); + expect(isNonBodyStoryBlockId(blockId)).toBe(true); + }); + + it.each(['body-paragraph-1', 'footnotes-heading', '__sd_semantic_footnotes_heading', undefined])( + 'does not treat %s as a note body fragment', + (blockId) => { + expect(getNoteStoryKind(blockId)).toBeUndefined(); + expect(isNonBodyStoryBlockId(blockId)).toBe(false); + }, + ); + + it.each([ + ['footnote-1-abc', true], + ['endnote-1-abc', false], + ['__sd_semantic_footnote-1-abc', false], + ['__sd_semantic_endnote-1-abc', false], + ] as const)('applies painter read-only for %s: %s', (blockId, expected) => { + expect(shouldApplyPlainFootnotePainterReadOnly(blockId)).toBe(expected); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/notes/story.ts b/packages/layout-engine/painters/dom/src/notes/story.ts new file mode 100644 index 0000000000..d7ee3c6337 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/notes/story.ts @@ -0,0 +1,10 @@ +import { getNoteStoryKind } from '@superdoc/dom-contract'; + +export { getNoteStoryKind, isNonBodyStoryBlockId } from '@superdoc/dom-contract'; +export type { NoteStoryKind } from '@superdoc/dom-contract'; + +// AIDEV-NOTE: FootnotesBuilder emits `footnote-{id}-` blocks into the body painter. +// Endnote and semantic note blocks have dedicated editing sessions, so only plain +// footnote story frames are locked at the painter layer. +export const shouldApplyPlainFootnotePainterReadOnly = (blockId: string | undefined): boolean => + getNoteStoryKind(blockId) === 'footnote'; diff --git a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts index 55870ed7e9..57883ea6da 100644 --- a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts +++ b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts @@ -1,5 +1,4 @@ import type { - Run, ParagraphBorders, ParagraphBorder, TableBorders, @@ -82,108 +81,13 @@ export const hashCellBorders = (borders: CellBorders | undefined): string => { return parts.join(';'); }; -/** - * Type guard to check if a run has a string property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a string - */ -export const hasStringProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'string'; -}; - -/** - * Type guard to check if a run has a number property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a number - */ -export const hasNumberProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'number'; -}; - -/** - * Type guard to check if a run has a boolean property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a boolean - */ -export const hasBooleanProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'boolean'; -}; - -/** - * Safely gets a string property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The string value or empty string if not present - */ -export const getRunStringProp = (run: Run, prop: string): string => { - if (hasStringProp(run, prop)) { - return run[prop]; - } - return ''; -}; - -/** - * Safely gets a number property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The number value or 0 if not present - */ -export const getRunNumberProp = (run: Run, prop: string): number => { - if (hasNumberProp(run, prop)) { - return run[prop]; - } - return 0; -}; - -/** - * Safely gets a boolean property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The boolean value or false if not present - */ -export const getRunBooleanProp = (run: Run, prop: string): boolean => { - if (hasBooleanProp(run, prop)) { - return run[prop]; - } - return false; -}; - -/** - * Safely gets the underline style from a run. - * Handles the object-shaped underline property { style?, color? }. - * - * @param run - The run to get the underline style from - * @returns The underline style or empty string if not present - */ -export const getRunUnderlineStyle = (run: Run): string => { - if ('underline' in run && typeof run.underline === 'boolean') { - return run.underline ? 'single' : ''; - } - if ('underline' in run && run.underline && typeof run.underline === 'object') { - return (run.underline as { style?: string }).style ?? ''; - } - return ''; -}; - -/** - * Safely gets the underline color from a run. - * Handles the object-shaped underline property { style?, color? }. - * - * @param run - The run to get the underline color from - * @returns The underline color or empty string if not present - */ -export const getRunUnderlineColor = (run: Run): string => { - if ('underline' in run && run.underline && typeof run.underline === 'object') { - return (run.underline as { color?: string }).color ?? ''; - } - return ''; -}; +export { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, + hasBooleanProp, + hasNumberProp, + hasStringProp, +} from './runs/hash.js'; diff --git a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts new file mode 100644 index 0000000000..19ffa2f086 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -0,0 +1,230 @@ +import type { ImageRun, ParagraphAttrs, ParagraphBlock, TextRun } from '@superdoc/contracts'; +import { getParagraphInlineDirection } from '@superdoc/contracts'; +import { hashParagraphBorders } from '../paragraph-hash-utils.js'; +import { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, +} from '../runs/hash.js'; + +type ParagraphHashFns = { + hashString: (seed: number, value: string) => number; + hashNumber: (seed: number, value: number | undefined | null) => number; +}; + +const hasListMarkerProperties = ( + attrs: unknown, +): attrs is { + numberingProperties: { numId?: number | string; ilvl?: number }; + wordLayout?: { marker?: { markerText?: string } }; +} => { + if (!attrs || typeof attrs !== 'object') return false; + const obj = attrs as Record; + + if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false; + const numProps = obj.numberingProperties as Record; + + if ('numId' in numProps) { + const numId = numProps.numId; + if (typeof numId !== 'number' && typeof numId !== 'string') return false; + } + + if ('ilvl' in numProps) { + const ilvl = numProps.ilvl; + if (typeof ilvl !== 'number') return false; + } + + if ('wordLayout' in obj && obj.wordLayout !== undefined) { + if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false; + const wordLayout = obj.wordLayout as Record; + + if ('marker' in wordLayout && wordLayout.marker !== undefined) { + if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false; + const marker = wordLayout.marker as Record; + + if ('markerText' in marker && marker.markerText !== undefined) { + if (typeof marker.markerText !== 'string') return false; + } + } + } + + return true; +}; + +export const deriveParagraphBlockVersion = ( + block: ParagraphBlock, + getSdtMetadataVersion: (metadata: ParagraphAttrs['sdt']) => string, + readClipPathValue: (value: unknown) => string, +): string => { + const markerVersion = hasListMarkerProperties(block.attrs) + ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}` + : ''; + + const runsVersion = block.runs + .map((run) => { + // Paragraph-level cache keys intentionally exclude run pmStart/pmEnd; position-only edits update datasets in place. + if (run.kind === 'image') { + const imgRun = run as ImageRun; + return [ + 'img', + imgRun.src, + imgRun.width, + imgRun.height, + imgRun.alt ?? '', + imgRun.title ?? '', + imgRun.clipPath ?? '', + imgRun.distTop ?? '', + imgRun.distBottom ?? '', + imgRun.distLeft ?? '', + imgRun.distRight ?? '', + readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + ].join(','); + } + + if (run.kind === 'lineBreak') { + return 'linebreak'; + } + + if (run.kind === 'tab') { + return [run.text ?? '', 'tab'].join(','); + } + + if (run.kind === 'fieldAnnotation') { + const size = run.size ? `${run.size.width ?? ''}x${run.size.height ?? ''}` : ''; + const highlighted = run.highlighted !== false ? 1 : 0; + return [ + 'field', + run.variant ?? '', + run.displayLabel ?? '', + run.fieldColor ?? '', + run.borderColor ?? '', + highlighted, + run.hidden ? 1 : 0, + run.visibility ?? '', + run.imageSrc ?? '', + run.linkUrl ?? '', + run.rawHtml ?? '', + size, + run.fontFamily ?? '', + run.fontSize ?? '', + run.textColor ?? '', + run.textHighlight ?? '', + run.bold ? 1 : 0, + run.italic ? 1 : 0, + run.underline ? 1 : 0, + run.fieldId ?? '', + run.fieldType ?? '', + ].join(','); + } + + const textRun = run as TextRun; + const trackedChangeVersion = textRun.trackedChange + ? [ + textRun.trackedChange.kind ?? '', + textRun.trackedChange.id ?? '', + textRun.trackedChange.storyKey ?? '', + textRun.trackedChange.author ?? '', + textRun.trackedChange.authorEmail ?? '', + textRun.trackedChange.authorImage ?? '', + textRun.trackedChange.date ?? '', + textRun.trackedChange.before ? JSON.stringify(textRun.trackedChange.before) : '', + textRun.trackedChange.after ? JSON.stringify(textRun.trackedChange.after) : '', + ].join(':') + : ''; + return [ + textRun.text ?? '', + textRun.fontFamily, + textRun.fontSize, + textRun.bold ? 1 : 0, + textRun.italic ? 1 : 0, + textRun.color ?? '', + textRun.underline?.style ?? '', + textRun.underline?.color ?? '', + textRun.strike ? 1 : 0, + textRun.highlight ?? '', + textRun.letterSpacing != null ? textRun.letterSpacing : '', + textRun.vertAlign ?? '', + textRun.baselineShift != null ? textRun.baselineShift : '', + textRun.token ?? '', + trackedChangeVersion, + textRun.comments?.length ?? 0, + ].join(','); + }) + .join('|'); + + const attrs = block.attrs as ParagraphAttrs | undefined; + const paragraphAttrsVersion = attrs + ? [ + attrs.alignment ?? '', + attrs.spacing?.before ?? '', + attrs.spacing?.after ?? '', + attrs.spacing?.line ?? '', + attrs.spacing?.lineRule ?? '', + attrs.indent?.left ?? '', + attrs.indent?.right ?? '', + attrs.indent?.firstLine ?? '', + attrs.indent?.hanging ?? '', + attrs.borders ? hashParagraphBorders(attrs.borders) : '', + attrs.shading?.fill ?? '', + attrs.shading?.color ?? '', + getParagraphInlineDirection(attrs) ?? '', + attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '', + ].join(':') + : ''; + + const sdtVersion = getSdtMetadataVersion(attrs?.sdt); + const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean); + return parts.join('|'); +}; + +export const hashParagraphBlockForTableVersion = ( + seed: number, + paragraphBlock: ParagraphBlock, + hashFns: ParagraphHashFns, +): number => { + const { hashNumber, hashString } = hashFns; + const runs = paragraphBlock.runs ?? []; + let hash = hashNumber(seed, runs.length); + const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined; + + if (attrs) { + hash = hashString(hash, attrs.alignment ?? ''); + hash = hashNumber(hash, attrs.spacing?.before ?? 0); + hash = hashNumber(hash, attrs.spacing?.after ?? 0); + hash = hashNumber(hash, attrs.spacing?.line ?? 0); + hash = hashString(hash, attrs.spacing?.lineRule ?? ''); + hash = hashNumber(hash, attrs.indent?.left ?? 0); + hash = hashNumber(hash, attrs.indent?.right ?? 0); + hash = hashNumber(hash, attrs.indent?.firstLine ?? 0); + hash = hashNumber(hash, attrs.indent?.hanging ?? 0); + hash = hashString(hash, attrs.shading?.fill ?? ''); + hash = hashString(hash, attrs.shading?.color ?? ''); + hash = hashString(hash, getParagraphInlineDirection(attrs) ?? ''); + if (attrs.borders) { + hash = hashString(hash, hashParagraphBorders(attrs.borders)); + } + } + + for (const run of runs) { + if ('text' in run && typeof run.text === 'string') { + hash = hashString(hash, run.text); + } + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + hash = hashString(hash, getRunStringProp(run, 'color')); + hash = hashString(hash, getRunStringProp(run, 'highlight')); + hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : ''); + hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : ''); + hash = hashNumber(hash, getRunNumberProp(run, 'fontSize')); + hash = hashString(hash, getRunStringProp(run, 'fontFamily')); + hash = hashString(hash, getRunUnderlineStyle(run)); + hash = hashString(hash, getRunUnderlineColor(run)); + hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); + hash = hashString(hash, getRunStringProp(run, 'vertAlign')); + hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + } + + return hash; +}; diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/border-layer.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/border-layer.ts diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts similarity index 53% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts index eaa1327e91..10e50634ca 100644 --- a/packages/layout-engine/painters/dom/src/features/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 { ListItemFragment, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; +import type { ParagraphBorders, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; /** @@ -33,115 +33,48 @@ const isBetweenBorderNone = (borders: ResolvedFragmentItem['paragraphBorders']): return borders.between.style === 'none'; }; -/** - * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) - * 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 or list-item (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' && frag.kind !== 'list-item') 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' && next.kind !== 'list-item') continue; - if (next.continuesFromPrev) continue; - if (next.blockId === frag.blockId && next.kind === 'para') continue; - if ( - next.blockId === frag.blockId && - next.kind === 'list-item' && - frag.kind === 'list-item' && - (next as ListItemFragment).itemId === (frag as ListItemFragment).itemId - ) - 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, @@ -156,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, @@ -171,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/features/paragraph-borders/index.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts similarity index 82% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/index.ts index 79084b6abe..51364d9dd0 100644 --- a/packages/layout-engine/painters/dom/src/features/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/frame.ts b/packages/layout-engine/painters/dom/src/paragraph/frame.ts new file mode 100644 index 0000000000..9e3b940543 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/frame.ts @@ -0,0 +1,43 @@ +import type { ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts'; +import { assertFragmentPmPositions } from '../pm-position-validation.js'; + +export type FragmentFrameSection = 'body' | 'header' | 'footer'; + +export const applyParagraphFragmentPmAttributes = ( + el: HTMLElement, + fragment: ParaFragment, + section?: FragmentFrameSection, + resolvedItem?: ResolvedFragmentItem, +): void => { + if (section === 'body' || section === undefined) { + assertFragmentPmPositions(fragment, 'paragraph fragment'); + } + + const pmStart = resolvedItem ? resolvedItem.pmStart : fragment.pmStart; + if (pmStart != null) { + el.dataset.pmStart = String(pmStart); + } else { + delete el.dataset.pmStart; + } + + const pmEnd = resolvedItem ? resolvedItem.pmEnd : fragment.pmEnd; + if (pmEnd != null) { + el.dataset.pmEnd = String(pmEnd); + } else { + delete el.dataset.pmEnd; + } + + const continuesFromPrev = resolvedItem ? resolvedItem.continuesFromPrev : fragment.continuesFromPrev; + if (continuesFromPrev) { + el.dataset.continuesFromPrev = 'true'; + } else { + delete el.dataset.continuesFromPrev; + } + + const continuesOnNext = resolvedItem ? resolvedItem.continuesOnNext : fragment.continuesOnNext; + if (continuesOnNext) { + el.dataset.continuesOnNext = 'true'; + } else { + delete el.dataset.continuesOnNext; + } +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/indentation.ts b/packages/layout-engine/painters/dom/src/paragraph/indentation.ts new file mode 100644 index 0000000000..72b9368c11 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/indentation.ts @@ -0,0 +1,102 @@ +import type { Line, ParagraphIndent } from '@superdoc/contracts'; +import { adjustAvailableWidthForTextIndent } from '@superdoc/contracts'; + +export type ParagraphLineIndentationParams = { + lineEl: HTMLElement; + line: Line; + indent?: ParagraphIndent; + indentLeftPx: number; + hasListMarkerLayout: boolean; + lineIndex: number; + localStartLine: number; + continuesFromPrev?: boolean; + suppressFirstLineIndent: boolean; + resetContinuationTextIndent?: boolean; +}; + +export const hasExplicitSegmentPositioning = (line: Line): boolean => + line.segments?.some((segment) => segment.x !== undefined) === true; + +export const applyParagraphLineIndentation = (params: ParagraphLineIndentationParams): void => { + const { + lineEl, + line, + indent, + indentLeftPx, + hasListMarkerLayout, + lineIndex, + localStartLine, + continuesFromPrev, + suppressFirstLineIndent, + resetContinuationTextIndent, + } = params; + const paraIndentLeft = indent?.left ?? 0; + const paraIndentRight = indent?.right ?? 0; + const firstLineOffset = suppressFirstLineIndent ? 0 : (indent?.firstLine ?? 0) - (indent?.hanging ?? 0); + const isFirstLine = lineIndex === 0 && localStartLine === 0 && !continuesFromPrev; + const explicitSegmentPositioning = hasExplicitSegmentPositioning(line); + + if (hasListMarkerLayout && indentLeftPx) { + if (!explicitSegmentPositioning) { + lineEl.style.paddingLeft = `${indentLeftPx}px`; + } + } else if (explicitSegmentPositioning) { + if (isFirstLine && firstLineOffset !== 0) { + const effectiveLeftIndent = paraIndentLeft < 0 ? 0 : paraIndentLeft; + const adjustedPadding = effectiveLeftIndent + firstLineOffset; + if (adjustedPadding > 0) { + lineEl.style.paddingLeft = `${adjustedPadding}px`; + } + } + } else if (paraIndentLeft && paraIndentLeft > 0) { + lineEl.style.paddingLeft = `${paraIndentLeft}px`; + } else if (!isFirstLine && indent?.hanging && indent.hanging > 0 && (paraIndentLeft == null || paraIndentLeft >= 0)) { + lineEl.style.paddingLeft = `${indent.hanging}px`; + } + + if (paraIndentRight && paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } + if (isFirstLine && firstLineOffset && !explicitSegmentPositioning) { + lineEl.style.textIndent = `${firstLineOffset}px`; + } else if (firstLineOffset && explicitSegmentPositioning) { + lineEl.style.textIndent = '0px'; + } else if (firstLineOffset && !hasListMarkerLayout && resetContinuationTextIndent) { + lineEl.style.textIndent = '0px'; + } +}; + +export const resolveAvailableWidthForLine = (params: { + containerWidth: number; + line: Line; + indentLeftPx: number; + indentRightPx: number; + firstLineOffset: number; + isFirstLine: boolean; + isListFirstLine: boolean; + resolvedListTextStartPx?: number; +}): number => { + const { + containerWidth, + line, + indentLeftPx, + indentRightPx, + firstLineOffset, + isFirstLine, + isListFirstLine, + resolvedListTextStartPx, + } = params; + const positiveIndentReduction = Math.max(0, indentLeftPx) + Math.max(0, indentRightPx); + const fallbackAvailableWidth = Math.max(0, containerWidth - positiveIndentReduction); + let availableWidth = line.maxWidth != null ? Math.min(line.maxWidth, fallbackAvailableWidth) : fallbackAvailableWidth; + + if (resolvedListTextStartPx != null) { + availableWidth = containerWidth - resolvedListTextStartPx - Math.max(0, indentRightPx); + } + + if (isFirstLine && !isListFirstLine && line.hasExplicitTabStops !== true) { + availableWidth = adjustAvailableWidthForTextIndent(availableWidth, firstLineOffset, line.maxWidth); + } + + return availableWidth; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts new file mode 100644 index 0000000000..d73603eea0 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -0,0 +1,333 @@ +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { toCssFontFamily } from '@superdoc/font-utils'; +import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts'; +import { + computeTabWidth, + resolveListMarkerGeometry, + resolveListTextStartPx, + type MinimalMarker, + type MinimalWordLayout, + type ResolvedListMarkerGeometry, +} from '@superdoc/common/list-marker-utils'; +import { applySourceAnchorDataset } from '../utils/source-anchor.js'; + +type PainterListTextStartParams = { + wordLayout: MinimalWordLayout | undefined; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + markerTextWidthPx?: number; +}; + +const getFiniteNonNegativeNumber = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return undefined; + } + return value; +}; + +const resolvePainterMarkerTextWidth = ( + markerTextWidthPx: number | undefined, + marker: { glyphWidthPx?: number; markerBoxWidthPx?: number }, +): number => + getFiniteNonNegativeNumber(markerTextWidthPx) ?? + getFiniteNonNegativeNumber(marker.glyphWidthPx) ?? + getFiniteNonNegativeNumber(marker.markerBoxWidthPx) ?? + 0; + +export const resolvePainterListMarkerGeometry = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): ResolvedListMarkerGeometry | undefined => + resolveListMarkerGeometry( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +export const resolvePainterListTextStartPx = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): number | undefined => + resolveListTextStartPx( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +type MarkerRunStyle = { + fontFamily?: string | null; + fontSize?: number | null; + bold?: boolean | null; + italic?: boolean | null; + color?: string | null; + letterSpacing?: number | null; + vanish?: boolean | null; +}; + +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, + run: MarkerRunStyle, + sourceAnchor?: SourceAnchor, +): HTMLElement => { + const markerContainer = doc.createElement('span'); + markerContainer.classList.add(DOM_CLASS_NAMES.LIST_MARKER); + markerContainer.style.display = 'inline-block'; + markerContainer.style.wordSpacing = '0px'; + + const markerEl = doc.createElement('span'); + markerEl.classList.add('superdoc-paragraph-marker'); + markerEl.textContent = markerText; + markerEl.style.pointerEvents = 'none'; + markerEl.style.fontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily ?? ''; + + if (run.fontSize != null) { + markerEl.style.fontSize = `${run.fontSize}px`; + } + markerEl.style.fontWeight = run.bold ? 'bold' : ''; + markerEl.style.fontStyle = run.italic ? 'italic' : ''; + + if (run.color) { + markerEl.style.color = run.color; + } + if (run.letterSpacing != null) { + markerEl.style.letterSpacing = `${run.letterSpacing}px`; + } + + markerContainer.appendChild(markerEl); + if (sourceAnchor) { + applySourceAnchorDataset(markerEl, sourceAnchor); + } + return markerContainer; +}; + +export const renderLegacyListMarker = (params: { + doc: Document; + lineEl: HTMLElement; + wordLayout?: MinimalWordLayout; + markerLayout: MinimalMarker; + markerMeasure: ParagraphMeasure['marker']; + markerTextWidthPx?: number; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + isRtl?: boolean; + sourceAnchor?: SourceAnchor; +}): void => { + const { + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + markerTextWidthPx, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + isRtl, + sourceAnchor, + } = params; + const markerTextWidth = markerTextWidthPx ?? markerMeasure?.markerTextWidth ?? 0; + const shouldUseSharedInlinePrefixGeometry = + markerLayout?.justification === 'left' && + wordLayout?.firstLineIndentMode !== true && + typeof markerTextWidth === 'number' && + Number.isFinite(markerTextWidth) && + markerTextWidth >= 0; + const markerGeometry = shouldUseSharedInlinePrefixGeometry + ? resolvePainterListMarkerGeometry({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx: markerTextWidth, + }) + : undefined; + + const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; + const markerJustification = isMarkerJustification(markerLayout?.justification) ? markerLayout.justification : 'left'; + let markerStartPos: number; + let currentPos: number; + if (markerJustification === 'left') { + markerStartPos = anchorPoint; + currentPos = markerStartPos + markerTextWidth; + } else if (markerJustification === 'right') { + markerStartPos = anchorPoint - markerTextWidth; + currentPos = anchorPoint; + } else { + markerStartPos = anchorPoint - markerTextWidth / 2; + currentPos = markerStartPos + markerTextWidth; + } + + const suffix = markerLayout?.suffix ?? 'tab'; + let suffixWidthPx = 0; + if (markerGeometry && (suffix === 'tab' || suffix === 'space')) { + suffixWidthPx = markerGeometry.suffixWidthPx; + } else if (suffix === 'tab') { + suffixWidthPx = computeTabWidth( + currentPos, + markerJustification, + wordLayout?.tabsPx, + hangingIndentPx, + firstLineIndentPx, + indentLeftPx, + ); + } else if (suffix === 'space') { + suffixWidthPx = 4; + } + + renderListMarkerFrame({ + doc, + lineEl, + markerText: markerLayout?.markerText ?? '', + run: markerLayout?.run ?? {}, + sourceAnchor, + 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: { + doc: Document; + lineEl: HTMLElement; + marker: ResolvedListMarkerItem; + isRtl?: boolean; + 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 = `${firstLinePaddingPx}px`; + } else { + lineEl.style.paddingLeft = `${firstLinePaddingPx}px`; + } + + if (vanish) { + return; + } + + const markerContainer = createListMarkerElement(doc, markerText, run, sourceAnchor); + markerContainer.style.position = 'relative'; + if (justification === 'right') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${markerStartPx}px`; + } else { + markerContainer.style.left = `${markerStartPx}px`; + } + } else if (justification === 'center') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${markerStartPx}px`; + lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + centerPaddingAdjustPx}px`; + } else { + markerContainer.style.left = `${markerStartPx}px`; + lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + centerPaddingAdjustPx}px`; + } + } + + prependMarkerSuffix(doc, lineEl, suffix, suffixWidthPx, run.fontSize ?? undefined); + lineEl.prepend(markerContainer); +}; + +const prependMarkerSuffix = ( + doc: Document, + lineEl: HTMLElement, + suffix: 'tab' | 'space' | 'nothing' | undefined, + suffixWidthPx: number, + fontSize?: number, +): void => { + if (suffix === 'tab') { + const tabEl = doc.createElement('span'); + tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); + tabEl.innerHTML = ' '; + tabEl.style.display = 'inline-block'; + if (fontSize != null) { + tabEl.style.fontSize = `${fontSize}px`; + } + tabEl.style.wordSpacing = '0px'; + tabEl.style.width = `${suffixWidthPx}px`; + lineEl.prepend(tabEl); + } else if (suffix === 'space') { + const spaceEl = doc.createElement('span'); + spaceEl.classList.add('superdoc-marker-suffix-space'); + if (fontSize != null) { + spaceEl.style.fontSize = `${fontSize}px`; + } + spaceEl.style.wordSpacing = '0px'; + spaceEl.textContent = '\u00A0'; + lineEl.prepend(spaceEl); + } +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts new file mode 100644 index 0000000000..b20b4de82d --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest'; +import { renderParagraphContent } from './renderParagraphContent.js'; +import type { Line, ParagraphBlock, ParagraphMeasure, ResolvedParagraphContent } from '@superdoc/contracts'; + +describe('renderParagraphContent', () => { + const line = (index: number): Line => ({ + fromRun: 0, + fromChar: index, + toRun: 0, + toChar: index + 1, + width: 10, + ascent: 12, + descent: 4, + lineHeight: 20, + }); + + it('keeps partial body fragments at their rendered line height', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'split-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0), line(1), line(2)], + totalHeight: 60, + }; + + const result = renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + lineIndexOffset: 0, + linesOverride: measure.lines.slice(0, 1), + continuesOnNext: true, + renderLine: () => doc.createElement('div'), + }); + + expect(result.renderedHeight).toBe(20); + expect(result.totalHeight).toBe(20); + expect(frameEl.style.height).toBe('20px'); + }); + + it('marks the final remeasured override line as the paragraph final line', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'remeasured-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0)], + totalHeight: 20, + }; + const renderedLines: Array<{ lineIndex: number; isLastLine: boolean; skipJustify?: boolean }> = []; + + renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 2, + lineIndexOffset: 0, + linesOverride: [line(0), line(1)], + renderLine: ({ lineIndex, isLastLine, skipJustify }) => { + renderedLines.push({ lineIndex, isLastLine, skipJustify }); + return doc.createElement('div'); + }, + }); + + expect(renderedLines).toEqual([ + { lineIndex: 0, isLastLine: false, skipJustify: false }, + { lineIndex: 1, isLastLine: true, skipJustify: true }, + ]); + }); + + it('preserves paragraph right indent on list marker lines', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'list-paragraph', + attrs: { + indent: { left: 24, hanging: 12, right: 18 }, + wordLayout: { + marker: { + markerText: '1.', + suffix: 'space', + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }, + }, + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0)], + marker: { + markerWidth: 10, + markerTextWidth: 8, + }, + totalHeight: 20, + }; + let lineEl: HTMLElement | undefined; + + renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + markerWidth: 10, + markerTextWidth: 8, + renderLine: () => { + lineEl = doc.createElement('div'); + return lineEl; + }, + }); + + expect(lineEl?.style.cssText).toContain('padding-right: 18px'); + }); + + it('renders resolved RTL list markers on the right side', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'resolved-list-paragraph', + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const resolvedContent: ResolvedParagraphContent = { + lines: [ + { + line: line(0), + lineIndex: 0, + availableWidth: 160, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: true, + hasExplicitSegmentPositioning: false, + indentOffset: 30, + }, + ], + marker: { + text: '1.', + justification: 'right', + suffix: 'space', + markerStartPx: 6, + suffixWidthPx: 4, + firstLinePaddingLeftPx: 30, + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }; + + renderParagraphContent({ + doc, + frameEl, + block, + measure: { kind: 'paragraph', lines: [line(0)], totalHeight: 20 }, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + resolvedContent, + renderLine: () => doc.createElement('div'), + }); + + const lineEl = frameEl.lastElementChild as HTMLElement; + const markerEl = lineEl.querySelector('.superdoc-list-marker'); + expect(lineEl.style.paddingRight).toBe('30px'); + expect(markerEl?.style.right).toBe('6px'); + }); + + it('converts the final paragraph mark for resolved content', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'resolved-cell-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const resolvedContent: ResolvedParagraphContent = { + lines: [ + { + line: line(0), + lineIndex: 0, + availableWidth: 160, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: false, + hasExplicitSegmentPositioning: false, + indentOffset: 0, + }, + ], + }; + + renderParagraphContent({ + doc, + frameEl, + block, + measure: { kind: 'paragraph', lines: [line(0)], totalHeight: 20 }, + containerKind: 'table-cell', + width: 200, + localStartLine: 0, + localEndLine: 1, + resolvedContent, + convertFinalParagraphMark: true, + renderLine: () => { + const lineEl = doc.createElement('div'); + const mark = doc.createElement('span'); + mark.classList.add('superdoc-formatting-paragraph-mark'); + mark.textContent = '¶'; + lineEl.appendChild(mark); + return lineEl; + }, + }); + + const mark = frameEl.querySelector('.superdoc-formatting-paragraph-mark'); + expect(mark?.classList.contains('superdoc-formatting-cell-mark')).toBe(true); + expect(mark?.textContent).toBe('¤'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts new file mode 100644 index 0000000000..5ea02313f5 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -0,0 +1,533 @@ +import type { + DropCapDescriptor, + Line, + ParagraphBlock, + ParagraphMeasure, + ResolvedParagraphContent, + Run, + SourceAnchor, +} from '@superdoc/contracts'; +import { + effectiveTableCellSpacing, + expandRunsForInlineNewlines, + getParagraphInlineDirection, +} from '@superdoc/contracts'; +import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; +import { + applySdtContainerChrome, + shouldRenderSdtContainerChrome, + 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, + hasExplicitSegmentPositioning, + resolveAvailableWidthForLine, +} from './indentation.js'; +import { renderLegacyListMarker, renderResolvedListMarker, resolvePainterListTextStartPx } from './list-marker.js'; +import { applyParagraphBlockStyles, clearParagraphFrameIndentStyles } from './styles.js'; + +export type RenderedParagraphLineInfo = { + el: HTMLElement; + top: number; + height: number; +}; + +export type ParagraphRenderLineInput = { + block: ParagraphBlock; + line: Line; + lineIndex: number; + isLastLine: boolean; + availableWidth?: number; + skipJustify?: boolean; + preExpandedRuns?: Run[]; + resolvedListTextStartPx?: number; + indentOffsetOverride?: number; + paragraphMarkLeftOffsetOverride?: number; +}; + +export type ParagraphRenderLine = (input: ParagraphRenderLineInput) => HTMLElement; + +export type ParagraphRenderDropCap = ( + descriptor: DropCapDescriptor, + measure?: { width: number; height: number; lines: number; mode: 'drop' | 'margin' }, +) => HTMLElement; + +export type ParagraphContainerKind = 'body-fragment' | 'table-cell'; + +type ParagraphSpacingPolicy = { + isFirstBlock: boolean; + isLastBlock: boolean; + paddingTop: number; +}; + +export type RenderParagraphContentParams = { + doc: Document; + frameEl: HTMLElement; + block: ParagraphBlock; + measure: ParagraphMeasure; + containerKind: ParagraphContainerKind; + width: number; + localStartLine: number; + localEndLine: number; + linesOverride?: Line[]; + lineIndexOffset?: number; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; + markerWidth?: number; + markerTextWidth?: number; + wordLayout?: MinimalWordLayout; + resolvedContent?: ResolvedParagraphContent; + betweenInfo?: BetweenBorderInfo; + sdtBoundary?: SdtBoundaryOptions; + spacingPolicy?: ParagraphSpacingPolicy; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + onSdtContainerChrome?: () => void; + renderLine: ParagraphRenderLine; + renderDropCap?: ParagraphRenderDropCap; + captureLineSnapshot?: ( + lineEl: HTMLElement, + options?: { inTableParagraph?: boolean; wrapperEl?: HTMLElement; sourceAnchor?: SourceAnchor }, + ) => void; + convertFinalParagraphMark?: boolean; + lineTopOffset?: number; + sourceAnchor?: SourceAnchor; +}; + +export type RenderParagraphContentResult = { + renderedHeight: number; + totalHeight: number; + renderedLines: RenderedParagraphLineInfo[]; +}; + +export const renderParagraphContent = (params: RenderParagraphContentParams): RenderParagraphContentResult => { + const { + doc, + frameEl, + block, + measure, + linesOverride, + width, + localStartLine, + localEndLine, + lineIndexOffset = 0, + continuesFromPrev, + continuesOnNext, + resolvedContent, + betweenInfo, + sdtBoundary, + spacingPolicy, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, + renderDropCap, + lineTopOffset = 0, + } = params; + + applyParagraphBlockStyles(frameEl, block.attrs); + const { shadingLayer, borderLayer } = createParagraphDecorationLayers(doc, width, block.attrs, betweenInfo); + if (shadingLayer) frameEl.appendChild(shadingLayer); + if (borderLayer) frameEl.appendChild(borderLayer); + stampBetweenBorderDataset(frameEl, betweenInfo); + + if (block.attrs?.styleId) { + frameEl.dataset.styleId = block.attrs.styleId; + frameEl.setAttribute('styleid', block.attrs.styleId); + } + applySdtDataset(frameEl, block.attrs?.sdt); + applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); + + const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { + ancestorContainerKeys, + ancestorContainerSdts, + }); + if (applySdtChrome) { + if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) { + onSdtContainerChrome?.(); + } + } + + renderParagraphDropCap({ + frameEl, + block, + measure, + resolvedContent, + continuesFromPrev, + renderDropCap, + }); + + clearParagraphFrameIndentStyles(frameEl); + + const spacingBefore = block.attrs?.spacing?.before; + let beforeHeight = 0; + if (spacingPolicy && localStartLine === 0) { + beforeHeight = effectiveTableCellSpacing(spacingBefore, spacingPolicy.isFirstBlock, spacingPolicy.paddingTop); + if (beforeHeight > 0) { + frameEl.style.marginTop = `${beforeHeight}px`; + } + } + + const renderResult = + resolvedContent != null + ? renderResolvedLines({ + ...params, + resolvedContent, + lineTopOffset: lineTopOffset + beforeHeight, + }) + : renderMeasuredLines({ + ...params, + lineTopOffset: lineTopOffset + beforeHeight, + }); + + let renderedHeight = renderResult.renderedHeight; + const originalLineCount = measure.lines?.length ?? linesOverride?.length ?? 0; + const renderedStartLine = lineIndexOffset + localStartLine; + const renderedEndLine = lineIndexOffset + localEndLine; + const renderedEntireBlock = + !continuesFromPrev && !continuesOnNext && renderedStartLine === 0 && renderedEndLine >= originalLineCount; + if (renderedEntireBlock && measure.totalHeight && measure.totalHeight > renderedHeight) { + renderedHeight = measure.totalHeight; + } + + let afterHeight = 0; + if (spacingPolicy && renderedEntireBlock && !spacingPolicy.isLastBlock) { + const spacingAfter = block.attrs?.spacing?.after; + if (typeof spacingAfter === 'number' && spacingAfter > 0) { + frameEl.style.marginBottom = `${spacingAfter}px`; + afterHeight = spacingAfter; + } + } + + if (renderedHeight > 0) { + frameEl.style.height = `${renderedHeight}px`; + } + + return { + renderedHeight, + totalHeight: beforeHeight + renderedHeight + afterHeight, + renderedLines: renderResult.renderedLines, + }; +}; + +const renderResolvedLines = ( + params: RenderParagraphContentParams & { resolvedContent: ResolvedParagraphContent }, +): { renderedHeight: number; renderedLines: RenderedParagraphLineInfo[] } => { + const { + frameEl, + block, + resolvedContent: content, + markerTextWidth, + renderLine, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + const renderedLines: RenderedParagraphLineInfo[] = []; + const resolvedMarker = content.marker; + const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; + let renderedHeight = 0; + + content.lines.forEach((resolvedLine, index) => { + const paragraphMarkLeftOffset = resolveResolvedListParagraphMarkOffset( + resolvedLine.isListFirstLine ? resolvedMarker : undefined, + markerTextWidth, + resolvedLine.indentOffset, + ); + const lineEl = renderLine({ + block, + line: resolvedLine.line, + lineIndex: resolvedLine.lineIndex, + isLastLine: index === content.lines.length - 1 && !content.continuesOnNext, + availableWidth: resolvedLine.availableWidth, + skipJustify: resolvedLine.skipJustify, + preExpandedRuns: expandedRunsForBlock, + resolvedListTextStartPx: resolvedLine.resolvedListTextStartPx, + indentOffsetOverride: resolvedLine.indentOffset, + paragraphMarkLeftOffsetOverride: paragraphMarkLeftOffset, + }); + + if (!resolvedLine.isListFirstLine) { + applyResolvedLineIndentation(lineEl, block, content, resolvedLine); + } + if (resolvedLine.paddingRightPx > 0) { + lineEl.style.paddingRight = `${resolvedLine.paddingRightPx}px`; + } + if (resolvedLine.isListFirstLine && resolvedMarker) { + renderResolvedListMarker({ doc: params.doc, lineEl, marker: resolvedMarker, isRtl, sourceAnchor }); + } + if (convertFinalParagraphMark && index === content.lines.length - 1 && !content.continuesOnNext) { + convertParagraphMarkToCellMark(lineEl); + } + captureLineSnapshot?.(lineEl, { + inTableParagraph: params.containerKind === 'table-cell', + wrapperEl: frameEl, + sourceAnchor, + }); + frameEl.appendChild(lineEl); + const height = resolvedLine.line.lineHeight; + renderedLines.push({ el: lineEl, top: lineTopOffset + renderedHeight, height }); + renderedHeight += height; + }); + + return { renderedHeight, renderedLines }; +}; + +const renderMeasuredLines = ( + params: RenderParagraphContentParams, +): { renderedHeight: number; renderedLines: RenderedParagraphLineInfo[] } => { + const { + doc, + frameEl, + block, + measure, + containerKind, + width, + localStartLine, + localEndLine, + linesOverride, + lineIndexOffset = 0, + continuesFromPrev, + continuesOnNext, + markerWidth, + markerTextWidth, + wordLayout, + renderLine, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + const lines = linesOverride ?? measure.lines ?? []; + const paraIndent = block.attrs?.indent; + const paraIndentLeft = paraIndent?.left ?? 0; + const paraIndentRight = paraIndent?.right ?? 0; + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; + const { + anchorIndentPx: paraMarkerAnchorIndent, + firstLinePx: markerFirstLine, + hangingPx: markerHanging, + } = resolveMarkerIndent(paraIndent, isRtl); + const wordLayoutIndentLeft = (wordLayout as { indentLeftPx?: number } | undefined)?.indentLeftPx; + const tableMarkerIndentLeft = + measure.marker?.indentLeft ?? + wordLayoutIndentLeft ?? + (typeof paraIndent?.left === 'number' ? paraIndent.left : 0); + const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + const expandedRunsForBlock = containerKind === 'body-fragment' ? expandRunsForInlineNewlines(block.runs) : undefined; + const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; + const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; + const markerLayout = wordLayout?.marker; + const markerMeasure = measure.marker; + + const legacyMarkerWidth = markerWidth ?? markerMeasure?.markerWidth; + const legacyMarkerTextWidth = markerTextWidth ?? markerMeasure?.markerTextWidth; + const listFirstLineTextStartPx = + !continuesFromPrev && legacyMarkerWidth && markerLayout && markerMeasure + ? resolvePainterListTextStartPx({ + wordLayout, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, + markerTextWidthPx: legacyMarkerTextWidth, + }) + : undefined; + + let renderedHeight = 0; + const renderedLines: RenderedParagraphLineInfo[] = []; + const renderedLocalEndLine = Math.min(localEndLine, lines.length); + + for (let lineIdx = localStartLine; lineIdx < localEndLine && lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + const explicitSegmentPositioning = hasExplicitSegmentPositioning(line); + const isFirstLine = lineIdx === 0 && !continuesFromPrev; + const isListFirstLine = Boolean(lineIdx === 0 && !continuesFromPrev && legacyMarkerWidth && markerLayout); + const shouldUseResolvedListTextStart = + isListFirstLine && explicitSegmentPositioning && listFirstLineTextStartPx != null; + const globalLineIndex = lineIndexOffset + lineIdx; + const isLastLineOfParagraph = + (linesOverride + ? lineIdx === renderedLocalEndLine - 1 + : globalLineIndex === (measure.lines?.length ?? lines.length) - 1) && !continuesOnNext; + const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; + const availableWidth = + containerKind === 'body-fragment' + ? resolveAvailableWidthForLine({ + containerWidth: width, + line, + indentLeftPx: paraIndentLeft, + indentRightPx: paraIndentRight, + firstLineOffset, + isFirstLine, + isListFirstLine, + resolvedListTextStartPx: shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, + }) + : undefined; + const lineEl = renderLine({ + block, + line, + lineIndex: globalLineIndex, + isLastLine: isLastLineOfParagraph, + availableWidth, + skipJustify: shouldSkipJustifyForLastLine, + preExpandedRuns: expandedRunsForBlock, + resolvedListTextStartPx: shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, + }); + lineEl.style.paddingLeft = ''; + lineEl.style.paddingRight = ''; + lineEl.style.textIndent = ''; + + if (convertFinalParagraphMark && isLastLineOfParagraph) { + convertParagraphMarkToCellMark(lineEl); + } + + if (isListFirstLine && markerLayout && markerMeasure) { + if (paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } + renderLegacyListMarker({ + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + markerTextWidthPx: legacyMarkerTextWidth, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, + isRtl, + sourceAnchor, + }); + } else { + applyParagraphLineIndentation({ + lineEl, + line, + indent: paraIndent, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hasListMarkerLayout: Boolean(markerLayout), + lineIndex: lineIdx, + localStartLine, + continuesFromPrev, + suppressFirstLineIndent, + resetContinuationTextIndent: containerKind === 'body-fragment', + }); + } + + captureLineSnapshot?.(lineEl, { + inTableParagraph: containerKind === 'table-cell', + wrapperEl: frameEl, + sourceAnchor, + }); + frameEl.appendChild(lineEl); + const height = line.lineHeight; + renderedLines.push({ el: lineEl, top: lineTopOffset + renderedHeight, height }); + renderedHeight += height; + } + + return { renderedHeight, renderedLines }; +}; + +const renderParagraphDropCap = (params: { + frameEl: HTMLElement; + block: ParagraphBlock; + measure: ParagraphMeasure; + resolvedContent?: ResolvedParagraphContent; + continuesFromPrev?: boolean; + renderDropCap?: ParagraphRenderDropCap; +}): void => { + const { frameEl, block, measure, resolvedContent, continuesFromPrev, renderDropCap } = params; + if (!renderDropCap) return; + if (resolvedContent?.dropCap) { + const dc = resolvedContent.dropCap; + const dropCapEl = renderDropCap( + { + mode: dc.mode, + run: { + text: dc.text, + fontFamily: dc.fontFamily, + fontSize: dc.fontSize, + bold: dc.bold, + italic: dc.italic, + color: dc.color, + position: dc.position, + }, + lines: 0, + }, + dc.width != null && dc.height != null + ? { width: dc.width, height: dc.height, lines: 0, mode: dc.mode } + : undefined, + ); + frameEl.appendChild(dropCapEl); + return; + } + const dropCapDescriptor = block.attrs?.dropCapDescriptor; + const dropCapMeasure = measure.dropCap; + if (dropCapDescriptor && dropCapMeasure && !continuesFromPrev) { + frameEl.appendChild(renderDropCap(dropCapDescriptor, dropCapMeasure)); + } +}; + +const applyResolvedLineIndentation = ( + lineEl: HTMLElement, + block: ParagraphBlock, + content: ResolvedParagraphContent, + resolvedLine: ResolvedParagraphContent['lines'][number], +): void => { + if (resolvedLine.paddingLeftPx > 0) { + lineEl.style.paddingLeft = `${resolvedLine.paddingLeftPx}px`; + } + if (resolvedLine.textIndentPx !== 0) { + lineEl.style.textIndent = `${resolvedLine.textIndentPx}px`; + } else if (resolvedLine.lineIndex > 0 || content.continuesFromPrev) { + const paraIndent = block.attrs?.indent; + const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + if (firstLineOffset && !resolvedLine.isListFirstLine) { + lineEl.style.textIndent = '0px'; + } + } +}; + +const resolveResolvedListParagraphMarkOffset = ( + marker: ResolvedParagraphContent['marker'] | undefined, + markerTextWidth: number | undefined, + indentOffset: number, +): number | undefined => { + if (!marker) return undefined; + if (typeof indentOffset === 'number' && Number.isFinite(indentOffset) && indentOffset > 0) { + return indentOffset; + } + if (marker.vanish) { + return indentOffset; + } + + const paddingLeft = Number.isFinite(marker.firstLinePaddingLeftPx) ? marker.firstLinePaddingLeftPx : 0; + const suffixWidth = marker.suffix !== 'nothing' && Number.isFinite(marker.suffixWidthPx) ? marker.suffixWidthPx : 0; + + if (marker.justification === 'left') { + const markerWidth = + typeof markerTextWidth === 'number' && Number.isFinite(markerTextWidth) && markerTextWidth > 0 + ? markerTextWidth + : 0; + return paddingLeft + markerWidth + suffixWidth; + } + + const centerPadding = + marker.justification === 'center' && Number.isFinite(marker.centerPaddingAdjustPx) + ? (marker.centerPaddingAdjustPx ?? 0) + : 0; + return paddingLeft + centerPadding + suffixWidth; +}; + +const convertParagraphMarkToCellMark = (lineEl: HTMLElement): void => { + const mark = lineEl.querySelector('.superdoc-formatting-paragraph-mark'); + if (!mark) return; + + mark.classList.add('superdoc-formatting-cell-mark'); + mark.textContent = '¤'; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts new file mode 100644 index 0000000000..47ddfc758b --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -0,0 +1,181 @@ +import type { + DropCapDescriptor, + ParaFragment, + ParagraphBlock, + ParagraphMeasure, + ResolvedFragmentItem, +} from '@superdoc/contracts'; +import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; +import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; +import type { BetweenBorderInfo } from './borders/index.js'; +import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; + +type ApplyStyles = (el: HTMLElement, styles: Partial) => void; + +type RenderParagraphFragmentParams = { + doc: Document | null; + fragment: ParaFragment; + sdtBoundary?: SdtBoundaryOptions; + betweenInfo?: BetweenBorderInfo; + resolvedItem?: ResolvedFragmentItem; + applyStyles: ApplyStyles; + applyResolvedFragmentFrame: (el: HTMLElement, item: ResolvedFragmentItem, fragment: ParaFragment) => void; + applyFragmentFrame: (el: HTMLElement, fragment: ParaFragment) => void; + renderLine: (input: ParagraphRenderLineInput) => HTMLElement; + captureLineSnapshot: ( + lineEl: HTMLElement, + options?: { sourceAnchor?: ResolvedFragmentItem['sourceAnchor']; wrapperEl?: HTMLElement }, + ) => void; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +const isMinimalWordLayout = (value: unknown): value is MinimalWordLayout => isMinimalWordLayoutShared(value); + +export const renderParagraphFragment = (params: RenderParagraphFragmentParams): HTMLElement => { + const { + doc, + fragment, + sdtBoundary, + betweenInfo, + resolvedItem, + applyStyles, + applyResolvedFragmentFrame, + applyFragmentFrame, + renderLine, + captureLineSnapshot, + createErrorPlaceholder, + } = params; + + try { + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + if (resolvedItem?.block?.kind !== 'paragraph' || resolvedItem?.measure?.kind !== 'paragraph') { + throw new Error(`DomPainter: missing resolved paragraph block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ParagraphBlock; + const measure = resolvedItem.measure as ParagraphMeasure; + const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; + const content = resolvedItem?.content; + + const paraContinuesFromPrev = resolvedItem?.continuesFromPrev; + const paraContinuesOnNext = resolvedItem?.continuesOnNext; + const paraMarkerWidth = resolvedItem?.markerWidth; + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment); + + const isTocEntry = block.attrs?.isTocEntry; + const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; + const hasSdtContainer = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt); + const paraIndentForOverflow = block.attrs?.indent; + const hasNegativeIndent = (paraIndentForOverflow?.left ?? 0) < 0 || (paraIndentForOverflow?.right ?? 0) < 0; + const styles = isTocEntry + ? { ...fragmentStyles, whiteSpace: 'nowrap' } + : hasMarker || hasSdtContainer || hasNegativeIndent + ? { ...fragmentStyles, overflow: 'visible' } + : fragmentStyles; + applyStyles(fragmentEl, styles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment); + } else { + applyFragmentFrame(fragmentEl, fragment); + } + + if (isTocEntry) { + fragmentEl.classList.add('superdoc-toc-entry'); + } + + if (paraContinuesFromPrev) { + fragmentEl.dataset.continuesFromPrev = 'true'; + } + if (paraContinuesOnNext) { + fragmentEl.dataset.continuesOnNext = 'true'; + } + + const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); + renderParagraphContent({ + doc, + frameEl: fragmentEl, + block, + measure, + containerKind: 'body-fragment', + width: fragment.width, + localStartLine: 0, + localEndLine: lines.length, + lineIndexOffset: fragment.fromLine, + linesOverride: lines, + continuesFromPrev: paraContinuesFromPrev, + continuesOnNext: paraContinuesOnNext, + markerWidth: paraMarkerWidth, + markerTextWidth: fragment.markerTextWidth, + wordLayout, + resolvedContent: content, + betweenInfo, + sdtBoundary, + renderDropCap: (descriptor, dropCapMeasure) => renderDropCap(doc, descriptor, dropCapMeasure), + renderLine, + captureLineSnapshot: (lineEl, options) => { + captureLineSnapshot(lineEl, { + sourceAnchor: options?.sourceAnchor, + wrapperEl: fragmentEl, + }); + }, + sourceAnchor: resolvedItem?.sourceAnchor, + }); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; + +const renderDropCap = ( + doc: Document, + descriptor: DropCapDescriptor, + measure: ParagraphMeasure['dropCap'], +): HTMLElement => { + const { run, mode } = descriptor; + + const dropCapEl = doc.createElement('span'); + dropCapEl.classList.add('superdoc-drop-cap'); + dropCapEl.textContent = run.text; + + dropCapEl.style.fontFamily = run.fontFamily; + dropCapEl.style.fontSize = `${run.fontSize}px`; + if (run.bold) { + dropCapEl.style.fontWeight = 'bold'; + } + if (run.italic) { + dropCapEl.style.fontStyle = 'italic'; + } + if (run.color) { + dropCapEl.style.color = run.color; + } + + if (mode === 'drop') { + dropCapEl.style.float = 'left'; + dropCapEl.style.marginRight = '4px'; + dropCapEl.style.lineHeight = '1'; + } else if (mode === 'margin') { + dropCapEl.style.position = 'absolute'; + dropCapEl.style.left = '0'; + dropCapEl.style.lineHeight = '1'; + } + + if (run.position && run.position !== 0) { + dropCapEl.style.position = dropCapEl.style.position || 'relative'; + dropCapEl.style.top = `${run.position}px`; + } + + if (measure) { + dropCapEl.style.width = `${measure.width}px`; + dropCapEl.style.height = `${measure.height}px`; + } + + return dropCapEl; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/styles.ts b/packages/layout-engine/painters/dom/src/paragraph/styles.ts new file mode 100644 index 0000000000..bd8e041554 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/styles.ts @@ -0,0 +1,37 @@ +import type { ParagraphAttrs } from '@superdoc/contracts'; +import { applyRtlStyles } from '../features/inline-direction/index.js'; + +export const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { + if (!attrs) return; + if (attrs.styleId) { + element.setAttribute('styleid', attrs.styleId); + } + applyRtlStyles(element, attrs); + if ((attrs as Record).dropCap) { + element.classList.add('sd-editor-dropcap'); + } + const indent = attrs.indent; + if (indent) { + if (indent.left && indent.left > 0) { + element.style.paddingLeft = `${indent.left}px`; + } + if (indent.right && indent.right > 0) { + element.style.paddingRight = `${indent.right}px`; + } + const hasNegativeLeftIndent = indent.left != null && indent.left < 0; + if (!hasNegativeLeftIndent) { + const textIndent = (indent.firstLine ?? 0) - (indent.hanging ?? 0); + if (textIndent) { + element.style.textIndent = `${textIndent}px`; + } + } + } +}; + +export const clearParagraphFrameIndentStyles = (element: HTMLElement): void => { + if (element.style.paddingLeft) element.style.removeProperty('padding-left'); + if (element.style.paddingRight) element.style.removeProperty('padding-right'); + if (element.style.marginLeft) element.style.removeProperty('margin-left'); + if (element.style.marginRight) element.style.removeProperty('margin-right'); + if (element.style.textIndent) element.style.removeProperty('text-indent'); +}; diff --git a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts index ea641ee9d4..bddec899fe 100644 --- a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts @@ -9,8 +9,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createTestPainter as createDomPainter } from './_test-utils.js'; import { DomPainter } from './renderer.js'; +import { renderResolvedTableFragment } from './table/renderResolvedTableFragment.js'; import type { FlowBlock, Measure, Layout } from '@superdoc/contracts'; +vi.mock('./table/renderResolvedTableFragment.js', () => ({ + renderResolvedTableFragment: vi.fn(), +})); + // --------------------------------------------------------------------------- // Minimal fixtures per fragment kind // --------------------------------------------------------------------------- @@ -50,64 +55,6 @@ function paragraphFixtures() { return { blocks: [block], measures: [measure], layout }; } -function listItemFixtures() { - const block: FlowBlock = { - kind: 'list', - id: 'list-dispatch', - listType: 'bullet', - items: [ - { - id: 'item-dispatch', - marker: { kind: 'bullet', text: '•', level: 0 }, - paragraph: { - kind: 'paragraph', - id: 'list-para-dispatch', - runs: [{ text: 'Item', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 5 }], - }, - }, - ], - }; - const measure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-dispatch', - markerWidth: 20, - markerTextWidth: 10, - indentLeft: 36, - paragraph: { - kind: 'paragraph', - lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 4, width: 40, ascent: 10, descent: 4, lineHeight: 16 }], - totalHeight: 16, - }, - }, - ], - totalHeight: 16, - }; - const layout: Layout = { - pageSize: { w: 400, h: 500 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-dispatch', - itemId: 'item-dispatch', - fromLine: 0, - toLine: 1, - x: 50, - y: 0, - width: 250, - markerWidth: 20, - }, - ], - }, - ], - }; - return { blocks: [block], measures: [measure], layout }; -} - function imageFixtures() { const block: FlowBlock = { kind: 'image', @@ -337,16 +284,6 @@ describe('renderFragment dispatch', () => { expect(spy.mock.calls[0]![0].kind).toBe('para'); }); - it('routes list-item fragment to renderListItemFragment', () => { - const dummyDiv = document.createElement('div'); - const spy = vi.spyOn(DomPainter.prototype as any, 'renderListItemFragment').mockReturnValue(dummyDiv); - const { blocks, measures, layout } = listItemFixtures(); - const painter = createDomPainter({ blocks, measures }); - painter.paint(layout, container); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0]![0].kind).toBe('list-item'); - }); - it('routes image fragment to renderImageFragment', () => { const dummyDiv = document.createElement('div'); const spy = vi.spyOn(DomPainter.prototype as any, 'renderImageFragment').mockReturnValue(dummyDiv); @@ -377,14 +314,14 @@ describe('renderFragment dispatch', () => { expect(spy.mock.calls[0]![0].drawingKind).toBe('chart'); }); - it('routes table fragment to renderTableFragment', () => { + it('routes table fragment to table-owned renderResolvedTableFragment', () => { const dummyDiv = document.createElement('div'); - const spy = vi.spyOn(DomPainter.prototype as any, 'renderTableFragment').mockReturnValue(dummyDiv); + vi.mocked(renderResolvedTableFragment).mockReturnValue(dummyDiv); const { blocks, measures, layout } = tableFixtures(); const painter = createDomPainter({ blocks, measures }); painter.paint(layout, container); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0]![0].kind).toBe('table'); + expect(renderResolvedTableFragment).toHaveBeenCalledTimes(1); + expect(vi.mocked(renderResolvedTableFragment).mock.calls[0]![0].fragment.kind).toBe('table'); }); it('throws for unknown fragment kind', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts index eb79122ec9..e668ed19eb 100644 --- a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts @@ -692,7 +692,7 @@ describe('DomPainter hanging indent with tabs', () => { // paddingLeft should be: left (100) + firstLine (500) = 600px expect(lineEl.style.paddingLeft).toBe('600px'); - // Tab element should exist and have width equal to LIST_MARKER_GAP (8px) + // Tab element should preserve the computed marker-to-text gap. const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); expect(tabEl.style.width).toBe('10px'); diff --git a/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts b/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts deleted file mode 100644 index 92706dc9c5..0000000000 --- a/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Known divergence lock tests. - * - * Rule: This file contains ONLY intentional mismatches between rendering - * paths. Every test has a Resolution target comment. When a future PR - * resolves the divergence, it deletes the corresponding test(s). - * - * If a new divergence is discovered during PR 0 implementation, it is added - * here with a resolution target — not fixed in production code. - */ - -import { describe, it, expect } from 'vitest'; -import { createTestPainter as createDomPainter } from './_test-utils.js'; -import type { FlowBlock, Measure, Layout, Line } from '@superdoc/contracts'; -import { normalizeLines } from './test-utils/normalize-line.js'; - -// --------------------------------------------------------------------------- -// Shared constants and helpers -// --------------------------------------------------------------------------- - -const JUSTIFY_TEXT = 'The quick brown fox jumps over'; -const JUSTIFY_TEXT_LEN = JUSTIFY_TEXT.length; -const LINE_WIDTH = 150; -const FRAGMENT_WIDTH = 300; - -function makeLine(fromChar: number, toChar: number, overrides?: Partial): Line { - return { - fromRun: 0, - fromChar, - toRun: 0, - toChar, - width: LINE_WIDTH, - maxWidth: FRAGMENT_WIDTH, - ascent: 10, - descent: 4, - lineHeight: 16, - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// List-item fixtures -// --------------------------------------------------------------------------- - -function listItemFixtures(opts: { alignment?: string; lines?: Line[] }) { - const halfLen = Math.floor(JUSTIFY_TEXT_LEN / 2); - const lines = opts.lines ?? [makeLine(0, halfLen), makeLine(halfLen, JUSTIFY_TEXT_LEN)]; - - const block: FlowBlock = { - kind: 'list', - id: 'list-div', - listType: 'number', - items: [ - { - id: 'item-div', - marker: { kind: 'number', text: '1.', level: 0 }, - paragraph: { - kind: 'paragraph', - id: 'list-para-div', - runs: [{ text: JUSTIFY_TEXT, fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 1 + JUSTIFY_TEXT_LEN }], - ...(opts.alignment ? { attrs: { alignment: opts.alignment } } : {}), - }, - }, - ], - }; - - const measure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-div', - markerWidth: 24, - markerTextWidth: 12, - indentLeft: 36, - paragraph: { - kind: 'paragraph', - lines, - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }, - }, - ], - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }; - - const layout: Layout = { - pageSize: { w: 600, h: 800 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-div', - itemId: 'item-div', - fromLine: 0, - toLine: lines.length, - x: 60, - y: 40, - width: FRAGMENT_WIDTH, - markerWidth: 24, - }, - ], - }, - ], - }; - - return { blocks: [block], measures: [measure], layout }; -} - -// --------------------------------------------------------------------------- -// Body/table-cell fixtures (for three-way comparison) -// --------------------------------------------------------------------------- - -function bodyFixtures(alignment: string) { - const halfLen = Math.floor(JUSTIFY_TEXT_LEN / 2); - const lines = [makeLine(0, halfLen), makeLine(halfLen, JUSTIFY_TEXT_LEN)]; - - const block: FlowBlock = { - kind: 'paragraph', - id: 'body-para-div', - runs: [{ text: JUSTIFY_TEXT, fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 1 + JUSTIFY_TEXT_LEN }], - attrs: { alignment }, - }; - - const measure: Measure = { - kind: 'paragraph', - lines, - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }; - - const layout: Layout = { - pageSize: { w: 600, h: 800 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'para', - blockId: 'body-para-div', - fromLine: 0, - toLine: lines.length, - x: 30, - y: 40, - width: FRAGMENT_WIDTH, - pmStart: 1, - pmEnd: 1 + JUSTIFY_TEXT_LEN, - }, - ], - }, - ], - }; - - return { blocks: [block], measures: [measure], layout }; -} - -function tableCellFixtures(alignment: string) { - const halfLen = Math.floor(JUSTIFY_TEXT_LEN / 2); - const lines = [makeLine(0, halfLen), makeLine(halfLen, JUSTIFY_TEXT_LEN)]; - - const block: FlowBlock = { - kind: 'table', - id: 'table-div', - rows: [ - { - id: 'row-0', - cells: [ - { - id: 'cell-0', - blocks: [ - { - kind: 'paragraph', - id: 'table-para-div', - runs: [ - { text: JUSTIFY_TEXT, fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 1 + JUSTIFY_TEXT_LEN }, - ], - attrs: { alignment }, - }, - ], - attrs: {}, - }, - ], - }, - ], - }; - - const measure: Measure = { - kind: 'table', - rows: [ - { - height: 40, - cells: [ - { - width: FRAGMENT_WIDTH, - height: 40, - gridColumnStart: 0, - blocks: [ - { - kind: 'paragraph', - lines, - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }, - ], - }, - ], - }, - ], - columnWidths: [FRAGMENT_WIDTH], - totalWidth: FRAGMENT_WIDTH, - totalHeight: 40, - }; - - const layout: Layout = { - pageSize: { w: 600, h: 800 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'table', - blockId: 'table-div', - fromRow: 0, - toRow: 1, - x: 30, - y: 40, - width: FRAGMENT_WIDTH, - height: 40, - }, - ], - }, - ], - }; - - return { blocks: [block], measures: [measure], layout }; -} - -function renderAndNormalize(fixtures: { blocks: FlowBlock[]; measures: Measure[]; layout: Layout }) { - const container = document.createElement('div'); - const painter = createDomPainter({ blocks: fixtures.blocks, measures: fixtures.measures }); - painter.paint(fixtures.layout, container); - return normalizeLines(container); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('known divergences (frozen — delete when resolved)', () => { - // ----------------------------------------------------------------------- - // Resolution target: PR 7 (collapse list-item parallel paint model) - // ----------------------------------------------------------------------- - - describe('list-item force-left alignment — Resolution target: PR 7 (collapse list-item parallel paint model)', () => { - it('list-item fragment forces textAlign to left even when paragraph alignment is justify', () => { - const container = document.createElement('div'); - const fix = listItemFixtures({ alignment: 'justify' }); - const painter = createDomPainter({ blocks: fix.blocks, measures: fix.measures }); - painter.paint(fix.layout, container); - - // The list-item content div overrides textAlign to 'left' - const contentEl = container.querySelector('.superdoc-list-content') as HTMLElement | null; - expect(contentEl).not.toBeNull(); - expect(contentEl!.style.textAlign).toBe('left'); - }); - - it('list-item fragment forces textAlign to left even when paragraph alignment is center', () => { - const container = document.createElement('div'); - const fix = listItemFixtures({ alignment: 'center' }); - const painter = createDomPainter({ blocks: fix.blocks, measures: fix.measures }); - painter.paint(fix.layout, container); - - const contentEl = container.querySelector('.superdoc-list-content') as HTMLElement | null; - expect(contentEl).not.toBeNull(); - expect(contentEl!.style.textAlign).toBe('left'); - }); - }); - - // ----------------------------------------------------------------------- - // Resolution target: PR 7 (collapse list-item parallel paint model) - // ----------------------------------------------------------------------- - - describe('list-item skip-justify on all lines — Resolution target: PR 7 (collapse list-item parallel paint model)', () => { - it('multi-line list-item produces zero wordSpacing on every line including non-last', () => { - // 2-line list item with alignment: 'justify' and multi-word text - const listLines = renderAndNormalize(listItemFixtures({ alignment: 'justify' })); - - expect(listLines.length).toBe(2); - // ALL lines have no word-spacing — body/table-cell would justify non-last lines - for (const line of listLines) { - expect(line.wordSpacing).toBe(''); - } - }); - }); - - // ----------------------------------------------------------------------- - // Resolution target: PR 5–7 (shared flow + list-item collapse) - // ----------------------------------------------------------------------- - - describe('three-way justify snapshot — Resolution target: PR 5–7 (shared flow + list-item collapse)', () => { - it('body and table-cell justify non-last lines; list-item does not', () => { - // Same logical paragraph (alignment: 'justify', 2 lines, multi-word text) - const bodyLines = renderAndNormalize(bodyFixtures('justify')); - const tableLines = renderAndNormalize(tableCellFixtures('justify')); - const listLines = renderAndNormalize(listItemFixtures({ alignment: 'justify' })); - - expect(bodyLines.length).toBe(2); - expect(tableLines.length).toBe(2); - expect(listLines.length).toBe(2); - - // Parity: body and table-cell line 0 wordSpacing should match and be > 0 - expect(bodyLines[0]!.wordSpacing).not.toBe(''); - expect(bodyLines[0]!.wordSpacing).toBe(tableLines[0]!.wordSpacing); - - // Divergence: list-item line 0 wordSpacing is empty (no justify) - expect(listLines[0]!.wordSpacing).toBe(''); - }); - }); -}); diff --git a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts index 21dea44686..5004f0adca 100644 --- a/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-position-mapping.test.ts @@ -23,17 +23,20 @@ const shiftByTwo = { }; describe('DomPainter.updatePositionAttributes', () => { - it('does not remap footnote fragments with body transaction mappings', () => { - const painter = new DomPainter(); - const { fragment, span } = makeFragment('footnote-1-abc', 2, 30); - - (painter as any).updatePositionAttributes(fragment, shiftByTwo); - - expect(fragment.dataset.pmStart).toBe('2'); - expect(fragment.dataset.pmEnd).toBe('30'); - expect(span.dataset.pmStart).toBe('2'); - expect(span.dataset.pmEnd).toBe('30'); - }); + it.each(['footnote-1-abc', 'endnote-1-abc', '__sd_semantic_footnote-1-abc', '__sd_semantic_endnote-1-abc'])( + 'does not remap %s fragments with body transaction mappings', + (blockId) => { + const painter = new DomPainter(); + const { fragment, span } = makeFragment(blockId, 2, 30); + + (painter as any).updatePositionAttributes(fragment, shiftByTwo); + + expect(fragment.dataset.pmStart).toBe('2'); + expect(fragment.dataset.pmEnd).toBe('30'); + expect(span.dataset.pmStart).toBe('2'); + expect(span.dataset.pmEnd).toBe('30'); + }, + ); it('still remaps body fragments when the mapping applies', () => { const painter = new DomPainter(); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f89b43b9d1..aed32b5e42 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1,98 +1,34 @@ import type { - ChartDrawing, ColumnLayout, - CustomGeometryData, - DrawingBlock, DrawingFragment, - DrawingGeometry, - DropCapDescriptor, - FieldAnnotationRun, - FlowBlock, FlowMode, - FlowRunLink, Fragment, - GradientFill, - ImageBlock, - ImageDrawing, ImageFragment, ImageHyperlink, - ImageRun, Line, - LineSegment, - ListBlock, - ListItemFragment, - ListMeasure, PageMargins, ParaFragment, - ParagraphAttrs, ParagraphBlock, - ParagraphMeasure, - PositionedDrawingGeometry, Run, - SdtMetadata, - ShapeGroupChild, - ShapeGroupDrawing, - ShapeTextContent, - SolidFillWithAlpha, SourceAnchor, - TableAttrs, - TableBlock, - TableCellAttrs, - TableFragment, - TableMeasure, - MathRun, - TextRun, - TrackedChangeKind, - TrackedChangesMode, - VectorShapeDrawing, - VectorShapeStyle, ResolvedLayout, ResolvedFragmentItem, ResolvedPage, ResolvedPaintItem, - ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, - ResolvedListMarkerItem, LayoutSourceIdentity, LayoutStoryLocator, } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, - adjustAvailableWidthForTextIndent, buildLayoutSourceIdentityForFragment, - calculateJustifySpacing, - computeLinePmRange, - expandRunsForInlineNewlines, - getCellSpacingPx, - getParagraphInlineDirection, normalizeColumnLayout, - normalizeBaselineShift, - resolveBaseFontSizeForVerticalText, - shouldApplyJustify, - sliceRunsForLine, - SPACE_CHARS, } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; -import { toCssFontFamily } from '@superdoc/font-utils'; -import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; -import { createChartElement as renderChartToElement } from './chart-renderer.js'; -import { - getRunBooleanProp, - getRunNumberProp, - getRunStringProp, - getRunUnderlineColor, - getRunUnderlineStyle, - hashCellBorders, - hashParagraphBorders, - hashTableBorders, -} from './paragraph-hash-utils.js'; -import { assertFragmentPmPositions, assertPmPositions } from './pm-position-validation.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { - BROWSER_DEFAULT_FONT_SIZE, CLASS_NAMES, containerStyles, containerStylesHorizontal, @@ -104,125 +40,41 @@ import { ensurePrintStyles, ensureSdtContainerStyles, ensureTrackChangeStyles, - fragmentStyles, - lineStyles, pageStyles, spreadStyles, type PageStyles, } from './styles.js'; -import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; -import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; -import { applyImageClipPath } from './utils/image-clip-path.js'; -import { - isMinimalWordLayout as isMinimalWordLayoutShared, - resolveMarkerIndent, -} from '@superdoc/common/list-marker-utils'; -import { - computeTabWidth, - createListMarkerElement, - resolvePainterListMarkerGeometry, - resolvePainterListTextStartPx, -} from './utils/marker-helpers.js'; -import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; -import { - computeBetweenBorderFlags, - createParagraphDecorationLayers, - stampBetweenBorderDataset, - type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; +import { renderResolvedTableFragment } from './table/renderResolvedTableFragment.js'; +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 { applySdtDataset } from './sdt/dataset.js'; +import { createInlineSdtWrapper } from './sdt/inline.js'; import { - applyRtlStyles, - shouldUseSegmentPositioning, - resolveRunDirectionAttribute, - normalizeRtlDateTokenForWordParity, -} from './features/inline-direction/index.js'; -import { convertOmmlToMathml } from './features/math/index.js'; - -/** - * Minimal type for WordParagraphLayoutOutput marker data used in rendering. - * Extracted to avoid dependency on @superdoc/word-layout package. - */ -type WordLayoutMarker = { - markerText?: string; - justification?: 'left' | 'right' | 'center'; - gutterWidthPx?: number; - markerBoxWidthPx?: number; - suffix?: 'tab' | 'space' | 'nothing'; - /** Pre-calculated X position where the marker should be placed (used in firstLineIndentMode). */ - markerX?: number; - /** Pre-calculated X position where paragraph text should begin after the marker (used in firstLineIndentMode). */ - textStartX?: number; - run: { - fontFamily: string; - fontSize: number; - bold?: boolean; - italic?: boolean; - color?: string; - letterSpacing?: number; - vanish?: boolean; - }; -}; - -/** - * Minimal type for wordLayout property used in this renderer. - * - * This is a subset of the full WordParagraphLayoutOutput type from @superdoc/word-layout. - * We extract only the fields needed for rendering to avoid a direct dependency on the - * word-layout package from the renderer. This allows the renderer to work with any object - * that provides these properties, maintaining loose coupling between packages. - * - * The wordLayout property is attached to ParagraphBlock.attrs during block processing - * and contains layout metadata needed for proper list marker and indent rendering. - * - * @property marker - Optional list marker layout containing text, styling, and positioning info - * @property indentLeftPx - Left indent in pixels (used for marker positioning calculations) - * @property firstLineIndentMode - When true, indicates the paragraph uses firstLine indent - * pattern (marker at left+firstLine) instead of standard hanging indent (marker at left-hanging). - * This flag changes how markers are positioned and how tab spacing is calculated. - * @property textStartPx - X position where paragraph text should begin (used for tab width calculation) - * @property tabsPx - Array of explicit tab stop positions in pixels - */ -type MinimalWordLayout = { - marker?: WordLayoutMarker; - indentLeftPx?: number; - /** True for firstLine indent pattern (marker at left+firstLine vs left-hanging). */ - firstLineIndentMode?: boolean; - /** X position where paragraph text should begin. */ - textStartPx?: number; - /** Array of explicit tab stop positions in pixels. */ - tabsPx?: number[]; -}; - -type LineEnd = { - type?: string; - width?: string; - length?: string; -}; - -type LineEnds = { - head?: LineEnd; - tail?: LineEnd; -}; - -type EffectExtent = { - left: number; - top: number; - right: number; - bottom: number; -}; - -type VectorShapeDrawingWithEffects = VectorShapeDrawing & { - lineEnds?: LineEnds; - effectExtent?: EffectExtent; -}; - -/** - * Type guard narrowing to the renderer-local MinimalWordLayout type. - * Delegates structural validation to the shared isMinimalWordLayout guard. - */ -function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { - return isMinimalWordLayoutShared(value); -} + collectSdtSnapshotEntitiesFromDomRoot, + type PaintSnapshotStructuredContentBlockEntity, + type PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; +import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; +import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; +import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; +import { renderLine as renderRunLine } from './runs/render-line.js'; +import type { RunRenderContext } from './runs/types.js'; +import { renderImageFragment as renderImageFragmentElement } from './images/image-fragment.js'; +import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; +import { applyStyles } from './utils/apply-styles.js'; +import type { FragmentRenderContext } from './fragment-context.js'; +import { applySourceAnchorDataset } from './utils/source-anchor.js'; +import { renderDrawingFragment as renderDrawingFragmentElement } from './drawings/renderDrawingFragment.js'; +import { isWordArtTextboxWatermarkBlock } from './textbox/wordArtWatermark.js'; +import { applyNoteStoryFrameAttributes } from './notes/frame.js'; +import { isNonBodyStoryBlockId } from './notes/story.js'; + +export type { + PaintSnapshotStructuredContentBlockEntity, + PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; /** * Layout mode for document rendering. @@ -347,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; @@ -414,24 +247,6 @@ export type PaintSnapshotAnnotationEntity = { layoutSourceIdentity?: LayoutSourceIdentity; }; -export type PaintSnapshotStructuredContentBlockEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; - layoutSourceIdentity?: LayoutSourceIdentity; -}; - -export type PaintSnapshotStructuredContentInlineEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; - layoutSourceIdentity?: LayoutSourceIdentity; -}; - export type PaintSnapshotImageEntity = { element: HTMLElement; pageIndex: number; @@ -500,6 +315,8 @@ type PaintSnapshotCaptureOptions = { sourceAnchor?: SourceAnchor; }; +type ResolvedTablePaintItem = Extract; + function roundSnapshotMetric(value: number): number | null { if (!Number.isFinite(value)) return null; return Math.round(value * 1000) / 1000; @@ -594,31 +411,6 @@ const resolveDecorationStory = (kind: 'header' | 'footer', data: PageDecorationP return typeof id === 'string' && id.length > 0 ? { kind, id } : { kind }; }; -export function applySourceAnchorDataset(element: HTMLElement, sourceAnchor?: SourceAnchor): void { - if (!sourceAnchor) { - delete element.dataset.sourceAnchor; - delete element.dataset.sourceNodeId; - delete element.dataset.sourceOccurrenceId; - return; - } - - try { - element.dataset.sourceAnchor = JSON.stringify(sourceAnchor); - } catch { - delete element.dataset.sourceAnchor; - } - if (sourceAnchor.sourceNodeId) { - element.dataset.sourceNodeId = sourceAnchor.sourceNodeId; - } else { - delete element.dataset.sourceNodeId; - } - if (sourceAnchor.occurrenceId) { - element.dataset.sourceOccurrenceId = sourceAnchor.occurrenceId; - } else { - delete element.dataset.sourceOccurrenceId; - } -} - function readSourceAnchorDataset(element: HTMLElement | null | undefined): SourceAnchor | undefined { if (!element) return undefined; const encoded = element.dataset?.sourceAnchor; @@ -708,45 +500,14 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap ); } - const blockSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`), - ); - for (const element of blockSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentBlocks.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - layoutSourceIdentity: readNearestLayoutSourceIdentity(element), - }) as PaintSnapshotStructuredContentBlockEntity, - ); - } - - const inlineSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}[data-sdt-id]`), - ); - for (const element of inlineSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentInlines.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - layoutSourceIdentity: readNearestLayoutSourceIdentity(element), - }) as PaintSnapshotStructuredContentInlineEntity, - ); - } + const sdtEntities = collectSdtSnapshotEntitiesFromDomRoot(rootEl, { + resolvePageIndex: resolveSnapshotPageIndex, + readDatasetNumber: readSnapshotDatasetNumber, + readLayoutSourceIdentity: readNearestLayoutSourceIdentity, + compactObject: compactSnapshotObject, + }); + entities.structuredContentBlocks.push(...sdtEntities.structuredContentBlocks); + entities.structuredContentInlines.push(...sdtEntities.structuredContentInlines); const inlineImageElements = Array.from( rootEl.querySelectorAll( @@ -890,7 +651,6 @@ function collectLineTabsForSnapshot(lineEl: HTMLElement): PaintSnapshotTabStyle[ return tabs; } -const LIST_MARKER_GAP = 8; /** * Default page height in pixels (11 inches at 96 DPI). * Used as a fallback when page size information is not available for ruler rendering. @@ -900,472 +660,18 @@ const DEFAULT_PAGE_HEIGHT_PX = 1056; const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; // Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). -type LinkRenderData = { - href?: string; - target?: string; - rel?: string; - tooltip?: string | null; - dataset?: Record; - blocked: boolean; -}; - -const LINK_DATASET_KEYS = { - blocked: 'linkBlocked', - docLocation: 'linkDocLocation', - history: 'linkHistory', - rId: 'linkRid', - truncated: 'linkTooltipTruncated', -} as const; - -const MAX_HREF_LENGTH = 2048; - -const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; - -/** - * Maximum allowed length for data URLs (10MB). - * Prevents denial of service attacks from extremely large embedded images. - */ -const MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; // 10MB - -/** - * Regular expression to validate data URL format for images. - * Only allows common, safe image MIME types with base64 encoding. - * Prevents XSS and malformed data URL attacks. - */ -const VALID_IMAGE_DATA_URL = /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp|bmp|ico|tiff?);base64,/i; -const SVG_NS = 'http://www.w3.org/2000/svg'; -const WORDART_LINE_FILL_RATIO = 0.9; - -/** - * Maximum resize multiplier for image metadata. - * Images can be resized up to 3x their original dimensions. - */ -const MAX_RESIZE_MULTIPLIER = 3; - -/** - * Fallback maximum dimension for image resizing when original size is small. - * Ensures images can be resized to at least 1000px even if original is smaller. - */ -const FALLBACK_MAX_DIMENSION = 1000; - -/** - * Minimum image dimension in pixels. - * Ensures images remain visible and interactive during resizing. - */ -const MIN_IMAGE_DIMENSION = 20; - -/** - * Pattern to detect ambiguous link text that doesn't convey destination (WCAG 2.4.4). - * Matches common generic phrases like "click here", "read more", etc. - */ -const AMBIGUOUS_LINK_PATTERNS = /^(click here|read more|more|link|here|this|download|view)$/i; - -/** - * Hyperlink rendering metrics for observability. - * Tracks sanitization, blocking, and security-related events. - */ -const linkMetrics = { - sanitized: 0, - blocked: 0, - invalidProtocol: 0, - homographWarnings: 0, - - reset() { - this.sanitized = 0; - this.blocked = 0; - this.invalidProtocol = 0; - this.homographWarnings = 0; - }, - - getMetrics() { - return { - 'hyperlink.sanitized.count': this.sanitized, - 'hyperlink.blocked.count': this.blocked, - 'hyperlink.invalid_protocol.count': this.invalidProtocol, - 'hyperlink.homograph_warnings.count': this.homographWarnings, - }; - }, -}; - -// Export for testing/monitoring -export { linkMetrics }; - -const TRACK_CHANGE_BASE_CLASS: Record = { - insert: 'track-insert-dec', - delete: 'track-delete-dec', - format: 'track-format-dec', -}; -// TRACK_CHANGE_FOCUSED_CLASS moved to CommentHighlightDecorator (super-editor). - -const TRACK_CHANGE_MODIFIER_CLASS: Record> = { - insert: { - review: 'highlighted', - original: 'hidden', - final: 'normal', - off: undefined, - }, - delete: { - review: 'highlighted', - original: 'normal', - final: 'hidden', - off: undefined, - }, - format: { - review: 'highlighted', - original: 'before', - final: 'normal', - off: undefined, - }, -}; - -type TrackedChangesRenderConfig = { - mode: TrackedChangesMode; - enabled: boolean; -}; - -/** - * Sanitize a URL to prevent XSS attacks. - * Only allows http, https, mailto, tel, and internal anchors. - * - * @param href - The URL to sanitize - * @returns Sanitized URL or null if blocked - */ -export function sanitizeUrl(href: string): string | null { - if (typeof href !== 'string') return null; - const sanitized = sanitizeHref(href); - return sanitized?.href ?? null; -} - -const LINK_TARGET_SET = new Set(['_blank', '_self', '_parent', '_top']); - -/** - * Normalize and validate an anchor fragment identifier for use in hyperlinks. - * Strips leading '#' if present and validates against safe character pattern. - * - * @param value - Raw anchor string (with or without leading '#') - * @returns Normalized anchor with leading '#' (e.g., '#section-1'), or null if invalid - * - * @remarks - * SECURITY: Only allows safe characters (A-Z, a-z, 0-9, ., _, -) to prevent HTML attribute injection. - * Rejects characters like quotes, angle brackets, colons, and spaces that could break HTML structure - * or enable XSS attacks when used in href attributes. - * - * @example - * normalizeAnchor('section-1') // Returns: '#section-1' - * normalizeAnchor('#bookmark') // Returns: '#bookmark' - * normalizeAnchor('unsafe