diff --git a/packages/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 3d6a3a73de..be5eb5cf0f 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -59,6 +59,40 @@ reads. 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. +## Measuring → Layout Ownership (SD-2845) + +Boundary between measured block geometry (`Measure[]`) and pagination/placement (`Layout`). See contract tests: +`measuring/dom/src/measuring-layout-contracts.test.ts` (`FlowBlock` → `Measure`) and +`layout-engine/src/measuring-layout-ownership-contracts.test.ts` (`FlowBlock` + `Measure` → `Layout`). +Further work is tracked in Linear (e.g. SD-2837, SD-2845). + +### Boundary Contract + +| Stage | Owns | Does not own | +| --- | --- | --- | +| pm-adapter | Builds `FlowBlock[]` from document state, preserves raw/source metadata, and resolves style-engine outputs into block attributes needed by measuring and rendering. | Measuring line breaks, table row heights, pagination, fragment placement, or painter-specific DOM decisions. | +| Measuring | Converts each `FlowBlock` into a same-index `Measure` for a known width/height constraint. It owns intrinsic/scaled dimensions, line breaks, line metrics, marker metrics, table cell content measurement, table columns where sizing is measurement-dependent, and zero-dimensional break measures. | Page/column placement, section scheduling, page creation, keep-next decisions, painter DOM structure, or reordering blocks. | +| Layout | Consumes `FlowBlock[]` plus same-index `Measure[]` and creates positioned `Layout` fragments. It owns pagination, section/page/column break effects, anchoring placement, float exclusions, fragment splitting, page metadata, and final page/column coordinates. | Canvas/DOM text measurement, intrinsic media measurement, table cell content measurement, or importing/resolving OOXML style cascades. | +| layout-bridge | Orchestrates conversion, constraint selection, measurement calls, cache reuse/invalidation, header/footer and footnote multi-pass layout, and calls into layout. | Reimplementing measurement or layout decisions except for explicit bridge-only orchestration needed to choose constraints or rerun a pass. | + +### Ownership Matrix + +| Area | Measuring should produce | Layout should consume or decide | layout-bridge may orchestrate | Current duplicated or unclear logic | +| --- | --- | --- | --- | --- | +| Paragraphs | `ParagraphMeasure` with line ranges, line widths, line heights, total height, marker metrics, optional drop-cap metrics, tab/segment metadata, and line max widths for the constraint used. | Place paragraph fragments into pages/columns, split by measured lines, apply spacing/keep-next/contextual spacing, apply float exclusions, and set continuation flags. | Select per-section measurement constraints, cache/reuse paragraph measures, invalidate dirty measures, and provide controlled remeasure callbacks when layout must place text in a narrower active region. | Layout still accepts `remeasureParagraph` and can attach fragment-local `lines`, so paragraph wrapping ownership is split between measuring and layout. Keep-next also reads measured heights directly from layout. | +| Lists and list items | `ListMeasure` with per-item marker width, marker text width, indent, nested `ParagraphMeasure`, and total list height. | Place each list item fragment, split item paragraph lines across pages/columns, preserve marker metrics on fragments, and apply continuation flags. | Convert list-style paragraphs into either paragraph blocks with marker attrs or list blocks consistently; measure/remeasure each item under the chosen list item constraint. | Measuring has `ListMeasure`, but `layoutDocument` currently has no `ListBlock` layout branch and an existing list layout test is skipped. Paragraphs may also carry word-layout marker data, creating two list paths. | +| Tables | `TableMeasure` with row/cell measures, total width/height, column widths, cell spacing, table border widths, row heights, and nested cell `Measure[]` for multi-block cells. | Place table fragments, split rows/partial rows, repeat headers, clamp/rescale fragment column widths when needed, position anchored/floating tables, and emit table metadata for resize boundaries. | Measure tables after selecting page/column constraints; remeasure tables in headers/footers/footnotes as needed; cache and invalidate table measures with block identity. | Table sizing logic is spread across measuring (`autofit-columns`, `fixed-table-columns`, nested cell measurement), contracts (`rescaleColumnWidths`), and layout (`layout-table`, frame/clamp logic). Some width decisions are measurement-owned while fragment clamping is layout-owned. | +| Table rows | Per-row height derived from measured cells, including row height rule effects where available, repeat-header metadata passed through block attrs. | Decide which rows fit, whether a row becomes a partial row, repeat header rows on continuation fragments, and continuation metadata. | Ensure table measures are recomputed when row content changes or available measurement width changes. | Row splitting depends on measured row/cell heights but row keep/cantSplit semantics cross table measurement and layout. | +| Table cells and nested cell content | Per-cell width/height, padding-aware content width, `blocks?: Measure[]` for nested paragraphs/images/drawings/tables, legacy `paragraph?: ParagraphMeasure`, spans, and grid column start. | Slice cell content into visible fragments for table pagination, maintain row/column boundaries, and map cell content to fragment geometry. | Recurse into measurement for each nested cell block with the cell content width; choose when nested content must be remeasured. | Nested content is measured recursively, while layout also has table cell slicing logic that interprets nested measures and block shapes. | +| Images | `ImageMeasure` with final width/height after intrinsic fallback, width/height constraints, objectFit/cover handling, and anchored negative-offset height bypass. | Place inline or anchored image fragments, compute x/y from page/column/margin anchors, set metadata and z-index, reserve flow space only when appropriate. | Provide max width/height based on page, header/footer, footnote, or table-cell context; hydrate image blocks before measuring when needed. | Scaling is measurement-owned, but layout computes placement metadata such as maxWidth/maxHeight and also has anchored/page-relative special handling. | +| Drawings | `DrawingMeasure` with drawing kind, final width/height, scale, natural size, normalized geometry, and group transform when present. | Place drawing fragments, compute anchoring and z-index, carry geometry/scale into fragments, and manage float exclusions. | Provide constraints and trigger remeasurement when drawing geometry or context changes. | Measuring handles rotation bounds/full-width shape sizing; layout handles anchored placement and pre-registration. Shape group/text content sizing boundaries need clearer documentation. | +| Section breaks | `SectionBreakMeasure` as a zero-dimensional control measure. | Apply section scheduling, page parity, page size/orientation/margin/column changes, section refs, numbering, vertical alignment, and column regions. | Preserve block order, pass break blocks through, compute per-section constraints for actual measurement, and use global-max constraints only as a compatibility check when deciding whether previous measures can be reused. | Section props are partly precomputed/looked ahead in layout and partly carried on break blocks; bridge computes both per-section constraints and a global-max compatibility constraint set from section blocks. | +| Page breaks | `PageBreakMeasure` as a zero-dimensional control measure. | Start a new page unless redundant, without producing a fragment. | Preserve the break in block/measure alignment and cache invalidation. | Page-break redundancy checks are layout-owned, but empty sectPr marker handling can interact with adjacent paragraph/break blocks. | +| Column breaks | `ColumnBreakMeasure` as a zero-dimensional control measure. | Advance to the next active column or start a new page from the last column, without producing a fragment. | Preserve alignment and recompute measurement constraints when section columns change. | Blocks are measured with per-section constraints, but layout can still trigger narrower active-region paragraph remeasurement, so wrapping ownership is still split. | +| Headers and footers | Measures for header/footer story blocks under header/footer-specific constraints; measured heights for variants, rIds, and section-aware references. | Lay out header/footer fragments per page/variant, apply header/footer heights to body page margins, and normalize fragments for render regions. | Own multi-pass header/footer measurement/layout orchestration, token resolution, variant bucketing, and cache invalidation. | Bridge has substantial header/footer orchestration; layout also consumes per-page/per-rId height maps and section refs. The height ownership boundary is functional but hard to reason about. | +| Footnotes | Measures for footnote story blocks under footnote band constraints, including nested content measures. | Reserve footnote space on body pages, place footnote fragments in footnote bands, and handle overflow across pages/columns. | Own multi-pass footnote measurement/layout, separator spacing, band overflow retries, and cache invalidation. | Footnote layout is bridge-heavy and interacts with body pagination through reserved space; ownership between reserve calculation and final placement should be explicit. | +| Nested measured content | Recursive `Measure[]` for nested blocks using the current container's content width and height rules. | Interpret nested measures only through the container layout algorithm, without remeasuring nested content directly. | Supply container constraints and invalidate nested measures when the parent container changes width or content. | Tables already recurse in measuring; future containers may duplicate this unless the recursion contract is centralized. | + ## Style Engine (`style-engine/`) Single source of truth for OOXML style cascade resolution. All property resolution flows through here. diff --git a/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts b/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts new file mode 100644 index 0000000000..7d00655a8c --- /dev/null +++ b/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import type { + ColumnBreakBlock, + DrawingBlock, + DrawingMeasure, + FlowBlock, + ImageBlock, + ImageMeasure, + Line, + Measure, + PageBreakBlock, + ParagraphMeasure, + SectionBreakBlock, + TableBlock, + TableMeasure, +} from '@superdoc/contracts'; +import { layoutDocument, type LayoutOptions } from './index.js'; + +const DEFAULT_OPTIONS: LayoutOptions = { + pageSize: { w: 500, h: 500 }, + margins: { top: 50, right: 50, bottom: 50, left: 50 }, +}; + +const line = (lineHeight: number, width = 100): Line => ({ + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width, + ascent: lineHeight * 0.8, + descent: lineHeight * 0.2, + lineHeight, + maxWidth: width, +}); + +const paragraphMeasure = (heights: number[]): ParagraphMeasure => ({ + kind: 'paragraph', + lines: heights.map((height) => line(height)), + totalHeight: heights.reduce((sum, height) => sum + height, 0), +}); + +const paragraphBlock = (id: string): FlowBlock => ({ + kind: 'paragraph', + id, + runs: [], +}); + +const tableBlock = (id: string): TableBlock => ({ + kind: 'table', + id, + rows: [ + { + id: `${id}-row-1`, + cells: [ + { + id: `${id}-cell-1`, + paragraph: { + kind: 'paragraph', + id: `${id}-cell-paragraph`, + runs: [], + }, + }, + ], + }, + ], +}); + +const tableMeasure = (width: number, height: number): TableMeasure => ({ + kind: 'table', + columnWidths: [width], + rows: [ + { + height, + cells: [ + { + width, + height, + paragraph: paragraphMeasure([height]), + }, + ], + }, + ], + totalWidth: width, + totalHeight: height, +}); + +describe('Measuring to Layout ownership contracts', () => { + it('consumes paragraph measure lines for fragment line ranges and pagination', () => { + const layout = layoutDocument([paragraphBlock('paragraph-contract')], [paragraphMeasure([120, 120, 120, 120])], { + pageSize: { w: 400, h: 300 }, + margins: { top: 30, right: 30, bottom: 30, left: 30 }, + }); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'para', + blockId: 'paragraph-contract', + fromLine: 0, + toLine: 2, + continuesOnNext: true, + }); + expect(layout.pages[1].fragments[0]).toMatchObject({ + kind: 'para', + blockId: 'paragraph-contract', + fromLine: 2, + toLine: 4, + continuesFromPrev: true, + }); + }); + + it('uses image measures, not block dimensions, for image fragment size', () => { + const block: ImageBlock = { + kind: 'image', + id: 'image-contract', + src: 'image.png', + width: 999, + height: 999, + }; + const measure: ImageMeasure = { kind: 'image', width: 120, height: 80 }; + + const layout = layoutDocument([block], [measure], DEFAULT_OPTIONS); + + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'image', + blockId: 'image-contract', + width: 120, + height: 80, + }); + }); + + it('uses drawing measures for drawing fragment geometry and size', () => { + const block: DrawingBlock = { + kind: 'drawing', + id: 'drawing-contract', + drawingKind: 'vectorShape', + geometry: { width: 999, height: 999 }, + }; + const measure: DrawingMeasure = { + kind: 'drawing', + drawingKind: 'vectorShape', + width: 140, + height: 70, + scale: 0.5, + naturalWidth: 280, + naturalHeight: 140, + geometry: { width: 280, height: 140 }, + }; + + const layout = layoutDocument([block], [measure], DEFAULT_OPTIONS); + + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'drawing', + blockId: 'drawing-contract', + drawingKind: 'vectorShape', + width: 140, + height: 70, + scale: 0.5, + geometry: { width: 280, height: 140 }, + }); + }); + + it('uses table measures for table fragment dimensions and row range', () => { + const block = tableBlock('table-contract'); + const measure = tableMeasure(180, 40); + + const layout = layoutDocument([block], [measure], DEFAULT_OPTIONS); + + expect(layout.pages[0].fragments[0]).toMatchObject({ + kind: 'table', + blockId: 'table-contract', + fromRow: 0, + toRow: 1, + width: 180, + height: 40, + }); + }); + + it('consumes page and column break measures as layout control flow without fragments', () => { + const pageBreak: PageBreakBlock = { kind: 'pageBreak', id: 'page-break' }; + const columnBreak: ColumnBreakBlock = { kind: 'columnBreak', id: 'column-break' }; + const blocks: FlowBlock[] = [ + paragraphBlock('p1'), + columnBreak, + paragraphBlock('p2'), + pageBreak, + paragraphBlock('p3'), + ]; + const measures: Measure[] = [ + paragraphMeasure([20]), + { kind: 'columnBreak' }, + paragraphMeasure([20]), + { kind: 'pageBreak' }, + paragraphMeasure([20]), + ]; + + const layout = layoutDocument(blocks, measures, { + ...DEFAULT_OPTIONS, + columns: { count: 2, gap: 20 }, + }); + + expect(layout.pages).toHaveLength(2); + expect(layout.pages[0].fragments.map((fragment) => fragment.blockId)).toEqual(['p1', 'p2']); + expect(layout.pages[1].fragments.map((fragment) => fragment.blockId)).toEqual(['p3']); + expect(layout.pages[0].fragments[1].x).toBeGreaterThan(layout.pages[0].fragments[0].x); + }); + + it('consumes section break measures as layout control flow without fragments', () => { + const firstSection: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb-first', + attrs: { isFirstSection: true }, + margins: { top: 50, right: 50, bottom: 50, left: 50 }, + }; + const nextPageSection: SectionBreakBlock = { + kind: 'sectionBreak', + id: 'sb-next', + type: 'nextPage', + margins: { top: 50, right: 50, bottom: 50, left: 50 }, + }; + const blocks: FlowBlock[] = [firstSection, paragraphBlock('p1'), nextPageSection, paragraphBlock('p2')]; + const measures: Measure[] = [ + { kind: 'sectionBreak' }, + paragraphMeasure([20]), + { kind: 'sectionBreak' }, + paragraphMeasure([20]), + ]; + + const layout = layoutDocument(blocks, measures, DEFAULT_OPTIONS); + + expect(layout.pages.length).toBeGreaterThanOrEqual(2); + const allBlockIds = layout.pages.flatMap((p) => p.fragments.map((f) => f.blockId)); + expect(allBlockIds).toEqual(['p1', 'p2']); + expect(allBlockIds).not.toContain('sb-first'); + expect(allBlockIds).not.toContain('sb-next'); + expect(layout.pages[0].fragments[0]).toMatchObject({ kind: 'para', blockId: 'p1' }); + expect(layout.pages[1].fragments[0]).toMatchObject({ kind: 'para', blockId: 'p2' }); + }); + + it('fails fast for mismatched FlowBlock and Measure kinds', () => { + expect(() => + layoutDocument([paragraphBlock('paragraph-contract')], [{ kind: 'pageBreak' }], DEFAULT_OPTIONS), + ).toThrow(/expected paragraph measure/); + }); + + // Today layoutDocument throws for ListBlock; when list layout lands, implement real + // assertions (e.g. list-item fragments, marker metrics) and drop this todo. + it.todo('consumes ListBlock + ListMeasure in layoutDocument (list-item fragments, marker widths, pagination)'); +}); diff --git a/packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts b/packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts new file mode 100644 index 0000000000..90238635ea --- /dev/null +++ b/packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { measureBlock } from './index.js'; +import type { DrawingBlock, FlowBlock, ListBlock, Measure, TableBlock } from '@superdoc/contracts'; + +const textRun = (text: string, fontSize = 16) => ({ + kind: 'text' as const, + text, + fontFamily: 'Arial', + fontSize, +}); + +const expectMeasureKind = ( + measure: Measure, + kind: TKind, +): Extract => { + expect(measure.kind).toBe(kind); + return measure as Extract; +}; + +describe('Measuring to Layout contract', () => { + it('produces paragraph line geometry and total height for layout', async () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'paragraph-contract', + runs: [textRun('SuperDoc wraps this paragraph into measured lines.')], + }; + + const measure = expectMeasureKind(await measureBlock(block, 120), 'paragraph'); + + expect(measure.lines.length).toBeGreaterThan(0); + expect(measure.totalHeight).toBe(measure.lines.reduce((sum, line) => sum + line.lineHeight, 0)); + for (const line of measure.lines) { + expect(line.width).toBeGreaterThanOrEqual(0); + expect(line.lineHeight).toBeGreaterThan(0); + expect(line.maxWidth).toBeGreaterThan(0); + } + }); + + it('produces list item marker metrics and nested paragraph measures', async () => { + const block: ListBlock = { + kind: 'list', + id: 'list-contract', + listType: 'number', + items: [ + { + id: 'item-1', + marker: { kind: 'number', text: '1.', level: 0, order: 1 }, + paragraph: { + kind: 'paragraph', + id: 'item-1-paragraph', + runs: [textRun('A list item paragraph measured under the item content width.')], + attrs: { indent: { left: 24, hanging: 18 } }, + }, + }, + ], + }; + + const measure = expectMeasureKind(await measureBlock(block, 220), 'list'); + + expect(measure.items).toHaveLength(1); + expect(measure.items[0]).toMatchObject({ + itemId: 'item-1', + indentLeft: 24, + paragraph: { kind: 'paragraph' }, + }); + expect(measure.items[0].markerTextWidth).toBeGreaterThan(0); + expect(measure.items[0].markerWidth).toBeGreaterThanOrEqual(measure.items[0].markerTextWidth); + expect(measure.totalHeight).toBe(measure.items[0].paragraph.totalHeight); + }); + + it('produces table row, cell, column, and nested content measures', async () => { + const block: TableBlock = { + kind: 'table', + id: 'table-contract', + columnWidths: [120], + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [ + { + kind: 'paragraph', + id: 'cell-paragraph', + runs: [textRun('Nested cell paragraph')], + }, + { + kind: 'image', + id: 'cell-image', + src: 'image.png', + width: 40, + height: 20, + }, + ], + }, + ], + }, + ], + }; + + const measure = expectMeasureKind(await measureBlock(block, 240), 'table'); + + expect(measure.columnWidths).toHaveLength(1); + expect(measure.totalWidth).toBeGreaterThan(0); + expect(measure.totalHeight).toBeGreaterThan(0); + expect(measure.rows).toHaveLength(1); + expect(measure.rows[0].cells).toHaveLength(1); + expect(measure.rows[0].cells[0].blocks?.map((nested) => nested.kind)).toEqual(['paragraph', 'image']); + }); + + it('produces final image dimensions after measurement constraints', async () => { + const block: FlowBlock = { + kind: 'image', + id: 'image-contract', + src: 'image.png', + width: 400, + height: 200, + }; + + const measure = expectMeasureKind(await measureBlock(block, { maxWidth: 100, maxHeight: 80 }), 'image'); + + expect(measure.width).toBe(100); + expect(measure.height).toBe(50); + }); + + it('produces drawing geometry, scale, and natural size for layout', async () => { + const block: DrawingBlock = { + kind: 'drawing', + id: 'drawing-contract', + drawingKind: 'vectorShape', + geometry: { width: 200, height: 100 }, + }; + + const measure = expectMeasureKind(await measureBlock(block, { maxWidth: 100, maxHeight: 100 }), 'drawing'); + + expect(measure).toMatchObject({ + drawingKind: 'vectorShape', + width: 100, + height: 50, + scale: 0.5, + naturalWidth: 200, + naturalHeight: 100, + geometry: { width: 200, height: 100 }, + }); + }); + + it('produces zero-dimensional control measures for break blocks', async () => { + await expect(measureBlock({ kind: 'sectionBreak', id: 'section-break', margins: {} }, 500)).resolves.toEqual({ + kind: 'sectionBreak', + }); + await expect(measureBlock({ kind: 'pageBreak', id: 'page-break' }, 500)).resolves.toEqual({ kind: 'pageBreak' }); + await expect(measureBlock({ kind: 'columnBreak', id: 'column-break' }, 500)).resolves.toEqual({ + kind: 'columnBreak', + }); + }); +});