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
4 changes: 4 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2065,4 +2065,8 @@ export type {
} from './resolved-layout.js';
export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from './resolved-layout.js';

// Pure transformations on inline-run shapes (used by pm-adapter, layout-bridge,
// and painter-dom). Located in contracts to avoid reverse stage dependencies.
export { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js';

export * as Engines from './engines/index.js';
16 changes: 16 additions & 0 deletions packages/layout-engine/contracts/src/resolved-layout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
ColumnLayout,
ColumnRegion,
DrawingBlock,
FlowMode,
Fragment,
Expand Down Expand Up @@ -66,6 +68,10 @@ export type ResolvedPage = {
};
/** Page orientation. */
orientation?: 'portrait' | 'landscape';
/** Column layout configuration for this page (reflects page-start config). */
columns?: ColumnLayout;
/** Vertical column regions when continuous section breaks change column layout mid-page. */
columnRegions?: ColumnRegion[];
};

/** Union of all resolved paint item kinds. */
Expand Down Expand Up @@ -111,6 +117,10 @@ export type ResolvedFragmentItem = {
zIndex?: number;
/** Source fragment kind — used by the painter for wrapper style decisions. */
fragmentKind: Fragment['kind'];
/** Source fragment back-pointer. Lets the painter iterate resolved items
* and pass the underlying fragment to render helpers without indexing
* back into the legacy `page.fragments` array. */
fragment: Fragment;
/** Block ID. Written to data-block-id. */
blockId: string;
/**
Expand Down Expand Up @@ -257,6 +267,8 @@ export type ResolvedTableItem = {
* promote this to a permanent API surface.
*/
fragmentIndex: number;
/** Source TableFragment back-pointer (see ResolvedFragmentItem.fragment). */
fragment: Fragment;
/** ProseMirror start position for click-to-position mapping. */
pmStart?: number;
/** ProseMirror end position for click-to-position mapping. */
Expand Down Expand Up @@ -318,6 +330,8 @@ export type ResolvedImageItem = {
* promote this to a permanent API surface.
*/
fragmentIndex: number;
/** Source ImageFragment back-pointer (see ResolvedFragmentItem.fragment). */
fragment: Fragment;
/** ProseMirror start position for click-to-position mapping. */
pmStart?: number;
/** ProseMirror end position for click-to-position mapping. */
Expand Down Expand Up @@ -371,6 +385,8 @@ export type ResolvedDrawingItem = {
* promote this to a permanent API surface.
*/
fragmentIndex: number;
/** Source DrawingFragment back-pointer (see ResolvedFragmentItem.fragment). */
fragment: Fragment;
/** ProseMirror start position for click-to-position mapping. */
pmStart?: number;
/** ProseMirror end position for click-to-position mapping. */
Expand Down
139 changes: 139 additions & 0 deletions packages/layout-engine/contracts/src/run-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import type { FlowBlock, Line, ParagraphBlock, Run, TabRun, TextRun, TrackedChangeMeta } from './index.js';
import { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js';

describe('expandRunsForInlineNewlines', () => {
const makeRun = (text: string, pmStart = 0): TextRun => ({
text,
fontFamily: 'Arial',
fontSize: 12,
pmStart,
pmEnd: pmStart + text.length,
});

it('returns runs without inline newlines unchanged', () => {
const runs: Run[] = [makeRun('hello')];
expect(expandRunsForInlineNewlines(runs)).toEqual(runs);
});

it('splits a text run at a single inline newline', () => {
const result = expandRunsForInlineNewlines([makeRun('foo\nbar')]);
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({ text: 'foo', pmStart: 0, pmEnd: 3 });
expect(result[1]).toMatchObject({ kind: 'break', pmStart: 3, pmEnd: 4 });
expect(result[2]).toMatchObject({ text: 'bar', pmStart: 4, pmEnd: 7 });
});

it('keeps the break and advances the cursor for a leading newline', () => {
const result = expandRunsForInlineNewlines([makeRun('\nfoo')]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ kind: 'break', pmStart: 0, pmEnd: 1 });
expect(result[1]).toMatchObject({ text: 'foo', pmStart: 1, pmEnd: 4 });
});

it('keeps both breaks when a run contains consecutive inline newlines', () => {
const result = expandRunsForInlineNewlines([makeRun('a\n\nb')]);
expect(result).toHaveLength(4);
expect(result[0]).toMatchObject({ text: 'a', pmStart: 0, pmEnd: 1 });
expect(result[1]).toMatchObject({ kind: 'break', pmStart: 1, pmEnd: 2 });
expect(result[2]).toMatchObject({ kind: 'break', pmStart: 2, pmEnd: 3 });
expect(result[3]).toMatchObject({ text: 'b', pmStart: 3, pmEnd: 4 });
});

it('does not emit an empty trailing text run for a trailing newline', () => {
const result = expandRunsForInlineNewlines([makeRun('foo\n')]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ text: 'foo', pmStart: 0, pmEnd: 3 });
expect(result[1]).toMatchObject({ kind: 'break', pmStart: 3, pmEnd: 4 });
});

it('propagates trackedChange metadata onto emitted break runs', () => {
const trackedChange: TrackedChangeMeta = {
id: 'change-1',
kind: 'insert',
author: 'alice',
date: '2024-01-01T00:00:00Z',
};
const run: TextRun = { ...makeRun('foo\nbar'), trackedChange };
const result = expandRunsForInlineNewlines([run]);
expect(result[1]).toMatchObject({ kind: 'break', trackedChange });
});
});

describe('sliceRunsForLine', () => {
const makeTextRun = (text: string, pmStart = 0): TextRun => ({
text,
fontFamily: 'Arial',
fontSize: 12,
pmStart,
pmEnd: pmStart + text.length,
});

const makeParagraph = (runs: Run[]): ParagraphBlock => ({
kind: 'paragraph',
id: 'p-1',
runs,
});

const makeLine = (overrides: Partial<Line> = {}): Line => ({
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 0,
width: 0,
ascent: 12,
descent: 4,
lineHeight: 16,
...overrides,
});

it('returns an empty array for non-paragraph blocks', () => {
const block: FlowBlock = {
kind: 'image',
id: 'i-1',
attrs: { src: 'about:blank', alt: '' },
} as unknown as FlowBlock;
expect(sliceRunsForLine(block, makeLine())).toEqual([]);
});

it('passes tab runs through unchanged', () => {
const tab: TabRun = { kind: 'tab', text: '\t', pmStart: 0, pmEnd: 1 };
const block = makeParagraph([tab]);
const line = makeLine({ toRun: 0, fromChar: 0, toChar: 1 });
expect(sliceRunsForLine(block, line)).toEqual([tab]);
});

it('passes line-break runs through unchanged', () => {
const lineBreak: Run = { kind: 'lineBreak', pmStart: 0, pmEnd: 1 } as Run;
const block = makeParagraph([lineBreak]);
const line = makeLine({ toRun: 0, fromChar: 0, toChar: 1 });
expect(sliceRunsForLine(block, line)).toEqual([lineBreak]);
});

it('slices text on the first/last run and adjusts pmStart/pmEnd', () => {
const run = makeTextRun('hello world', 100);
const block = makeParagraph([run]);
const line = makeLine({ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5 });
const result = sliceRunsForLine(block, line);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ text: 'hello', pmStart: 100, pmEnd: 105 });
});

it('passes middle text runs through unchanged when the line spans multiple runs', () => {
const first = makeTextRun('foo', 0);
const middle = makeTextRun('bar', 3);
const last = makeTextRun('baz', 6);
const block = makeParagraph([first, middle, last]);
const line = makeLine({ fromRun: 0, fromChar: 0, toRun: 2, toChar: 3 });
const result = sliceRunsForLine(block, line);
expect(result).toHaveLength(3);
expect(result[1]).toBe(middle);
});

it('drops empty slices when the requested range produces no characters', () => {
const run = makeTextRun('abc', 0);
const block = makeParagraph([run]);
const line = makeLine({ fromRun: 0, fromChar: 2, toRun: 0, toChar: 2 });
expect(sliceRunsForLine(block, line)).toEqual([]);
});
});
109 changes: 109 additions & 0 deletions packages/layout-engine/contracts/src/run-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Pure transformations on inline-run shapes.
*
* These helpers operate on `Run[]` shapes defined in this contracts package.
* They have no upstream dependencies (no pm-adapter, no layout-bridge, no
* style-engine), so any stage can consume them without creating a reverse
* dependency back into a downstream package.
*/

import type { FlowBlock, Line, Run, TextRun } from './index.js';

/**
* Expands text runs that contain inline newlines into multiple runs.
*
* @param {Run[]} runs - The runs to expand
* @returns {Run[]} The expanded runs
*/
export function expandRunsForInlineNewlines(runs: Run[]): Run[] {
const result: Run[] = [];
for (const run of runs) {
const textRun = run as TextRun;
if ('text' in run && typeof textRun.text === 'string' && textRun.text.includes('\n')) {
const segments = textRun.text.split('\n');
let cursor = textRun.pmStart ?? 0;
segments.forEach((segment, idx) => {
if (segment.length > 0) {
result.push({ ...textRun, text: segment, pmStart: cursor, pmEnd: cursor + segment.length });
cursor += segment.length;
}
if (idx !== segments.length - 1) {
result.push({
kind: 'break',
breakType: 'line',
pmStart: cursor,
pmEnd: cursor + 1,
sdt: textRun.sdt,
trackedChange: textRun.trackedChange,
});
cursor += 1;
}
});
} else {
result.push(run);
}
}
return result;
}

/**
* Extracts the subset of runs that appear in a specific line.
* Handles partial runs that span multiple lines.
*
* @param block - The paragraph block containing the runs
* @param line - The line to extract runs for
* @returns Array of runs present in the line
*/
export function sliceRunsForLine(block: FlowBlock, line: Line): Run[] {
const result: Run[] = [];
if (block.kind !== 'paragraph') return result;

for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) {
const run = block.runs[runIndex];
if (!run) continue;

if (run.kind === 'tab') {
result.push(run);
continue;
}

// Images, line breaks, breaks, field annotations, and math runs are atomic
// units. They occupy a single character of the run sequence and are passed
// through to the result without slicing.
if (
'src' in run ||
run.kind === 'lineBreak' ||
run.kind === 'break' ||
run.kind === 'fieldAnnotation' ||
run.kind === 'math'
) {
result.push(run);
continue;
}

const text = run.text ?? '';
const isFirstRun = runIndex === line.fromRun;
const isLastRun = runIndex === line.toRun;

if (isFirstRun || isLastRun) {
const start = isFirstRun ? line.fromChar : 0;
const end = isLastRun ? line.toChar : text.length;
const slice = text.slice(start, end);
if (!slice) continue;
const pmStart =
run.pmStart != null ? run.pmStart + start : run.pmEnd != null ? run.pmEnd - (text.length - start) : undefined;
const pmEnd =
run.pmStart != null ? run.pmStart + end : run.pmEnd != null ? run.pmEnd - (text.length - end) : undefined;
result.push({
...run,
text: slice,
pmStart,
pmEnd,
});
} else {
result.push(run);
}
}

return result;
}
3 changes: 2 additions & 1 deletion packages/layout-engine/layout-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ export type { HeaderFooterLayoutResult, IncrementalLayoutResult } from './increm
export { computeDisplayPageNumber } from '@superdoc/layout-engine';
export type { DisplayPageInfo, HeaderFooterConstraints } from '@superdoc/layout-engine';
export { remeasureParagraph } from './remeasure';
export { measureCharacterX, sliceRunsForLine } from './text-measurement';
export { measureCharacterX } from './text-measurement';
export { sliceRunsForLine } from '@superdoc/contracts';
export { clickToPositionDom, findPageElement } from './dom-mapping';
export { isListItem, getWordLayoutConfig, calculateTextStartIndent, extractParagraphIndent } from './list-indent-utils';
export type { TextIndentCalculationParams } from './list-indent-utils';
Expand Down
Loading
Loading