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
20 changes: 19 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,24 @@ export type TabRun = RunMarks & {
pmEnd?: number;
/** SDT metadata if tab is inside a structured document tag. */
sdt?: SdtMetadata;
/**
* SD-3266: true when this TabRun was produced by expandRunsForInlineTabs from a
* literal U+0009 in run text (i.e. inside `<w:t>` / `<w:delText>`) rather than
* from a canonical `<w:tab/>` element. Layout treats these compactly — no
* tab-stop advance, glyph-sized width — so a deleted "[\t]" placeholder fits
* inline like Word's body view and the strikethrough can paint across.
*/
fromLiteralTab?: boolean;
/** Optional tracked-change metadata propagated from the source text run (SD-3266). */
trackedChange?: TrackedChangeMeta;
/**
* SD-3266: typography propagated from the source text run when a literal-tab
* TabRun is synthesized. The measurer uses fontFamily/fontSize to compute the
* "→" glyph width; the painter sets the same on the visible tab span so it
* does not inherit the line container's `font-size: 0` and render invisibly.
*/
fontFamily?: string;
fontSize?: number;
};

export type LineBreakRun = {
Expand Down Expand Up @@ -2224,6 +2242,6 @@ export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from

// 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 { expandRunsForInlineNewlines, expandRunsForInlineTabs, sliceRunsForLine } from './run-helpers.js';

export * as Engines from './engines/index.js';
87 changes: 86 additions & 1 deletion packages/layout-engine/contracts/src/run-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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';
import { expandRunsForInlineNewlines, expandRunsForInlineTabs, sliceRunsForLine } from './run-helpers.js';

describe('expandRunsForInlineNewlines', () => {
const makeRun = (text: string, pmStart = 0): TextRun => ({
Expand Down Expand Up @@ -60,6 +60,91 @@ describe('expandRunsForInlineNewlines', () => {
});
});

describe('expandRunsForInlineTabs (SD-3266)', () => {
const makeRun = (text: string, pmStart = 0, trackedChange?: TrackedChangeMeta): TextRun => ({
text,
fontFamily: 'Arial',
fontSize: 12,
pmStart,
pmEnd: pmStart + text.length,
...(trackedChange ? { trackedChange } : {}),
});

it('returns runs unchanged when no run contains a literal tab', () => {
const runs: Run[] = [makeRun('hello'), makeRun(' world', 5)];
expect(expandRunsForInlineTabs(runs)).toBe(runs);
});

it('splits a tracked-deletion text "[\\t]" into [text, tab(fromLiteralTab), text]', () => {
const meta: TrackedChangeMeta = {
kind: 'delete',
id: 'test',
author: 'tester',
date: '2026-01-01',
storyKey: 'body',
};
const result = expandRunsForInlineTabs([makeRun('[\t]', 0, meta)]);
expect(result).toHaveLength(3);
expect(result[0]).toMatchObject({ text: '[', pmStart: 0, pmEnd: 1 });
expect(result[1]).toMatchObject({
kind: 'tab',
text: '\t',
pmStart: 1,
pmEnd: 2,
fromLiteralTab: true,
});
expect((result[1] as TabRun).trackedChange).toEqual(meta);
expect(result[2]).toMatchObject({ text: ']', pmStart: 2, pmEnd: 3 });
});

it('splits non-revision text containing "\\t" and sets fromLiteralTab but NOT trackedChange', () => {
// TOC-style "Chapter 1\t42" / signature-line "Sign:____\t".
// `fromLiteralTab` is the load-bearing signal for the measurer→painter
// width handoff (synthesized TabRuns are fresh on each helper call, so
// `run.width` mutation isn't visible to the painter — the measurer also
// emits a LineSegment, which the painter reads back via segmentsByRun).
// `trackedChange` stays undefined for non-revision tabs so the painter
// applies real tab-stop advance + signature underline (vs. the compact
// 2-space strut used for revision placeholders).
const result = expandRunsForInlineTabs([makeRun('Chapter 1\t42', 0)]);
expect(result).toHaveLength(3);
const tab = result[1] as TabRun;
expect(tab.kind).toBe('tab');
expect(tab.fromLiteralTab).toBe(true);
expect(tab.trackedChange).toBeUndefined();
});

it('preserves tabStops + indent on the synthesized tab', () => {
const meta: TrackedChangeMeta = {
kind: 'delete',
id: 'x',
author: 'a',
date: 'd',
storyKey: 'body',
};
const tabStops = [{ pos: 720, val: 'start' as const }];
const indent = { left: 100 };
const result = expandRunsForInlineTabs([makeRun('a\tb', 0, meta)], tabStops, indent);
const tab = result[1] as TabRun;
expect(tab.tabStops).toBe(tabStops);
expect(tab.indent).toBe(indent);
});

it('handles consecutive tabs ("[\\t\\t]") by emitting two tab runs in a row', () => {
const meta: TrackedChangeMeta = {
kind: 'delete',
id: 'x',
author: 'a',
date: 'd',
storyKey: 'body',
};
const result = expandRunsForInlineTabs([makeRun('a(n) [\t\t] (X)', 0, meta)]);
// Expect: text "a(n) [" + tab + tab + text "] (X)"
const kinds = result.map((r) => (r.kind === 'tab' ? 'tab' : 'text'));
expect(kinds).toEqual(['text', 'tab', 'tab', 'text']);
});
});

describe('sliceRunsForLine', () => {
const makeTextRun = (text: string, pmStart = 0): TextRun => ({
text,
Expand Down
148 changes: 147 additions & 1 deletion packages/layout-engine/contracts/src/run-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* dependency back into a downstream package.
*/

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

/**
* Expands text runs that contain inline newlines into multiple runs.
Expand Down Expand Up @@ -46,6 +46,152 @@ export function expandRunsForInlineNewlines(runs: Run[]): Run[] {
return result;
}

/**
* SD-3266: expands TEXT runs that contain literal U+0009 into a sequence of
* `[text, tab(fromLiteralTab=true), text, ...]` runs. ECMA-376 represents tab
* stops with `<w:tab/>`; literal `\t` inside `<w:t>` / `<w:delText>` is
* non-canonical, but Word documents in the wild — notably Orbital Copilot's
* `<w:del>[\t]</w:del>` placeholders — emit it anyway. The CSS `white-space:
* pre` containers we paint would otherwise expand the literal tab to the next
* CSS tab stop, blowing the line apart at render time.
*
* Critically, this helper must be applied IDENTICALLY by the measurer and the
* painter. The measurer's `Line.fromRun/toRun` indices refer to the array this
* helper produces; if the painter doesn't expand the same way it ends up
* indexing an unexpanded run array and silently drops content (SD-3266 root
* cause).
*
* The PM doc is not modified — this transformation is local to the layout
* pipeline. On export, the original text run with literal `\t` flows through
* the converter unchanged, preserving round-trip fidelity.
*
* @param runs - Runs to expand
* @param tabStops - Paragraph tab stops to attach to created TabRuns
* @param indent - Paragraph indent to attach to created TabRuns
* @returns Expanded run array
*/
export function expandRunsForInlineTabs(runs: Run[], tabStops?: TabStop[], indent?: ParagraphIndent): Run[] {
const hasLiteralTab = runs.some(
(r) =>
(r.kind === undefined || r.kind === 'text') &&
typeof (r as TextRun).text === 'string' &&
(r as TextRun).text.includes('\t'),
);
if (!hasLiteralTab) return runs;

const result: Run[] = [];
for (const run of runs) {
const isTextLike = run.kind === undefined || run.kind === 'text';
if (!isTextLike || typeof (run as TextRun).text !== 'string' || !(run as TextRun).text.includes('\t')) {
result.push(run);
continue;
}
const textRun = run as TextRun;
const text = textRun.text;
let buffer = '';
let cursor = textRun.pmStart ?? 0;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (ch === '\t') {
if (buffer.length > 0) {
result.push({
...textRun,
text: buffer,
pmStart: cursor - buffer.length,
pmEnd: cursor,
});
buffer = '';
}
// SD-3266: `fromLiteralTab` marks a TabRun synthesized from a literal
// U+0009 in run text — independently of revision context. The flag is
// load-bearing for two distinct downstream needs:
// 1) Compact rendering ([ ] strut, no tab-stop advance) applies
// only when the originating run ALSO carried a tracked-change
// mark. TOC-style runs (e.g. "Chapter 1\t42") still expect a real
// tab-stop advance + leader behavior.
// 2) The measurer→painter width handoff: synthesized TabRuns are
// fresh object instances on each `expandRunsForInlineTabs` call
// (measurer and painter each call the helper), so the measurer's
// `run.width` mutation is NOT visible to the painter. By tagging
// every synthesized tab we let the measurer emit a LineSegment
// that the painter reads back via segmentsByRun — preventing the
// width-collapse bug for trailing/standalone literal `\t` tabs
// (e.g. signature-line "Sign:____\t"). Real `<w:tab/>` PM nodes
// don't carry the flag, so existing tab-stop logic stays intact
// and the same object instance flows through unmodified.
const isInRevision = textRun.trackedChange != null;
// SD-3266: carry typography (fontFamily, fontSize, bold/italic, color,
// underline, strike, ...) from the source text run onto the synthesized
// tab so that:
// - the measurer's canvas-based glyph-width measurement uses the right
// font (otherwise it falls back to 0/defaults and the arrow is sized
// to nothing),
// - the painter's tab span inherits visible typography (the layout
// line container uses font-size: 0 for whitespace control, so each
// child must declare its own font).
// We pick the typography subset explicitly to avoid clobbering TabRun's
// own `kind`/`text`/`pmStart`/`pmEnd`/`tabStops`/`indent` fields.
const {
fontFamily,
fontSize,
bold,
italic,
color,
underline,
strike,
highlight,
letterSpacing,
vertAlign,
baselineShift,
} = textRun as Partial<TextRun>;
const tabRun: TabRun = {
kind: 'tab',
text: '\t',
pmStart: cursor,
pmEnd: cursor + 1,
tabStops,
indent,
leader: null,
sdt: textRun.sdt,
...(fontFamily != null ? { fontFamily } : {}),
...(fontSize != null ? { fontSize } : {}),
...(bold != null ? { bold } : {}),
...(italic != null ? { italic } : {}),
...(color != null ? { color } : {}),
...(underline != null ? { underline } : {}),
...(strike != null ? { strike } : {}),
...(highlight != null ? { highlight } : {}),
...(letterSpacing != null ? { letterSpacing } : {}),
...(vertAlign != null ? { vertAlign } : {}),
...(baselineShift != null ? { baselineShift } : {}),
fromLiteralTab: true,
...(isInRevision
? {
// Propagate tracked-change metadata so the painter can paint
// the strikethrough/underline across the synthesized glyph.
trackedChange: textRun.trackedChange,
}
: {}),
Comment thread
tupizz marked this conversation as resolved.
};
result.push(tabRun);
cursor += 1;
continue;
}
buffer += ch;
cursor += 1;
}
if (buffer.length > 0) {
result.push({
...textRun,
text: buffer,
pmStart: cursor - buffer.length,
pmEnd: cursor,
});
}
}
return result;
}

/**
* Extracts the subset of runs that appear in a specific line.
* Handles partial runs that span multiple lines.
Expand Down
27 changes: 27 additions & 0 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,33 @@ describe('measureBlock', () => {
expect(measure.lines[0].lineHeight).toBeGreaterThan(0);
expect(measure.lines[0].maxFontSize).toBeGreaterThan(0);
});

it('SD-3266: publishes measured width via a LineSegment for non-revision literal tabs', async () => {
// Signature-line pattern: text + trailing literal `\t` outside any
// tracked-change. The measurer's `run.width` mutation is invisible to
// the painter because the painter expands the run array independently
// and gets a fresh TabRun. The measured advance must therefore be
// published as a LineSegment so segmentsByRun.get(runIndex) carries the
// width across the measurer→painter boundary.
const block: FlowBlock = {
kind: 'paragraph',
id: 'sig-line',
runs: [{ text: 'Sign:____\t', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 10 }],
attrs: {
tabs: [{ pos: 360, val: 'left' }],
},
};

const measure = expectParagraphMeasure(await measureBlock(block, 1000));
expect(measure.lines).toHaveLength(1);
const line = measure.lines[0];
// expandRunsForInlineTabs splits the source TextRun into [text, tab]
// — the synthesized tab lives at runIndex 1 on the expanded array
// the line.segments index into.
const tabSegments = (line.segments ?? []).filter((s) => s.runIndex === 1);
expect(tabSegments).toHaveLength(1);
expect(tabSegments[0].width).toBeGreaterThan(0);
});
});

describe('space-only runs', () => {
Expand Down
Loading
Loading