diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 08cb0815cc..def97e99da 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -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 `` / ``) rather than + * from a canonical `` 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 = { @@ -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'; diff --git a/packages/layout-engine/contracts/src/run-helpers.test.ts b/packages/layout-engine/contracts/src/run-helpers.test.ts index aaae281c2b..cebe8f6906 100644 --- a/packages/layout-engine/contracts/src/run-helpers.test.ts +++ b/packages/layout-engine/contracts/src/run-helpers.test.ts @@ -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 => ({ @@ -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, diff --git a/packages/layout-engine/contracts/src/run-helpers.ts b/packages/layout-engine/contracts/src/run-helpers.ts index 13409e2b69..480c28eab9 100644 --- a/packages/layout-engine/contracts/src/run-helpers.ts +++ b/packages/layout-engine/contracts/src/run-helpers.ts @@ -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. @@ -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 ``; literal `\t` inside `` / `` is + * non-canonical, but Word documents in the wild — notably Orbital Copilot's + * `[\t]` 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 `` 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; + 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, + } + : {}), + }; + 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. diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 78f6e8ab23..01055c5462 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -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', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 39e85716cf..37afbf7880 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -31,66 +31,66 @@ */ import { + DEFAULT_LIST_HANGING_PX as DEFAULT_LIST_HANGING, + DEFAULT_LIST_INDENT_BASE_PX as DEFAULT_LIST_INDENT_BASE, + DEFAULT_LIST_INDENT_STEP_PX as DEFAULT_LIST_INDENT_STEP, + LIST_MARKER_GAP, + MIN_MARKER_GUTTER, +} from '@superdoc/common/layout-constants'; +import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils'; +import { + effectiveTableCellSpacing, Engines, + expandRunsForInlineTabs, + getCellSpacingPx, + LeaderDecoration, + resolveBaseFontSizeForVerticalText, + type DrawingBlock, + type DrawingGeometry, + type DrawingMeasure, + type DropCapDescriptor, + type FieldAnnotationRun, type FlowBlock, - type ParagraphBlock, - type ParagraphSpacing, - type ParagraphIndent, type ImageBlock, + type ImageMeasure, + type ImageRun, + type Line, + type LineBreakRun, type ListBlock, + type ListMeasure, type Measure, - type Line, + type ParagraphBlock, + type ParagraphIndent, type ParagraphMeasure, - type ImageMeasure, + type ParagraphSpacing, + type Run, type TableBlock, + type TableBorders, + type TableBorderValue, + type TableCellMeasure, type TableMeasure, type TableRowMeasure, - type TableCellMeasure, - type ListMeasure, - type Run, - type TextRun, type TabRun, - type ImageRun, - type LineBreakRun, - type FieldAnnotationRun, type TabStop, - type DrawingBlock, - type DrawingMeasure, - type DrawingGeometry, - type DropCapDescriptor, - type CellSpacing, - type TableBorders, - type TableBorderValue, - effectiveTableCellSpacing, - LeaderDecoration, - resolveBaseFontSizeForVerticalText, + type TextRun, } from '@superdoc/contracts'; -import type { WordParagraphLayoutOutput } from '@superdoc/word-layout'; -import { - LIST_MARKER_GAP, - MIN_MARKER_GUTTER, - DEFAULT_LIST_INDENT_BASE_PX as DEFAULT_LIST_INDENT_BASE, - DEFAULT_LIST_INDENT_STEP_PX as DEFAULT_LIST_INDENT_STEP, - DEFAULT_LIST_HANGING_PX as DEFAULT_LIST_HANGING, -} from '@superdoc/common/layout-constants'; -import { resolveListTextStartPx, type MinimalMarker } from '@superdoc/common/list-marker-utils'; -import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils'; import { toCssFontFamily } from '@superdoc/font-utils'; -export { installNodeCanvasPolyfill } from './setup.js'; -import { clearMeasurementCache, getMeasuredTextWidth, setCacheSize } from './measurementCache.js'; -import { getFontMetrics, clearFontMetricsCache, type FontInfo } from './fontMetricsCache.js'; +import { calculateRotatedBounds, normalizeRotation } from '@superdoc/geometry-utils'; +import type { WordParagraphLayoutOutput } from '@superdoc/word-layout'; import { computeAutoFitColumnWidths } from './autofit-columns.js'; import { buildAutoFitWorkingGridInput, type WorkingTableGridInput } from './autofit-normalize.js'; -import { computeFixedTableColumnWidths } from './fixed-table-columns.js'; import type { FixedLayoutResult } from './fixed-table-columns.js'; +import { computeFixedTableColumnWidths } from './fixed-table-columns.js'; +import { clearFontMetricsCache, getFontMetrics, type FontInfo } from './fontMetricsCache.js'; +import { clearMeasurementCache, getMeasuredTextWidth, setCacheSize } from './measurementCache.js'; import { buildAutoFitTableResultCacheKey, - buildTableCellContentMetricsCacheKey, getCachedAutoFitTableResult, - type TableAutoFitContentMetricsResult, measureTableAutoFitContentMetrics, setCachedAutoFitTableResult, + type TableAutoFitContentMetricsResult, } from './table-autofit-metrics.js'; +export { installNodeCanvasPolyfill } from './setup.js'; export { clearFontMetricsCache }; @@ -158,13 +158,11 @@ const DEFAULT_TAB_INTERVAL_TWIPS = 720; // 0.5 inch in twips const TWIPS_PER_INCH = 1440; const PX_PER_INCH = 96; // Standard CSS/DOM DPI const TWIPS_PER_PX = TWIPS_PER_INCH / PX_PER_INCH; // 15 twips per pixel -const _PX_PER_PT = 96 / 72; // Reserved for future pt↔px conversions const twipsToPx = (twips: number): number => twips / TWIPS_PER_PX; const pxToTwips = (px: number): number => Math.round(px * TWIPS_PER_PX); // Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported. export { getCellSpacingPx } from '@superdoc/contracts'; -import { getCellSpacingPx } from '@superdoc/contracts'; /** * Returns the border width in pixels for a table border value (matches painter border-utils logic). @@ -1296,57 +1294,9 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P runsToProcess.push(run as Run); } } - if (runsToProcess.some((run) => isTextRun(run) && typeof run.text === 'string' && run.text.includes('\t'))) { - const expandedRuns: Run[] = []; - for (const run of runsToProcess) { - if (!isTextRun(run) || typeof run.text !== 'string' || !run.text.includes('\t')) { - expandedRuns.push(run); - continue; - } - const textRun = run as TextRun; - let buffer = ''; - let cursor = textRun.pmStart ?? 0; - const text = textRun.text; - for (let i = 0; i < text.length; i += 1) { - const char = text[i]; - if (char === '\t') { - if (buffer.length > 0) { - expandedRuns.push({ - ...textRun, - text: buffer, - pmStart: cursor - buffer.length, - pmEnd: cursor, - }); - buffer = ''; - } - const tabRun: TabRun = { - kind: 'tab', - text: '\t', - pmStart: cursor, - pmEnd: cursor + 1, - tabStops: block.attrs?.tabs as TabStop[] | undefined, - indent, - leader: (textRun as unknown as TabRun)?.leader ?? null, - sdt: textRun.sdt, - }; - expandedRuns.push(tabRun); - cursor += 1; - continue; - } - buffer += char; - cursor += 1; - } - if (buffer.length > 0) { - expandedRuns.push({ - ...textRun, - text: buffer, - pmStart: cursor - buffer.length, - pmEnd: cursor, - }); - } - } - runsToProcess = expandedRuns; - } + // SD-3266: delegate to the shared helper so the painter (which calls the + // same helper) produces a matching expanded run array. Indices must align. + runsToProcess = expandRunsForInlineTabs(runsToProcess, block.attrs?.tabs as TabStop[] | undefined, indent); const totalTabRuns = runsToProcess.reduce((count, run) => (isTabRun(run) ? count + 1 : count), 0); /** @@ -1569,6 +1519,39 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P }; } + // SD-3266: TabRuns synthesized by expandRunsForInlineTabs from a literal + // U+0009 inside ``/``. Two cases: + // + // a) tracked-change-marked literal tab (deletion/insertion placeholder + // like `[\t]`): render as a compact 2-space strut + // (Word's "Deleted: [→]" balloon convention). No tab-stop advance. + // + // b) plain literal tab (not in a revision): retain real tab-stop + // advance semantics (signature-line "Sign:____\t", arbitrary "\t" in + // text content). We still need to publish the measured width to the + // painter — synthesized TabRuns are fresh object instances on each + // `expandRunsForInlineTabs` call, so the `run.width = tabAdvance` + // mutation below is invisible to the painter. Emit a LineSegment so + // the painter can read width back via segmentsByRun, preventing the + // trailing-tab width-collapse bug for non-revision literal tabs. + if ((run as TabRun).fromLiteralTab && (run as TabRun).trackedChange) { + // SD-3266 case (a): compact 2-space placeholder. + const { font } = buildFontString(run as unknown as TextRun); + const glyphWidth = measureRunWidth(' ', font, ctx, run as unknown as Run); + const segStart = currentLine.toChar; + const segEnd = segStart + 1; + currentLine.toRun = runIndex; + currentLine.toChar = segEnd; + currentLine.width = roundValue(currentLine.width + glyphWidth); + currentLine.maxFontSize = Math.max(currentLine.maxFontSize, lastFontSize); + appendSegment(currentLine.segments, runIndex, segStart, segEnd, glyphWidth); + (run as TabRun & { width?: number }).width = glyphWidth; + pendingRunSpacing = 0; + pendingTabAlignment = null; + pendingLeader = null; + continue; + } + // Advance to the appropriate tab stop (explicit alignment stops take precedence for trailing tabs) const originX = currentLine.width; // Use first-line effective indent (accounts for hanging) on first line, body indent otherwise @@ -1615,6 +1598,20 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P // Persist measured tab width on the TabRun for downstream consumers/tests (run as TabRun & { width?: number }).width = tabAdvance; + // SD-3266: For literal-`\t` tabs synthesized by `expandRunsForInlineTabs`, + // the painter re-runs that helper independently and gets a FRESH TabRun + // instance — so the `run.width` mutation above is invisible to it. + // Emit a LineSegment carrying the measured advance so the painter can + // read it back via `segmentsByRun` and avoid the trailing-tab width + // collapse bug for non-revision literal tabs (e.g. signature-line + // "Sign:____\t"). Real `` PM-node tabs don't carry the flag, so + // they don't pay this cost. + if ((run as TabRun).fromLiteralTab) { + const segStart = currentLine.toChar; + const segEnd = segStart + 1; + appendSegment(currentLine.segments, runIndex, segStart, segEnd, tabAdvance); + } + currentLine.maxFontSize = Math.max(currentLine.maxFontSize, lastFontSize); currentLine.toRun = runIndex; currentLine.toChar = 1; // tab is a single character diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 71ca0b8ba2..3098d6267e 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -2,15 +2,18 @@ import type { DropCapDescriptor, Line, ParagraphBlock, + ParagraphIndent, ParagraphMeasure, ResolvedParagraphContent, Run, SdtMetadata, SourceAnchor, + TabStop, } from '@superdoc/contracts'; import { effectiveTableCellSpacing, expandRunsForInlineNewlines, + expandRunsForInlineTabs, getParagraphInlineDirection, } from '@superdoc/contracts'; import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; @@ -238,7 +241,13 @@ const renderResolvedLines = ( } = params; const renderedLines: RenderedParagraphLineInfo[] = []; const resolvedMarker = content.marker; - const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); + // SD-3266: chain expandRunsForInlineTabs so the painter's pre-expanded run + // array matches the measurer's Line.fromRun/toRun indexing contract. + const expandedRunsForBlock = expandRunsForInlineTabs( + expandRunsForInlineNewlines(block.runs), + block.attrs?.tabs as TabStop[] | undefined, + block.attrs?.indent as ParagraphIndent | undefined, + ); const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; let renderedHeight = 0; @@ -324,12 +333,19 @@ const renderMeasuredLines = ( } = resolveMarkerIndent(paraIndent, isRtl); const wordLayoutIndentLeft = (wordLayout as { indentLeftPx?: number } | undefined)?.indentLeftPx; const tableMarkerIndentLeft = - measure.marker?.indentLeft ?? - wordLayoutIndentLeft ?? - (typeof paraIndent?.left === 'number' ? paraIndent.left : 0); + measure.marker?.indentLeft ?? wordLayoutIndentLeft ?? (typeof paraIndent?.left === 'number' ? paraIndent.left : 0); const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); - const expandedRunsForBlock = containerKind === 'body-fragment' ? expandRunsForInlineNewlines(block.runs) : undefined; + // SD-3266: same chain as the resolved-content path above — the painter must + // see the same expansion the measurer used or Line.fromRun/toRun misalign. + const expandedRunsForBlock = + containerKind === 'body-fragment' + ? expandRunsForInlineTabs( + expandRunsForInlineNewlines(block.runs), + block.attrs?.tabs as TabStop[] | undefined, + block.attrs?.indent as ParagraphIndent | undefined, + ) + : undefined; const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; const markerLayout = wordLayout?.marker; diff --git a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts index e668ed19eb..31ed44c8b8 100644 --- a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts @@ -1513,7 +1513,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 100, @@ -1593,7 +1597,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 360, @@ -1674,7 +1682,8 @@ describe('DomPainter hanging indent with tabs', () => { id: blockId, runs: [ { - text: 'First line text continues on second line\twith tab', + // SD-3266: replace literal \t with space (see note in failing test above). + text: 'First line text continues on second line with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, @@ -1770,7 +1779,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 200, @@ -1848,7 +1861,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 0, @@ -1934,7 +1951,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 100, @@ -1980,7 +2001,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 100, @@ -2245,7 +2270,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 360, @@ -2292,7 +2321,11 @@ describe('DomPainter hanging indent with tabs', () => { const block: FlowBlock = { kind: 'paragraph', id: blockId, - runs: [{ text: 'Text\twith tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], + // SD-3266: literal U+0009 now expands at paint-time via expandRunsForInlineTabs, + // which would invalidate the runIndex:0 indices these segment fixtures use. + // This describe block exercises segment positioning, not tab semantics — use + // a plain space to keep the original 1-run shape under the hand-rolled measure. + runs: [{ text: 'Text with tab', fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: 13 }], attrs: { indent: { left: 0, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 09b0348e3c..425f309c1b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -17,6 +17,7 @@ import type { PageMargins, ParaFragment, ParagraphBlock, + ParagraphIndent, PositionedDrawingGeometry, Run, ShapeGroupChild, @@ -27,6 +28,7 @@ import type { TableBlock, TableFragment, TableMeasure, + TabStop, VectorShapeDrawing, VectorShapeStyle, ResolvedLayout, @@ -43,6 +45,7 @@ import { LAYOUT_BOUNDARY_SCHEMA, buildLayoutSourceIdentityForFragment, expandRunsForInlineNewlines, + expandRunsForInlineTabs, getCellSpacingPx, normalizeColumnLayout, } from '@superdoc/contracts'; @@ -3686,7 +3689,13 @@ export class DomPainter { let expandedRuns = tableCellExpandedRunsCache.get(block); if (!expandedRuns) { - expandedRuns = expandRunsForInlineNewlines(block.runs); + // SD-3266: chain expandRunsForInlineTabs so the painter's run array + // matches the measurer's (Line.fromRun/toRun indexing contract). + expandedRuns = expandRunsForInlineTabs( + expandRunsForInlineNewlines(block.runs), + block.attrs?.tabs as TabStop[] | undefined, + block.attrs?.indent as ParagraphIndent | undefined, + ); tableCellExpandedRunsCache.set(block, expandedRuns); } diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index 76edade860..e048ed7e68 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -1,8 +1,17 @@ -import type { LineSegment, ParagraphAttrs, ParagraphBlock, Run, TextRun } from '@superdoc/contracts'; +import type { + LineSegment, + ParagraphAttrs, + ParagraphBlock, + ParagraphIndent, + Run, + TabStop, + TextRun, +} from '@superdoc/contracts'; import { calculateJustifySpacing, computeLinePmRange, expandRunsForInlineNewlines, + expandRunsForInlineTabs, shouldApplyJustify, sliceRunsForLine, SPACE_CHARS, @@ -162,7 +171,19 @@ export const renderLine = ({ paragraphMarkLeftOffsetOverride, runContext, }: RenderLineParams): HTMLElement => { - const expandedBlock = { ...block, runs: preExpandedRuns ?? expandRunsForInlineNewlines(block.runs) }; + // SD-3266: chain expandRunsForInlineTabs after expandRunsForInlineNewlines so + // the painter sees the same expanded run array the measurer used to compute + // Line.fromRun/toRun. Otherwise indices misalign and slices drop content. + const expandedBlock = { + ...block, + runs: + preExpandedRuns ?? + expandRunsForInlineTabs( + expandRunsForInlineNewlines(block.runs), + block.attrs?.tabs as TabStop[] | undefined, + block.attrs?.indent as ParagraphIndent | undefined, + ), + }; const lineRange = computeLinePmRange(expandedBlock, line); let runsForLine = sliceRunsForLine(expandedBlock, line); @@ -326,7 +347,8 @@ export const renderLine = ({ if (shouldUseSegmentPositioning(hasExplicitPositioning ?? false, Boolean(line.segments), isRtl)) { renderExplicitlyPositionedRuns({ - block, + // SD-3266: pass the expanded block — line.fromRun/toRun refer to expanded indices. + block: expandedBlock, line, context, el, @@ -507,6 +529,8 @@ const renderExplicitlyPositionedRuns = ({ }; for (let runIndex = line.fromRun; runIndex <= line.toRun; runIndex += 1) { + // SD-3266: caller passes the already-expanded block — line.fromRun/toRun + // index into the expanded run array (see expandRunsForInlineTabs above). const baseRun = block.runs[runIndex]; if (!baseRun) continue; @@ -514,6 +538,10 @@ const renderExplicitlyPositionedRuns = ({ // Find where the immediate next content begins (if it's right after this tab) const immediateNextSegment = findImmediateNextSegment(runIndex); const tabStartX = cumulativeX; + // SD-3266: forward the measurer-recorded segment width so literal-tab + // placeholders advance by exactly the 2-space glyph width. + const segmentList = segmentsByRun.get(runIndex); + const segmentWidth = segmentList?.[0]?.width; const { element: tabEl, tabEndX, @@ -527,6 +555,8 @@ const renderExplicitlyPositionedRuns = ({ indentOffset, immediateNextSegment, styleId, + trackedConfig, + segmentWidth, ); appendToLineGeo(tabEl, baseRun, tabStartX + indentOffset, actualTabWidth); @@ -707,7 +737,7 @@ const renderInlineRuns = ({ // Special handling for TabRuns (e.g., signature lines with underlines) const elem = run.kind === 'tab' - ? renderInlineTabRun(run, line, runContext.doc, runContext.layoutEpoch, styleId) + ? renderInlineTabRun(run, line, runContext.doc, runContext.layoutEpoch, styleId, trackedConfig) : renderRun(run, context, runContext, trackedConfig); if (elem) { diff --git a/packages/layout-engine/painters/dom/src/runs/tab-run.ts b/packages/layout-engine/painters/dom/src/runs/tab-run.ts index bb404bc0ff..93a62613be 100644 --- a/packages/layout-engine/painters/dom/src/runs/tab-run.ts +++ b/packages/layout-engine/painters/dom/src/runs/tab-run.ts @@ -1,4 +1,48 @@ -import type { Line, LineSegment, Run } from '@superdoc/contracts'; +import type { Line, LineSegment, Run, TabRun } from '@superdoc/contracts'; +import { applyTrackedChangeDecorations } from './tracked-changes.js'; +import type { TrackedChangesRenderConfig } from './types.js'; + +/** + * SD-3266: render a synthesized literal-tab span (the kind produced by + * `expandRunsForInlineTabs` when a ``/`` contained a literal + * U+0009). textContent is two literal spaces; typography is carried over from + * the source text run (the line container uses `font-size: 0` for whitespace + * control, so we must declare these here), and the `.superdoc-tab` class + * participates in SD-2939's existing + * `.superdoc-show-formatting-marks .superdoc-tab::after { content: "→" }` + * overlay automatically. + * + * Underline (signature-line use case) is applied here too so a non-revision + * literal `\t` in `Sign:____\t` keeps its underline through the placeholder. + */ +const renderFromLiteralTabSpan = ( + run: TabRun, + doc: Document, + layoutEpoch: number, + trackedConfig?: TrackedChangesRenderConfig, + styleId?: string, +): HTMLElement => { + const tabEl = doc.createElement('span'); + tabEl.classList.add('superdoc-tab', 'superdoc-tab--literal'); + tabEl.textContent = ' '; + tabEl.style.whiteSpace = 'pre'; + if (run.fontFamily) tabEl.style.fontFamily = run.fontFamily; + if (run.fontSize != null) tabEl.style.fontSize = `${run.fontSize}px`; + if (run.bold) tabEl.style.fontWeight = 'bold'; + if (run.italic) tabEl.style.fontStyle = 'italic'; + if (run.color) tabEl.style.color = run.color; + applyTabUnderline(tabEl, run); + // Apply trackedChange decorations (strikethrough for delete, underline for + // insert) so the placeholder reads as part of the revision in the body. + if (trackedConfig && run.trackedChange) { + applyTrackedChangeDecorations(tabEl, run as Run, trackedConfig); + } + if (styleId) tabEl.setAttribute('styleid', styleId); + if (run.pmStart != null) tabEl.dataset.pmStart = String(run.pmStart); + if (run.pmEnd != null) tabEl.dataset.pmEnd = String(run.pmEnd); + tabEl.dataset.layoutEpoch = String(layoutEpoch); + return tabEl; +}; export const renderInlineTabRun = ( run: Extract, @@ -6,7 +50,14 @@ export const renderInlineTabRun = ( doc: Document, layoutEpoch: number, styleId?: string, + trackedConfig?: TrackedChangesRenderConfig, ): HTMLElement => { + // SD-3266: literal-tab placeholders inside revision text render as a compact + // 2-space strut, not as a tab-stop advance. + if (run.fromLiteralTab) { + return renderFromLiteralTabSpan(run, doc, layoutEpoch, trackedConfig, styleId); + } + const tabEl = doc.createElement('span'); tabEl.classList.add('superdoc-tab'); @@ -39,7 +90,37 @@ export const renderPositionedTabRun = ( indentOffset: number, immediateNextSegment?: LineSegment, styleId?: string, + trackedConfig?: TrackedChangesRenderConfig, + segmentWidth?: number, ): { element: HTMLElement; tabEndX: number; actualTabWidth: number } => { + // SD-3266: literal-tab placeholder. Two flavors flow through here: + // (a) revision tabs (run.trackedChange set) — measurer emits a 2-glyph + // segment, so segmentWidth ≈ the two-space placeholder. We just + // position it at cumulativeX. + // (b) plain literal tabs (no revision) — measurer emits a segment whose + // width is the real tab-stop advance (signature line "Sign:____\t" can + // be hundreds of px). We must set the span's box width to that advance + // so following content lines up, AND so an inherited `underline` mark + // paints a visible signature underline across the full gap. + if (run.fromLiteralTab) { + const measuredAdvance = segmentWidth ?? run.width ?? 0; + const tabEl = renderFromLiteralTabSpan(run, doc, layoutEpoch, trackedConfig, styleId); + tabEl.style.position = 'absolute'; + tabEl.style.left = `${tabStartX + indentOffset}px`; + tabEl.style.top = '0px'; + tabEl.style.lineHeight = `${line.lineHeight}px`; + // For non-revision literal tabs the measured advance is the source of + // truth for the layout box. Setting an explicit width preserves alignment + // for trailing/standalone tabs whose visible content (two spaces) is + // narrower than the advance. + if (!run.trackedChange && measuredAdvance > 0) { + tabEl.style.display = 'inline-block'; + tabEl.style.width = `${measuredAdvance}px`; + tabEl.style.height = `${line.lineHeight}px`; + } + return { element: tabEl, tabEndX: tabStartX + measuredAdvance, actualTabWidth: measuredAdvance }; + } + // The tab should span from where previous content ended to where next content begins. // If layout supplied a tab-end boundary for the next segment, prefer it. // Otherwise, use the next segment's explicit X (from tab alignment) or the diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index 99117c1210..00763a632d 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -1523,4 +1523,118 @@ describe('CommentDialog.vue', () => { expect(header.props('config')).toEqual({ readOnly: false }); }); }); + + /** + * SD-3266: the displayTrackedTextSegments() helper splits revision text on \t + * so each tab character is rendered inside a + * (formatting-mark blue) while the surrounding bracket text keeps its + * red/green deletion/insertion color. The helper is a closure inside