Skip to content
Merged
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
4 changes: 2 additions & 2 deletions devtools/visual-testing/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ export {
} from './vertical-text.js';

export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';

// Editor-neutral layout identity primitives (prep-001).
// Additive only — `pmStart`/`pmEnd` and PM-shaped fields remain available
// alongside these on every fragment/run.
export {
LAYOUT_BOUNDARY_SCHEMA,
bodyStoryLocator,
namedStoryLocator,
computeLayoutFragmentId,
buildLayoutSourceIdentity,
buildLayoutSourceIdentityForFragment,
} from './layout-identity.js';
export type {
LayoutBlockRef,
LayoutFragmentId,
LayoutPartialRowIdentity,
LayoutSourceIdentity,
LayoutStoryKind,
LayoutStoryLocator,
} from './layout-identity.js';
import type { LayoutSourceIdentity } from './layout-identity.js';
export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js';
export type { NormalizedColumnLayout } from './column-layout.js';
/** Inline field annotation metadata extracted from w:sdt nodes. */
Expand Down Expand Up @@ -1937,6 +1958,15 @@ export type ParaFragment = {
pmStart?: number;
pmEnd?: number;
sourceAnchor?: SourceAnchor;
/**
* Optional editor-neutral identity for this fragment.
*
* Additive (prep-001). PM-facing `pmStart`/`pmEnd` and `blockId` remain
* authoritative for v1 consumers; this field exists so downstream surfaces
* can address rendered output without requiring `pmStart`/`pmEnd`. See
* `layout-identity.ts`.
*/
layoutSourceIdentity?: LayoutSourceIdentity;
};

export type TableColumnBoundary = {
Expand Down Expand Up @@ -2003,6 +2033,8 @@ export type TableFragment = {
* When set, the renderer uses these instead of measure.columnWidths. */
columnWidths?: number[];
sourceAnchor?: SourceAnchor;
/** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */
layoutSourceIdentity?: LayoutSourceIdentity;
};

export type ImageFragment = {
Expand All @@ -2019,6 +2051,8 @@ export type ImageFragment = {
pmEnd?: number;
metadata?: ImageFragmentMetadata;
sourceAnchor?: SourceAnchor;
/** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */
layoutSourceIdentity?: LayoutSourceIdentity;
};

export type DrawingFragment = {
Expand All @@ -2038,6 +2072,8 @@ export type DrawingFragment = {
pmStart?: number;
pmEnd?: number;
sourceAnchor?: SourceAnchor;
/** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */
layoutSourceIdentity?: LayoutSourceIdentity;
};

export type ListItemFragment = {
Expand All @@ -2053,6 +2089,8 @@ export type ListItemFragment = {
continuesFromPrev?: boolean;
continuesOnNext?: boolean;
sourceAnchor?: SourceAnchor;
/** Optional editor-neutral identity (prep-001). See `ParaFragment.layoutSourceIdentity`. */
layoutSourceIdentity?: LayoutSourceIdentity;
};

export type Fragment = ParaFragment | ImageFragment | DrawingFragment | ListItemFragment | TableFragment;
Expand Down
171 changes: 171 additions & 0 deletions packages/layout-engine/contracts/src/layout-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Editor-neutral layout identity primitives (prep-001) — contract tests.
*
* These tests pin the additive substrate so v1 fixtures cannot quietly
* lose `pmStart`/`pmEnd` fields and future producers cannot break the
* editor-neutral shape used by downstream surfaces.
*/
import { describe, expect, it } from 'vitest';
import {
LAYOUT_BOUNDARY_SCHEMA,
bodyStoryLocator,
buildLayoutSourceIdentity,
buildLayoutSourceIdentityForFragment,
computeLayoutFragmentId,
namedStoryLocator,
} from './layout-identity.js';
import type { ParaFragment, TableFragment } from './index.js';

describe('layout-identity (prep-001)', () => {
it('exposes a versioned schema constant', () => {
expect(LAYOUT_BOUNDARY_SCHEMA).toBe('layout-identity/1');
});

it('builds story locators for body and named stories', () => {
expect(bodyStoryLocator()).toEqual({ kind: 'body' });
expect(namedStoryLocator('header', 'rId4')).toEqual({ kind: 'header', id: 'rId4' });
expect(namedStoryLocator('footer', 'rId7')).toEqual({ kind: 'footer', id: 'rId7' });
});

it('falls back to unknown when a named story has no id', () => {
// Empty id is not a useful story handle; reject rather than silently
// shipping `{kind:'header', id:''}` to consumers.
expect(namedStoryLocator('header', '')).toEqual({ kind: 'unknown' });
});

it('computes a stable opaque fragment id for paragraph fragments', () => {
const a = computeLayoutFragmentId({
blockId: '5-paragraph',
kind: 'para',
fromLine: 0,
toLine: 2,
});
const b = computeLayoutFragmentId({
blockId: '5-paragraph',
kind: 'para',
fromLine: 0,
toLine: 2,
});
const c = computeLayoutFragmentId({
blockId: '5-paragraph',
kind: 'para',
fromLine: 7,
toLine: 8,
});

expect(a).toBe(b);
expect(a).not.toBe(c);
});

it('differentiates by story when story changes', () => {
const body = computeLayoutFragmentId({ blockId: '5-paragraph', kind: 'para', fromLine: 0 });
const header = computeLayoutFragmentId({
blockId: '5-paragraph',
kind: 'para',
fromLine: 0,
story: namedStoryLocator('header', 'rId4'),
});
expect(body).not.toBe(header);
});

it('differentiates paragraph and table split fragments by their full rendered slice', () => {
const paragraphA = computeLayoutFragmentId({
blockId: '5-paragraph',
kind: 'para',
fromLine: 0,
toLine: 1,
});
const paragraphB = computeLayoutFragmentId({
blockId: '5-paragraph',
kind: 'para',
fromLine: 0,
toLine: 2,
});
const a = computeLayoutFragmentId({
blockId: '12-table',
kind: 'table',
fromRow: 3,
toRow: 4,
partialRow: { rowIndex: 3, fromLineByCell: [0, 1], toLineByCell: [2, -1] },
});
const b = computeLayoutFragmentId({
blockId: '12-table',
kind: 'table',
fromRow: 3,
toRow: 4,
partialRow: { rowIndex: 3, fromLineByCell: [2, 1], toLineByCell: [4, -1] },
});
expect(paragraphA).not.toBe(paragraphB);
expect(a).not.toBe(b);
});

it('derives fragment identity from the same discriminators used by rendered fragment keys', () => {
const para = buildLayoutSourceIdentityForFragment({
kind: 'para',
blockId: 'p1',
fromLine: 0,
toLine: 1,
x: 20,
y: 40,
});
const imageA = buildLayoutSourceIdentityForFragment({
kind: 'image',
blockId: 'img1',
x: 20,
y: 40,
});
const imageB = buildLayoutSourceIdentityForFragment({
kind: 'image',
blockId: 'img1',
x: 20,
y: 80,
});

expect(para.fragmentId).toContain('para:0:1');
expect(imageA.fragmentId).not.toBe(imageB.fragmentId);
});

it('builds a composite identity with schema, story, blockRef, and fragmentId', () => {
const identity = buildLayoutSourceIdentity({
blockId: '5-paragraph',
kind: 'para',
fromLine: 0,
});
expect(identity.schema).toBe(LAYOUT_BOUNDARY_SCHEMA);
expect(identity.story).toEqual({ kind: 'body' });
expect(identity.blockRef).toBe('5-paragraph');
expect(typeof identity.fragmentId).toBe('string');
expect(identity.fragmentId.length).toBeGreaterThan(0);
});

it('keeps neutral identity optional on Fragment shapes', () => {
// Compile-time intent: ParaFragment / TableFragment should still type-check
// without `layoutSourceIdentity` so v1 fixtures (and existing tests) stay
// valid.
const para: ParaFragment = {
kind: 'para',
blockId: '0-paragraph',
fromLine: 0,
toLine: 1,
x: 0,
y: 0,
width: 100,
};
const table: TableFragment = {
kind: 'table',
blockId: '12-table',
fromRow: 0,
toRow: 1,
x: 0,
y: 0,
width: 200,
height: 50,
};

expect(para.layoutSourceIdentity).toBeUndefined();
expect(table.layoutSourceIdentity).toBeUndefined();
// pmStart / pmEnd remain available alongside the neutral substrate.
expect('pmStart' in para).toBe(false);
expect('pmEnd' in para).toBe(false);
});
});
Loading
Loading