From b28d284a41322ef699cfef6b73070115b2873119 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 5 May 2026 17:01:08 +0300 Subject: [PATCH] chore: create measurement to layout ownership matrix --- .../docs/measuring-layout-ownership.md | 49 ++++ ...asuring-layout-ownership-contracts.test.ts | 243 ++++++++++++++++++ .../src/measuring-layout-contracts.test.ts | 157 +++++++++++ 3 files changed, 449 insertions(+) create mode 100644 packages/layout-engine/docs/measuring-layout-ownership.md create mode 100644 packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts create mode 100644 packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts diff --git a/packages/layout-engine/docs/measuring-layout-ownership.md b/packages/layout-engine/docs/measuring-layout-ownership.md new file mode 100644 index 0000000000..d039423e7e --- /dev/null +++ b/packages/layout-engine/docs/measuring-layout-ownership.md @@ -0,0 +1,49 @@ +# Measuring to Layout Ownership Matrix + +This document describes the current contract and the follow-up work needed where ownership is +duplicated, unclear, or incomplete. + +## 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 | Follow-up ticket proposal | +| --- | --- | --- | --- | --- | --- | +| 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. | Remove duplicate paragraph remeasurement ownership from layout-bridge/layout and replace it with a single constraint-aware paragraph measurement contract. Related: SD-2837. | +| 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. | Add first-class `ListBlock` consumption in layout or collapse list blocks into paragraph marker contracts before layout; unskip/replace the skipped list layout test. | +| 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. | Document and enforce table width phases: intrinsic/autofit/fixed sizing in measuring, section clamp/rescale in layout, and no table sizing in layout-bridge. | +| 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. | Add contract tests for `cantSplit`, exact/atLeast row heights, and repeated header rows at the Measure-to-Layout boundary. | +| 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. | Add an explicit nested-cell-content contract doc/test for how `TableCellMeasure.blocks` maps to table fragment rendering and partial row slicing. | +| 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. | Split image sizing vs placement metadata explicitly: measuring owns final dimensions; layout owns placement metadata only. Add tests for page-relative anchor dimensions vs placement. | +| 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. | Add drawing-specific contract coverage for vector shapes, image drawings, shape groups, and charts, especially rotation/full-width behavior. | +| 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. | Add a section-boundary contract that separates measurement constraint discovery from layout section scheduling and page metadata. | +| 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. | Add contract tests for page-break plus empty section marker paragraphs so bridge/import assumptions stay aligned with layout. | +| 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. | Tie column-aware measurement to the paragraph remeasurement follow-up so text wrapping does not have two owners. | +| 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. | Document header/footer height inputs as a bridge-to-layout contract and add tests that a height map changes body margins without remeasuring body blocks. | +| 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. | Add a footnote band contract describing which pass owns body shrinkage, separator spacing, overflow, and final page assignment. | +| 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. | Create a shared nested-measure contract for table cells, future containers, headers/footers, footnotes, and SDT block containers. | + +## Contract Tests Added + +- `packages/layout-engine/measuring/dom/src/measuring-layout-contracts.test.ts` + covers representative `FlowBlock -> Measure` outputs for paragraphs, lists, tables, images, drawings, and break blocks. +- `packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts` + covers representative `FlowBlock + Measure -> Layout` consumption for paragraphs, media, drawings, tables, page/column breaks, and the current list handoff gap. + +## Ticket-ready Follow-ups + +1. Remove duplicate paragraph remeasurement ownership from layout/layout-bridge and make paragraph measurement constraint-aware. +2. Add first-class `ListBlock` consumption in layout or remove `ListBlock` from the layout boundary in favor of paragraph marker attrs. +3. Split table width ownership into measurement-owned intrinsic/autofit/fixed sizing and layout-owned section clamp/rescale. +4. Define the nested cell content contract from `TableCellMeasure.blocks` to partial row/table fragment rendering. +5. Add section-boundary tests that separate bridge measurement constraint discovery from layout section scheduling. +6. Define header/footer height maps as the bridge-to-layout contract and test body margin inflation from those maps. +7. Define footnote multi-pass ownership for body shrinkage, separator spacing, overflow, and final page assignment. +8. Add drawing subtype contract tests for vector shapes, image drawings, shape groups, and charts. 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..1d8aa1cebe --- /dev/null +++ b/packages/layout-engine/layout-engine/src/measuring-layout-ownership-contracts.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; +import type { + ColumnBreakBlock, + DrawingBlock, + DrawingMeasure, + FlowBlock, + ImageBlock, + ImageMeasure, + Line, + ListBlock, + ListMeasure, + Measure, + PageBreakBlock, + ParagraphMeasure, + 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('fails fast for mismatched FlowBlock and Measure kinds', () => { + expect(() => + layoutDocument([paragraphBlock('paragraph-contract')], [{ kind: 'pageBreak' }], DEFAULT_OPTIONS), + ).toThrow(/expected paragraph measure/); + }); + + it('documents the current ListBlock handoff gap until layout consumes ListMeasure', () => { + const block: ListBlock = { + kind: 'list', + id: 'list-contract', + listType: 'number', + items: [ + { + id: 'list-item-1', + marker: { kind: 'number', text: '1.', level: 0, order: 1 }, + paragraph: { kind: 'paragraph', id: 'list-item-1-paragraph', runs: [] }, + }, + ], + }; + const measure: ListMeasure = { + kind: 'list', + items: [ + { + itemId: 'list-item-1', + markerWidth: 20, + markerTextWidth: 10, + indentLeft: 24, + paragraph: paragraphMeasure([20]), + }, + ], + totalHeight: 20, + }; + + expect(() => layoutDocument([block], [measure], DEFAULT_OPTIONS)).toThrow(/unsupported block kind/); + }); +}); 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', + }); + }); +});