Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/layout-engine/docs/measuring-layout-ownership.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading