diff --git a/packages/layout-engine/painters/dom/package.json b/packages/layout-engine/painters/dom/package.json index a1d489d11b..c2afee04ca 100644 --- a/packages/layout-engine/painters/dom/package.json +++ b/packages/layout-engine/painters/dom/package.json @@ -17,6 +17,7 @@ "test": "vitest run" }, "dependencies": { + "@superdoc/common": "workspace:*", "@superdoc/contracts": "workspace:*", "@superdoc/font-utils": "workspace:*", "@superdoc/measuring-dom": "workspace:*", diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index ac579eba28..537becb404 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { createDomPainter, sanitizeUrl, linkMetrics, applyRunDataAttributes } from './index.js'; +import { resolveListMarkerGeometry } from '../../../../../shared/common/list-marker-utils.js'; import type { FlowBlock, Measure, @@ -888,13 +889,11 @@ describe('DomPainter', () => { painter.paint(listParaLayout, mount); const firstLine = mount.querySelector('.superdoc-line') as HTMLElement; - // Word-spacing is calculated based on available width AFTER accounting for marker position + inline width. - // Fragment has indent: { left: 48, hanging: 24 }, so markerStartPos = 48 - 24 = 24 - // fragment.markerTextWidth is 12 - // Text starts at: markerStartPos (24) + markerTextWidth (12) + space (4px) = 40px - // availableWidth = 400 - 40 = 360 - // slack = 360 - 180 = 180, wordSpacing = 180 / 5 = 36px - expect(firstLine.style.wordSpacing).toBe('36px'); + // Inline list first lines without explicit segment positioning keep the measured width contract. + // The painter caps line.maxWidth by fragment width minus positive paragraph indents. + // availableWidth = 400 - leftIndent(48) = 352 + // slack = 352 - 180 = 172, wordSpacing = 172 / 5 = 34.4px + expect(firstLine.style.wordSpacing).toBe('34.4px'); const suffix = firstLine.querySelector('.superdoc-marker-suffix-space') as HTMLElement; expect(suffix).toBeTruthy(); @@ -2336,6 +2335,213 @@ describe('DomPainter', () => { expect(textSpan?.style.left).toBe('48px'); }); + it('positions first-line list text from the resolved tab stop instead of stale wordLayout.textStartPx', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'list-tab-stop-block', + runs: [{ text: 'Closing.', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + indent: { left: 48, hanging: 24 }, + numberingProperties: { numId: 1, ilvl: 0 }, + wordLayout: { + firstLineIndentMode: true, + indentLeftPx: 48, + textStartPx: 48, + tabsPx: [144], + marker: { + markerText: '2.1', + glyphWidthPx: 20, + markerBoxWidthPx: 20, + markerX: 0, + justification: 'left', + suffix: 'tab', + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }, + }, + }; + + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 64, + ascent: 12, + descent: 4, + lineHeight: 20, + segments: [{ runIndex: 0, fromChar: 0, toChar: 8, width: 64, x: 0 }], + }, + ], + totalHeight: 20, + marker: { + markerWidth: 20, + markerTextWidth: 20, + indentLeft: 48, + }, + }; + + const listLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'list-tab-stop-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 240, + markerWidth: 20, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + painter.paint(listLayout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + expect(lineEl).toBeTruthy(); + + const textSpan = Array.from(lineEl.querySelectorAll('span')).find((el) => el.textContent === 'Closing.') as + | HTMLElement + | undefined; + expect(textSpan).toBeTruthy(); + expect(textSpan?.style.left).toBe('144px'); + }); + + it('preserves measured justification width for inline list first lines without explicit segments', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'inline-justify-list-block', + runs: [ + { + text: 'Subject to the terms of this Agreement, Company will use', + fontFamily: 'Times New Roman', + fontSize: 13.333333333333332, + }, + ], + attrs: { + alignment: 'justify', + numberingProperties: { numId: 1, ilvl: 3 }, + wordLayout: { + indentLeftPx: 0, + hangingPx: 18, + firstLinePx: 0, + tabsPx: [], + textStartPx: 0, + marker: { + markerText: '1.1', + glyphWidthPx: 16.6669921875, + markerBoxWidthPx: 24.6669921875, + justification: 'left', + suffix: 'tab', + run: { + fontFamily: 'Times New Roman', + fontSize: 13.333333333333332, + }, + }, + }, + }, + }; + + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 57, + width: 309.5732421875, + ascent: 11.69921875, + descent: 2.876953125, + lineHeight: 15.33333333333333, + maxWidth: 325.7330078125, + segments: [{ runIndex: 0, fromChar: 0, toChar: 57, width: 312.90625 }], + spaceCount: 9, + }, + { + fromRun: 0, + fromChar: 57, + toRun: 0, + toChar: 61, + width: 24, + ascent: 11.69921875, + descent: 2.876953125, + lineHeight: 15.33333333333333, + maxWidth: 350.4, + segments: [{ runIndex: 0, fromChar: 57, toChar: 61, width: 24 }], + spaceCount: 0, + }, + ], + totalHeight: 30.66666666666666, + marker: { + markerWidth: 24.6669921875, + markerTextWidth: 16.6669921875, + indentLeft: 0, + gutterWidth: 8, + }, + }; + + const listLayout: Layout = { + pageSize: layout.pageSize, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-justify-list-block', + fromLine: 0, + toLine: 2, + x: 0, + y: 0, + width: 350.4, + markerWidth: 24.6669921875, + markerTextWidth: 16.6669921875, + }, + ], + }, + ], + }; + + const painter = createDomPainter({ blocks: [block], measures: [measure] }); + painter.paint(listLayout, mount); + + const lineEl = mount.querySelector('.superdoc-line') as HTMLElement; + expect(lineEl).toBeTruthy(); + const markerEl = mount.querySelector('.superdoc-paragraph-marker') as HTMLElement; + const tabEl = mount.querySelector('.superdoc-tab') as HTMLElement; + + const expectedMarkerGeometry = resolveListMarkerGeometry( + block.attrs?.wordLayout as Parameters[0], + 0, + 0, + 0, + () => 16.6669921875, + ); + + const appliedWordSpacing = Number.parseFloat(lineEl.style.wordSpacing); + const expectedWordSpacing = (325.7330078125 - 309.5732421875) / 9; + + expect(markerEl).toBeTruthy(); + expect(tabEl).toBeTruthy(); + expect(expectedMarkerGeometry).toBeTruthy(); + expect(lineEl.style.paddingLeft).toBe(`${expectedMarkerGeometry!.markerStartPx}px`); + expect(Number.parseFloat(tabEl.style.width)).toBeCloseTo(expectedMarkerGeometry!.suffixWidthPx, 4); + expect(appliedWordSpacing).toBeGreaterThan(0); + expect(appliedWordSpacing).toBeCloseTo(expectedWordSpacing, 5); + }); + it('reuses fragment DOM nodes when layout geometry changes', () => { const painter = createDomPainter({ blocks: [block], measures: [measure] }); painter.paint(layout, mount); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 801f2891bc..145ee763dc 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -85,7 +85,11 @@ import { import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { applyImageClipPath } from './utils/image-clip-path.js'; -import { computeTabWidth } from './utils/marker-helpers.js'; +import { + computeTabWidth, + resolvePainterListMarkerGeometry, + resolvePainterListTextStartPx, +} from './utils/marker-helpers.js'; import { applySdtContainerStyling, getSdtContainerKey, @@ -2745,13 +2749,38 @@ export class DomPainter { const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; - // Pre-calculate actual marker+tab inline width for list first lines. - // The measurer uses textStartPx to calculate line.maxWidth, but the painter renders - // marker+tab as inline elements that may consume MORE space than textStartPx indicates. - // This causes justify overflow when line.maxWidth > (fragment.width - actualMarkerTabWidth). - let listFirstLineMarkerTabEndPx: number | null = null; + const listFirstLineTextStartPx = + !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker + ? resolvePainterListTextStartPx({ + wordLayout, + indentLeftPx: paraIndentLeft, + hangingIndentPx: paraIndent?.hanging ?? 0, + firstLineIndentPx: paraIndent?.firstLine ?? 0, + markerTextWidthPx: fragment.markerTextWidth, + }) + : undefined; + + const shouldUseSharedInlinePrefixGeometry = + !fragment.continuesFromPrev && + fragment.markerWidth && + wordLayout?.marker?.justification === 'left' && + wordLayout.firstLineIndentMode !== true && + typeof fragment.markerTextWidth === 'number' && + Number.isFinite(fragment.markerTextWidth) && + fragment.markerTextWidth >= 0; + const listFirstLineMarkerGeometry = shouldUseSharedInlinePrefixGeometry + ? resolvePainterListMarkerGeometry({ + wordLayout, + indentLeftPx: paraIndentLeft, + hangingIndentPx: paraIndent?.hanging ?? 0, + firstLineIndentPx: paraIndent?.firstLine ?? 0, + markerTextWidthPx: fragment.markerTextWidth, + }) + : undefined; + + // Pre-calculate marker geometry used later when painting the inline prefix. let listTabWidth = 0; - let markerStartPos: number; + let markerStartPos = 0; if (!fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker) { const markerTextWidth = fragment.markerTextWidth!; const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0); @@ -2768,9 +2797,10 @@ export class DomPainter { currentPos = markerStartPos + markerTextWidth; } - // Calculate tab width using same logic as marker rendering section const suffix = wordLayout.marker.suffix ?? 'tab'; - if (suffix === 'tab') { + if (listFirstLineMarkerGeometry && (suffix === 'tab' || suffix === 'space')) { + listTabWidth = listFirstLineMarkerGeometry.suffixWidthPx; + } else if (suffix === 'tab') { listTabWidth = computeTabWidth( currentPos, markerJustification, @@ -2782,10 +2812,15 @@ export class DomPainter { } else if (suffix === 'space') { listTabWidth = 4; } - listFirstLineMarkerTabEndPx = currentPos + listTabWidth; } lines.forEach((line, index) => { + const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true; + const hasListFirstLineMarker = + index === 0 && !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker; + const shouldUseResolvedListTextStart = + hasListFirstLineMarker && hasExplicitSegmentPositioning && listFirstLineTextStartPx != null; + // Calculate available width from fragment dimensions (the actual rendered width). // This is the ground truth for justify calculations since it matches what's visible. // Only subtract positive indents - negative indents already expand fragment.width in layout @@ -2797,13 +2832,11 @@ export class DomPainter { let availableWidthOverride = line.maxWidth != null ? Math.min(line.maxWidth, fallbackAvailableWidth) : fallbackAvailableWidth; - // For list first lines, use the actual marker+tab inline width instead of line.maxWidth - // which is based on textStartPx and may not match the actual rendered inline width. - // Must also subtract paraIndentRight to match measurer's calculation: - // initialAvailableWidth = maxWidth - textStartPx - indentRight - // Only subtract positive paraIndentRight - negative indents already expand fragment.width - if (index === 0 && listFirstLineMarkerTabEndPx != null) { - availableWidthOverride = fragment.width - listFirstLineMarkerTabEndPx - Math.max(0, paraIndentRight); + // Only explicit-positioned list first lines need a painter-side width override. + // Inline list first lines already have the correct measured width in `line.maxWidth`, + // and second-guessing that width causes justified spacing regressions. + if (shouldUseResolvedListTextStart) { + availableWidthOverride = fragment.width - listFirstLineTextStartPx - Math.max(0, paraIndentRight); } // Determine if this is the true last line of the paragraph that should skip justification. @@ -2824,23 +2857,12 @@ export class DomPainter { availableWidthOverride, fragment.fromLine + index, shouldSkipJustifyForLastLine, + shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, ); // List first lines handle indentation via marker positioning and tab stops, // not CSS padding/text-indent. This matches Word's rendering model. - const isListFirstLine = - index === 0 && - !fragment.continuesFromPrev && - fragment.markerWidth && - fragment.markerTextWidth && - wordLayout?.marker; - - /** - * Determines if this line contains segments with explicit X positioning (typically from tabs). - * When segments have explicit X positions, they are rendered with absolute positioning, - * which means CSS textIndent has no effect on their placement. - */ - const hasExplicitSegmentPositioning = line.segments?.some((seg) => seg.x !== undefined); + const isListFirstLine = Boolean(hasListFirstLineMarker && fragment.markerTextWidth); /** * Identifies first lines that require special indent handling. @@ -2926,8 +2948,11 @@ export class DomPainter { } if (isListFirstLine) { - const marker = wordLayout.marker!; - lineEl.style.paddingLeft = `${paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0)}px`; // HERE CONTROLS WHERE TAB STARTS - I think this will vary with justification + const marker = wordLayout?.marker; + if (!marker) { + return; + } + lineEl.style.paddingLeft = `${paraIndentLeft + (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0)}px`; // Skip marker rendering when hidden by vanish property (preserves list indentation) if (!marker.run.vanish) { @@ -2950,10 +2975,10 @@ export class DomPainter { markerContainer.style.position = 'relative'; if (markerJustification === 'right') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification + markerContainer.style.left = `${markerStartPos}px`; } else if (markerJustification === 'center') { markerContainer.style.position = 'absolute'; - markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; // HERE CONTROLS MARKER POSITION - I think this will vary with justification + markerContainer.style.left = `${markerStartPos - fragment.markerTextWidth! / 2}px`; lineEl.style.paddingLeft = parseFloat(lineEl.style.paddingLeft) + fragment.markerTextWidth! / 2 + 'px'; } @@ -4198,6 +4223,7 @@ export class DomPainter { ctx: FragmentRenderContext, lineIndex: number, isLastLine: boolean, + resolvedListTextStartPx?: number, ): HTMLElement => { // Check if paragraph ends with a line break const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; @@ -4206,7 +4232,7 @@ export class DomPainter { // Skip justify only on the last line, unless the paragraph ends with a line break const shouldSkipJustify = isLastLine && !paragraphEndsWithLineBreak; - return this.renderLine(block, line, ctx, undefined, lineIndex, shouldSkipJustify); + return this.renderLine(block, line, ctx, undefined, lineIndex, shouldSkipJustify, resolvedListTextStartPx); }; /** @@ -5176,6 +5202,7 @@ export class DomPainter { * @param availableWidthOverride - Optional override for available width used in justification calculations * @param lineIndex - Optional zero-based index of the line within the fragment * @param skipJustify - When true, prevents justification even if alignment is 'justify' + * @param resolvedListTextStartPx - Optional canonical text-start override for list first lines * @returns The rendered line element */ private renderLine( @@ -5185,6 +5212,7 @@ export class DomPainter { availableWidthOverride?: number, lineIndex?: number, skipJustify?: boolean, + resolvedListTextStartPx?: number, ): HTMLElement { if (!this.doc) { throw new Error('DomPainter: document is not available'); @@ -5480,13 +5508,15 @@ export class DomPainter { const wordLayoutValue = (block.attrs as ParagraphAttrs | undefined)?.wordLayout; const wordLayout = isMinimalWordLayout(wordLayoutValue) ? wordLayoutValue : undefined; const isListParagraph = Boolean(wordLayout?.marker); - const rawTextStartPx = + const fallbackListTextStartPx = typeof wordLayout?.marker?.textStartX === 'number' && Number.isFinite(wordLayout.marker.textStartX) ? wordLayout.marker.textStartX : typeof wordLayout?.textStartPx === 'number' && Number.isFinite(wordLayout.textStartPx) ? wordLayout.textStartPx : undefined; - const listIndentOffset = isFirstLineOfPara ? (rawTextStartPx ?? indentLeft) : indentLeft; + const listIndentOffset = isFirstLineOfPara + ? (resolvedListTextStartPx ?? fallbackListTextStartPx ?? indentLeft) + : indentLeft; const indentOffset = isListParagraph ? listIndentOffset : indentLeft + firstLineOffsetForCumX; let cumulativeX = 0; // Start at 0, we'll add indentOffset when positioning const segmentsByRun = new Map(); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 7708d45a62..fef1f2acb2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -36,7 +36,11 @@ import { getSdtContainerKey, type SdtBoundaryOptions, } from '../utils/sdt-helpers.js'; -import { computeTabWidth } from '../utils/marker-helpers.js'; +import { + computeTabWidth, + resolvePainterListMarkerGeometry, + resolvePainterListTextStartPx, +} from '../utils/marker-helpers.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; @@ -235,6 +239,8 @@ type MarkerRenderParams = { doc: Document; /** Line element to which the marker will be attached */ lineEl: HTMLElement; + /** Full word-layout information for this paragraph */ + wordLayout?: WordLayoutInfo; /** Marker layout information from word-layout engine */ markerLayout: WordLayoutMarker; /** Marker measurement data from measurement stage */ @@ -275,24 +281,39 @@ type TableCellIndentParams = { * Renders a list marker (bullet or number) for a paragraph line inside a table cell. * * Mirrors the top-level renderer approach: the marker and suffix separator are prepended - * inside `lineEl` as inline elements, and `lineEl.paddingLeft` controls the text start - * position. This avoids a wrapper div and ensures consistent positioning across all - * justification modes. - * - * **Anchor Point Model:** - * The anchor point (`indentLeftPx - hangingIndent + firstLineIndent`) is where the - * numbering position is defined in OOXML. Justification determines how the marker text - * aligns relative to this point: - * - `left`: Marker text starts at anchor, flows right - * - `right`: Marker text ends at anchor - * - `center`: Marker text is centered on anchor - * - * After the marker, a suffix separator (tab/space/nothing) fills the gap to the text start. + * inside `lineEl`, and `lineEl.paddingLeft` controls the text start position. This keeps + * table cell list markers aligned with the top-level paragraph renderer. * * @param params - Marker rendering parameters */ function renderListMarker(params: MarkerRenderParams): void { - const { doc, lineEl, markerLayout, markerMeasure, indentLeftPx, hangingIndentPx, firstLineIndentPx, tabsPx } = params; + const { + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + tabsPx, + } = params; + + const shouldUseSharedInlinePrefixGeometry = + markerLayout?.justification === 'left' && + wordLayout?.firstLineIndentMode !== true && + typeof markerMeasure?.markerTextWidth === 'number' && + Number.isFinite(markerMeasure.markerTextWidth) && + markerMeasure.markerTextWidth >= 0; + const markerGeometry = shouldUseSharedInlinePrefixGeometry + ? resolvePainterListMarkerGeometry({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx: markerMeasure?.markerTextWidth, + }) + : undefined; const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; @@ -313,7 +334,9 @@ function renderListMarker(params: MarkerRenderParams): void { const suffix = markerLayout?.suffix ?? 'tab'; let listTabWidth = 0; - if (suffix === 'tab') { + if (markerGeometry && (suffix === 'tab' || suffix === 'space')) { + listTabWidth = markerGeometry.suffixWidthPx; + } else if (suffix === 'tab') { listTabWidth = computeTabWidth( currentPos, markerJustification, @@ -528,6 +551,7 @@ type EmbeddedTableRenderParams = { context: FragmentRenderContext, lineIndex: number, isLastLine: boolean, + resolvedListTextStartPx?: number, ) => HTMLElement; /** Optional callback invoked after a table line's final styles/markers are applied. */ captureLineSnapshot?: ( @@ -856,6 +880,7 @@ type TableCellRenderDependencies = { context: FragmentRenderContext, lineIndex: number, isLastLine: boolean, + resolvedListTextStartPx?: number, ) => HTMLElement; /** Optional callback invoked after a table line's final styles/markers are applied. */ captureLineSnapshot?: ( @@ -1310,6 +1335,16 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const firstLineIndentPx = block.attrs?.indent && typeof block.attrs.indent.firstLine === 'number' ? block.attrs.indent.firstLine : 0; const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const listFirstLineTextStartPx = + markerLayout && markerMeasure + ? resolvePainterListTextStartPx({ + wordLayout: wordLayout ?? undefined, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx: markerMeasure.markerTextWidth, + }) + : undefined; // Calculate the global line indices for this block const blockStartGlobal = cumulativeLineCount; @@ -1382,6 +1417,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen { ...context, section: 'body' }, lineIdx, isLastLine, + lineIdx === 0 && localStartLine === 0 ? listFirstLineTextStartPx : undefined, ); lineEl.style.paddingLeft = ''; lineEl.style.paddingRight = ''; @@ -1406,6 +1442,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderListMarker({ doc, lineEl, + wordLayout: wordLayout ?? undefined, markerLayout, markerMeasure, indentLeftPx, diff --git a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts index ef53cdcc4c..1aefd1ec68 100644 --- a/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts +++ b/packages/layout-engine/painters/dom/src/utils/marker-helpers.ts @@ -1,24 +1,89 @@ +import { + resolveListMarkerGeometry, + resolveListTextStartPx, + type MinimalMarker, + type MinimalWordLayout, + type ResolvedListMarkerGeometry, +} from '@superdoc/common/list-marker-utils'; + /** * Default tab interval in pixels (0.5 inch at 96 DPI). - * Used when calculating tab stops for list markers that extend past the implicit tab stop. - * This matches Microsoft Word's default tab interval behavior. + * Used by the legacy painter path for marker suffix tabs that do not yet route + * through the shared list geometry helper. */ const DEFAULT_TAB_INTERVAL_PX = 48; +type PainterListTextStartParams = { + wordLayout: MinimalWordLayout | undefined; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + markerTextWidthPx?: number; +}; + +const getFiniteNonNegativeNumber = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return undefined; + } + return value; +}; + +/** + * Resolves marker width using the already-measured glyph width from layout whenever possible. + */ +const resolvePainterMarkerTextWidth = ( + markerTextWidthPx: number | undefined, + marker: { glyphWidthPx?: number; markerBoxWidthPx?: number }, +): number => + getFiniteNonNegativeNumber(markerTextWidthPx) ?? + getFiniteNonNegativeNumber(marker.glyphWidthPx) ?? + getFiniteNonNegativeNumber(marker.markerBoxWidthPx) ?? + 0; + +/** + * Resolves the canonical marker geometry for a list first line while letting the + * painter reuse the measured marker glyph width instead of remeasuring text. + */ +export const resolvePainterListMarkerGeometry = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): ResolvedListMarkerGeometry | undefined => + resolveListMarkerGeometry( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +/** + * Resolves the canonical text-start position for a list first line while letting + * the painter reuse the measured marker glyph width instead of remeasuring text. + */ +export const resolvePainterListTextStartPx = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): number | undefined => + resolveListTextStartPx( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + /** * Compute the width of the tab separator between a list marker and its text content. * - * Finds the next tab stop past `currentPos` (the x position after the marker text) - * using explicit tab stops first, then falling back to default 48px intervals. - * For hanging indents, an implicit tab stop is injected at `leftIndent`. - * - * @param currentPos - X position after the marker text ends (pixels) - * @param justification - Marker justification ('left', 'right', or 'center') - * @param tabs - Explicit tab stop positions in pixels - * @param hangingIndent - Hanging indent in pixels - * @param firstLineIndent - First line indent in pixels - * @param leftIndent - Left indent in pixels (paraIndentLeft) - * @returns Width of the tab separator in pixels + * This legacy painter path is still used for marker modes whose rendering contract + * differs from the shared geometry helper today, such as right/center-justified + * markers and firstLineIndentMode paragraphs. */ export const computeTabWidth = ( currentPos: number, @@ -31,10 +96,8 @@ export const computeTabWidth = ( const nextDefaultTabStop = currentPos + DEFAULT_TAB_INTERVAL_PX - (currentPos % DEFAULT_TAB_INTERVAL_PX); let tabWidth: number; if (justification === 'left') { - // Check for explicit tab stops past current position const explicitTabs = [...(tabs ?? [])]; if (hangingIndent && hangingIndent > 0) { - // Account for hanging indent by adding an implicit tab stop at leftIndent explicitTabs.push(leftIndent); explicitTabs.sort((a, b) => a - b); } @@ -48,7 +111,6 @@ export const computeTabWidth = ( } if (targetTabStop === undefined) { - // Advance to next default 48px tab interval, matching Word behavior. targetTabStop = nextDefaultTabStop; } tabWidth = targetTabStop - currentPos; diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index 625db71161..fbb6f52746 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -326,7 +326,7 @@ export function paragraphToFlowBlocks({ kind: 'paragraph', id: baseBlockId, runs: [emptyRun], - attrs: deepClone(paragraphAttrs), + attrs: emptyParagraphAttrs, }); return blocks; } diff --git a/packages/super-editor/src/core/parts/invalidation/invalidation-handlers.ts b/packages/super-editor/src/core/parts/invalidation/invalidation-handlers.ts index 5c21152388..2f18ef758c 100644 --- a/packages/super-editor/src/core/parts/invalidation/invalidation-handlers.ts +++ b/packages/super-editor/src/core/parts/invalidation/invalidation-handlers.ts @@ -7,7 +7,7 @@ */ import type { Editor } from '../../Editor.js'; -import type { PartChangedEvent, PartId } from '../types.js'; +import type { PartChangedEvent, PartId } from '../types.js'; import { registerInvalidationHandler } from './part-invalidation-registry.js'; // --------------------------------------------------------------------------- diff --git a/packages/word-layout/src/index.ts b/packages/word-layout/src/index.ts index d9116d54f7..865d161e7c 100644 --- a/packages/word-layout/src/index.ts +++ b/packages/word-layout/src/index.ts @@ -5,7 +5,7 @@ */ import type { WordParagraphLayoutInput, WordParagraphLayoutOutput, WordListSuffix } from './types.js'; -import { DEFAULT_LIST_HANGING_PX } from './marker-utils.js'; +import { DEFAULT_LIST_HANGING_PX, LIST_MARKER_GAP } from './marker-utils.js'; import { twipsToPixels } from './unit-conversions.js'; export * from './types.js'; @@ -130,11 +130,10 @@ export function computeWordParagraphLayout(input: WordParagraphLayoutInput): Wor layout.marker = { markerText: listRenderingAttrs.markerText, - // markerBoxWidthPx: markerBoxWidthPx + 1000, - // markerX, - // textStartX: layout.textStartPx, - // Gutter is the small gap between marker and text, not the full marker box width - // gutterWidthPx: LIST_MARKER_GAP, + markerBoxWidthPx, + markerX, + textStartX: layout.textStartPx, + gutterWidthPx: LIST_MARKER_GAP, justification: listRenderingAttrs.justification ?? 'left', suffix: normalizeSuffix(listRenderingAttrs.suffix), run: markerRun, diff --git a/packages/word-layout/src/types.ts b/packages/word-layout/src/types.ts index de6f2b567d..f616b3cce4 100644 --- a/packages/word-layout/src/types.ts +++ b/packages/word-layout/src/types.ts @@ -90,9 +90,12 @@ export type WordParagraphLayoutInput = { export type WordListMarkerLayout = { markerText: string; + markerBoxWidthPx?: number; gutterWidthPx?: number; justification: WordListJustification; + markerX?: number; suffix: WordListSuffix; + textStartX?: number; run: ResolvedRunProperties; }; diff --git a/packages/word-layout/tests/word-layout.test.ts b/packages/word-layout/tests/word-layout.test.ts index 1e0de992f5..93e994f0b4 100644 --- a/packages/word-layout/tests/word-layout.test.ts +++ b/packages/word-layout/tests/word-layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { computeWordParagraphLayout, DEFAULT_LIST_HANGING_PX } from '../src/index.js'; +import { computeWordParagraphLayout, DEFAULT_LIST_HANGING_PX, LIST_MARKER_GAP } from '../src/index.js'; import type { WordParagraphLayoutInput } from '../src/types.js'; const buildInput = (overrides: Partial = {}): WordParagraphLayoutInput => ({ @@ -30,6 +30,10 @@ describe('computeWordParagraphLayout', () => { expect(layout.tabsPx).toEqual([72]); expect(layout.defaultTabIntervalPx).toBe(720); expect(layout.marker?.markerText).toBe('3.'); + expect(layout.marker?.markerBoxWidthPx).toBe(18); + expect(layout.marker?.markerX).toBe(18); + expect(layout.marker?.textStartX).toBe(36); + expect(layout.marker?.gutterWidthPx).toBe(LIST_MARKER_GAP); expect(layout.marker?.justification).toBe('left'); expect(layout.marker?.suffix).toBe('tab'); expect(layout.marker?.run.fontFamily).toBe('Calibri'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f485f9f42a..5c5c9e97d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -834,6 +834,9 @@ importers: packages/layout-engine/painters/dom: dependencies: + '@superdoc/common': + specifier: workspace:* + version: link:../../../../shared/common '@superdoc/contracts': specifier: workspace:* version: link:../../contracts @@ -18535,7 +18538,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.9 + minimatch: 9.0.5 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -19643,7 +19646,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.4 + semver: 7.7.3 jsprim@2.0.2: dependencies: diff --git a/shared/common/list-marker-utils.test.ts b/shared/common/list-marker-utils.test.ts index 2d39dd739d..cccc57b481 100644 --- a/shared/common/list-marker-utils.test.ts +++ b/shared/common/list-marker-utils.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { resolveListTextStartPx, type MinimalWordLayout, type MinimalMarker } from './list-marker-utils.js'; +import { + resolveListMarkerGeometry, + resolveListTextStartPx, + type MinimalWordLayout, + type MinimalMarker, +} from './list-marker-utils.js'; import { LIST_MARKER_GAP, SPACE_SUFFIX_GAP_PX, DEFAULT_TAB_INTERVAL_PX } from './layout-constants.js'; describe('resolveListTextStartPx', () => { @@ -239,6 +244,29 @@ describe('resolveListTextStartPx', () => { expect(result).toBe(26); }); + it('prefers the next explicit tab stop over a stale first-line text start target', () => { + const wordLayout: MinimalWordLayout = { + firstLineIndentMode: true, + textStartPx: 48, + tabsPx: [144], + marker: { + markerX: 0, + glyphWidthPx: 20, + suffix: 'tab', + }, + }; + + const geometry = resolveListMarkerGeometry(wordLayout, 0, 0, 0, mockMeasureMarkerText); + + expect(geometry).toEqual({ + markerStartPx: 0, + markerTextWidthPx: 20, + textStartPx: 144, + suffixWidthPx: 124, + }); + expect(resolveListTextStartPx(wordLayout, 0, 0, 0, mockMeasureMarkerText)).toBe(144); + }); + it('uses textStartX when no tab stop found', () => { const wordLayout: MinimalWordLayout = { firstLineIndentMode: true, @@ -312,6 +340,76 @@ describe('resolveListTextStartPx', () => { }); describe('standard hanging indent mode', () => { + it('ignores paragraph tab stops when textStartPx is present', () => { + // Regression: tabsPx must NOT override textStartPx in standard hanging-indent mode. + // Paragraph tabs are for inline w:tab characters, not list-prefix positioning. + const wordLayout: MinimalWordLayout = { + marker: { glyphWidthPx: 10, suffix: 'tab' }, + textStartPx: 24, + tabsPx: [48, 96], + }; + // markerStart = 24 - 18 = 6, markerContentEnd = 16 + // textStartPx = 24 clears the glyph → used directly + const result = resolveListTextStartPx(wordLayout, 24, 0, 18, mockMeasureMarkerText); + + expect(result).toBe(24); + }); + + it('ignores numbering-tab metadata drift in tabsPx', () => { + // Regression (list-mixed-abstract-ids): numbering tab metadata in tabsPx + // must not nudge text a few px past the hanging-indent text start. + const wordLayout: MinimalWordLayout = { + marker: { glyphWidthPx: 8, suffix: 'tab' }, + textStartPx: 24, + tabsPx: [26], // numbering tab slightly past textStartPx + }; + const result = resolveListTextStartPx(wordLayout, 24, 0, 18, mockMeasureMarkerText); + + expect(result).toBe(24); + }); + + it('leaves later inline tabs alone when resolving list prefix', () => { + // Regression (HVY-20): a later paragraph tab stop (e.g. after "Payment.") + // must not be consumed by the list-prefix helper. + const wordLayout: MinimalWordLayout = { + marker: { glyphWidthPx: 6, suffix: 'tab' }, + textStartPx: 24, + tabsPx: [192], // far-right tab for inline w:tab use + }; + const result = resolveListTextStartPx(wordLayout, 24, 0, 18, mockMeasureMarkerText); + + expect(result).toBe(24); + }); + + it('uses the explicit text start when a wide marker box still leaves clear space after the glyph', () => { + const wordLayout: MinimalWordLayout = { + marker: { + glyphWidthPx: 5, + markerBoxWidthPx: 24, + suffix: 'tab', + }, + textStartPx: 24, + }; + + const result = resolveListTextStartPx(wordLayout, 24, 0, 24, mockMeasureMarkerText); + + expect(result).toBe(24); + }); + + it('uses markerBoxWidthPx to advance a zero-indent heading marker to the next default tab stop', () => { + const wordLayout: MinimalWordLayout = { + marker: { + glyphWidthPx: 11, + markerBoxWidthPx: 18, + suffix: 'tab', + }, + }; + + const result = resolveListTextStartPx(wordLayout, 0, 0, 0, mockMeasureMarkerText); + + expect(result).toBe(DEFAULT_TAB_INTERVAL_PX); + }); + it('calculates marker start from indents', () => { const wordLayout: MinimalWordLayout = { marker: { @@ -476,7 +574,7 @@ describe('resolveListTextStartPx', () => { expect(result).toBe(36); // 28 + 8 = 36 }); - it('enforces minimum gutterWidth when textStartPx is close to currentPosStandard', () => { + it('uses textStartPx directly when it already clears the visible marker glyph', () => { const wordLayout: MinimalWordLayout = { marker: { glyphWidthPx: 15, @@ -487,12 +585,11 @@ describe('resolveListTextStartPx', () => { const indentLeft = 36; const hanging = 18; // markerStartPos = 36 - 18 = 18 - // currentPosStandard = 18 + 15 = 33 - // gap = max(36 - 33, 8) = max(3, 8) = 8 (gutterWidth enforced) + // markerContentEnd = 18 + 15 = 33 + // textStartPx = 36 already clears the visible marker glyph, so it is used as-is const result = resolveListTextStartPx(wordLayout, indentLeft, 0, hanging, mockMeasureMarkerText); - expect(result).toBe(33 + LIST_MARKER_GAP); // 33 + 8 = 41 - expect(result).toBeGreaterThanOrEqual(33 + LIST_MARKER_GAP); + expect(result).toBe(36); }); it('enforces minimum gutterWidth when textStartPx is behind currentPosStandard', () => { @@ -665,12 +762,11 @@ describe('resolveListTextStartPx', () => { marker: { glyphWidthPx: 12, suffix: 'tab' }, textStartPx: 48, }; - // markerStartPos = 48 - 18 = 30, currentPosStandard = 30 + 12 = 42 - // gap = max(48 - 42, 8) = max(6, 8) = 8 + // markerStartPos = 48 - 18 = 30, markerContentEnd = 30 + 12 = 42 + // textStartPx = 48 already clears the visible marker glyph, so it is used as-is const result = resolveListTextStartPx(wordLayout, 48, 0, 18, mockMeasureMarkerText); - expect(result).toBe(42 + LIST_MARKER_GAP); // 42 + 8 = 50 (gap enforced to minimum) - expect(result).toBe(50); + expect(result).toBe(48); }); }); @@ -683,12 +779,10 @@ describe('resolveListTextStartPx', () => { textStartPx: 12, }; // indentLeft=12, hanging=12 → markerStartPos = 0 - // currentPosStandard = 0 + 10 = 10 - // gap = max(12 - 10, 8) = max(2, 8) = 8 + // markerContentEnd = 10, so textStartPx = 12 is valid and used directly const result = resolveListTextStartPx(wordLayout, 12, 0, 12, mockMeasureMarkerText); - expect(result).toBe(10 + LIST_MARKER_GAP); // 18 - expect(result).toBeGreaterThanOrEqual(10 + LIST_MARKER_GAP); + expect(result).toBe(12); }); it('marker overflows tiny hanging space', () => { @@ -729,11 +823,11 @@ describe('resolveListTextStartPx', () => { textStartPx: 36, }; // indentLeft=36, hanging=18 → markerStartPos = 18 - // currentPosStandard = 18 + 16 = 34 - // gap = max(36 - 34, 8) = max(2, 8) = 8 + // markerContentEnd = 18 + 16 = 34 + // textStartPx = 36 already clears the visible marker glyph, so it is used directly const result = resolveListTextStartPx(wordLayout, 36, 0, 18, mockMeasureMarkerText); - expect(result).toBe(34 + LIST_MARKER_GAP); // 42 + expect(result).toBe(36); }); }); diff --git a/shared/common/list-marker-utils.ts b/shared/common/list-marker-utils.ts index 276b9b4ba6..6c6639ee69 100644 --- a/shared/common/list-marker-utils.ts +++ b/shared/common/list-marker-utils.ts @@ -79,289 +79,285 @@ export type MinimalWordLayout = { export type MarkerTextMeasurer = (markerText: string, marker: MinimalMarker) => number; /** - * Resolves the horizontal pixel position where list item text should start. + * Resolved list prefix geometry for a single numbered or bulleted paragraph line. * - * This is the authoritative implementation of list marker text positioning logic, - * used across all measurement and layout subsystems. It handles multiple rendering modes: - * - * **Standard Hanging Indent Mode:** - * - Marker positioned in hanging indent area (absolute positioning) - * - Text starts at paraIndentLeft - * - Tab after marker advances to next tab stop or firstLine indent position - * - * **First-Line Indent Mode (input-rule created lists):** - * - Marker positioned at paraIndentLeft + firstLine (inline with text flow) - * - Tab after marker advances to first available tab stop or textStartPx - * - Matches Word's rendering behavior for auto-numbered lists - * - * **Suffix Handling:** - * - 'space': Add SPACE_SUFFIX_GAP_PX (4px) gap after marker - * - 'nothing': Text immediately follows marker with no gap - * - 'tab': Advance to next tab stop or calculated position - * - * **Justification Modes:** - * - 'left': Standard tab-based spacing - * - 'center'/'right': Use gutterWidthPx with minimum of LIST_MARKER_GAP - * - * Algorithm: - * 1. Determine marker text width (use glyphWidthPx if available, otherwise measure or use markerBoxWidth) - * 2. Calculate marker start position (markerX for firstLineIndentMode, else paraIndentLeft - hanging + firstLine) - * 3. Apply suffix-specific spacing: - * - 'space': markerStart + markerWidth + SPACE_SUFFIX_GAP_PX - * - 'nothing': markerStart + markerWidth - * - 'tab': Calculate tab width based on mode and justification - * 4. For 'tab' suffix with center/right justification: use gutterWidth - * 5. For 'tab' suffix with left justification in firstLineIndentMode: find next tab stop or use textStartX - * 6. For 'tab' suffix with left justification in standard mode: calculate tab to firstLine indent - * - * @param wordLayout - Word layout configuration containing marker info and positioning mode - * @param indentLeft - Left paragraph indent in pixels (base position for standard mode) - * @param firstLine - First-line indent in pixels (offset from left indent) - * @param hanging - Hanging indent in pixels (creates space for marker in standard mode) - * @param measureMarkerText - Function to measure marker text width if glyphWidthPx not available. - * Should return width in pixels. For list-indent-utils, can return provided markerWidth fallback. - * @returns Horizontal pixel position where text content should begin, or undefined if no marker present. - * This value represents the X coordinate from the left edge of the paragraph content area. - * - * @example - * ```typescript - * // Standard hanging indent list (marker in margin) - * const textStart = resolveListTextStartPx( - * { - * marker: { - * glyphWidthPx: 20, - * suffix: 'tab', - * justification: 'left' - * } - * }, - * 36, // indentLeft - * 0, // firstLine - * 18, // hanging - * () => 20 // measureMarkerText (not called since glyphWidthPx provided) - * ); - * // Returns: ~36 (text starts at indentLeft after tab) - * ``` - * - * @example - * ```typescript - * // First-line indent mode list (input-rule created) - * const textStart = resolveListTextStartPx( - * { - * firstLineIndentMode: true, - * marker: { - * markerX: 0, - * glyphWidthPx: 18, - * textStartX: 48, - * suffix: 'tab', - * justification: 'left' - * } - * }, - * 0, // indentLeft - * 0, // firstLine - * 0, // hanging - * () => 18 - * ); - * // Returns: 48 (from textStartX) - * ``` - * - * @example - * ```typescript - * // Space suffix (rare but valid) - * const textStart = resolveListTextStartPx( - * { - * marker: { - * glyphWidthPx: 15, - * suffix: 'space', - * markerX: 0 - * }, - * firstLineIndentMode: true - * }, - * 0, 0, 0, - * () => 15 - * ); - * // Returns: 19 (markerX:0 + glyphWidth:15 + SPACE_SUFFIX_GAP_PX:4) - * ``` + * All coordinates are measured from the left edge of the paragraph content area. */ -export function resolveListTextStartPx( +export type ResolvedListMarkerGeometry = { + /** Left edge where the visible marker glyph should be painted. */ + markerStartPx: number; + /** Visible marker glyph width in pixels. */ + markerTextWidthPx: number; + /** Horizontal position where paragraph text begins after marker + suffix. */ + textStartPx: number; + /** Width contributed by the suffix separator (tab, space, or nothing). */ + suffixWidthPx: number; +}; + +const getFiniteNumber = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined; + } + return value; +}; + +const getNonNegativeFiniteNumber = (value: unknown): number | undefined => { + const numericValue = getFiniteNumber(value); + if (numericValue == null || numericValue < 0) { + return undefined; + } + return numericValue; +}; + +const getMarkerTextWidthPx = (marker: MinimalMarker, measureMarkerText: MarkerTextMeasurer): number => { + const glyphWidthPx = getNonNegativeFiniteNumber(marker.glyphWidthPx); + if (glyphWidthPx != null) { + return glyphWidthPx; + } + + if (marker.markerText) { + const measuredWidthPx = measureMarkerText(marker.markerText, marker); + const safeMeasuredWidthPx = getNonNegativeFiniteNumber(measuredWidthPx); + if (safeMeasuredWidthPx != null) { + return safeMeasuredWidthPx; + } + } + + return getNonNegativeFiniteNumber(marker.markerBoxWidthPx) ?? 0; +}; + +const getMarkerBoxWidthPx = (marker: MinimalMarker, markerTextWidthPx: number): number => + Math.max(getNonNegativeFiniteNumber(marker.markerBoxWidthPx) ?? 0, markerTextWidthPx); + +const getExplicitFirstLineMarkerStartPx = ( wordLayout: MinimalWordLayout | undefined, - indentLeft: number, - firstLine: number, - hanging: number, - measureMarkerText: MarkerTextMeasurer, -): number | undefined { - const marker = wordLayout?.marker; - if (!marker) { - const textStartPx = - wordLayout?.firstLineIndentMode === true && - typeof wordLayout.textStartPx === 'number' && - Number.isFinite(wordLayout.textStartPx) - ? wordLayout.textStartPx - : undefined; - return textStartPx; + marker: MinimalMarker, +): number | undefined => { + if (wordLayout?.firstLineIndentMode !== true) { + return undefined; } - // Step 1: Determine marker box width (fallback for text width if needed) - const markerBoxWidth = - typeof marker.markerBoxWidthPx === 'number' && Number.isFinite(marker.markerBoxWidthPx) - ? marker.markerBoxWidthPx - : 0; + return getFiniteNumber(marker.markerX); +}; - // Step 2: Determine marker text width - let markerTextWidth = - typeof marker.glyphWidthPx === 'number' && Number.isFinite(marker.glyphWidthPx) ? marker.glyphWidthPx : undefined; +const getMarkerAnchorPx = (indentLeft: number, firstLine: number, hanging: number): number => + indentLeft - hanging + firstLine; - // If glyphWidthPx not available and marker has text, measure it - if (markerTextWidth == null && marker.markerText) { - markerTextWidth = measureMarkerText(marker.markerText, marker); +const getMarkerStartPx = (anchorPx: number, justification: string, markerTextWidthPx: number): number => { + if (justification === 'right') { + return anchorPx - markerTextWidthPx; + } + if (justification === 'center') { + return anchorPx - markerTextWidthPx / 2; } + return anchorPx; +}; - // Fallback to marker box width if measurement failed or unavailable - if (!Number.isFinite(markerTextWidth) || (markerTextWidth !== undefined && markerTextWidth < 0)) { - markerTextWidth = markerBoxWidth; +const getNextExplicitTabStopPx = (tabsPx: number[] | undefined, currentPosPx: number): number | undefined => { + if (!Array.isArray(tabsPx)) { + return undefined; } - // Ensure non-negative width (markerTextWidth is guaranteed to be a number here) - const finalMarkerTextWidth = Math.max(0, markerTextWidth ?? 0); - - // Step 3: Determine marker start position - let markerStartPos: number; - if ( - wordLayout?.firstLineIndentMode === true && - typeof marker.markerX === 'number' && - Number.isFinite(marker.markerX) - ) { - // First-line indent mode: marker positioned at markerX - markerStartPos = marker.markerX; - } else { - // Standard mode: marker in hanging indent area - markerStartPos = indentLeft - hanging + firstLine; + for (const tabPx of tabsPx) { + if (typeof tabPx === 'number' && Number.isFinite(tabPx) && tabPx > currentPosPx) { + return tabPx; + } } - // Validate marker start position - if (!Number.isFinite(markerStartPos)) { - markerStartPos = 0; + return undefined; +}; + +const getFirstLineTextStartTargetPx = ( + wordLayout: MinimalWordLayout | undefined, + marker: MinimalMarker, +): number | undefined => { + return getFiniteNumber(marker.textStartX) ?? getFiniteNumber(wordLayout?.textStartPx); +}; + +const getNextDefaultTabStopPx = (currentPosPx: number): number => { + const remainderPx = currentPosPx % DEFAULT_TAB_INTERVAL_PX; + if (remainderPx === 0) { + return currentPosPx + DEFAULT_TAB_INTERVAL_PX; } + return currentPosPx + DEFAULT_TAB_INTERVAL_PX - remainderPx; +}; - // Current horizontal position after marker - const currentPos = markerStartPos + finalMarkerTextWidth; - const suffix = marker.suffix ?? 'tab'; +const getMinimumReadableTextStartPx = (markerContentEndPx: number, gutterWidthPx: number): number => + markerContentEndPx + gutterWidthPx; - // Step 4: Handle 'space' suffix - if (suffix === 'space') { - return markerStartPos + finalMarkerTextWidth + SPACE_SUFFIX_GAP_PX; +const resolveExplicitStandardTextStartPx = ( + explicitTextStartPx: number | undefined, + markerContentEndPx: number, + gutterWidthPx: number, +): number | undefined => { + if (explicitTextStartPx == null) { + return undefined; + } + + if (explicitTextStartPx > markerContentEndPx) { + return explicitTextStartPx; + } + + if (explicitTextStartPx > 0) { + return getMinimumReadableTextStartPx(markerContentEndPx, gutterWidthPx); + } + + return undefined; +}; + +/** + * Resolves full marker geometry for a list prefix on a paragraph line. + * + * This is the canonical geometry source for marker start, suffix width, and + * paragraph text start. Measurement and painting should both use this helper + * so they stay aligned on numbered-list first lines. + * + * @param wordLayout - Word list layout metadata for the paragraph + * @param indentLeft - Paragraph left indent in pixels + * @param firstLine - Paragraph first-line indent in pixels + * @param hanging - Paragraph hanging indent in pixels + * @param measureMarkerText - Callback used when marker glyph width is not precomputed + * @returns Resolved list prefix geometry, or undefined when the paragraph has no marker + */ +export function resolveListMarkerGeometry( + wordLayout: MinimalWordLayout | undefined, + indentLeft: number, + firstLine: number, + hanging: number, + measureMarkerText: MarkerTextMeasurer, +): ResolvedListMarkerGeometry | undefined { + const marker = wordLayout?.marker; + if (!marker) { + return undefined; } - // Step 5: Handle 'nothing' suffix + const markerTextWidthPx = getMarkerTextWidthPx(marker, measureMarkerText); + const markerBoxWidthPx = getMarkerBoxWidthPx(marker, markerTextWidthPx); + const justification = marker.justification ?? 'left'; + const explicitFirstLineMarkerStartPx = getExplicitFirstLineMarkerStartPx(wordLayout, marker); + const anchorPx = getMarkerAnchorPx(indentLeft, firstLine, hanging); + const markerStartPx = explicitFirstLineMarkerStartPx ?? getMarkerStartPx(anchorPx, justification, markerTextWidthPx); + const markerContentEndPx = markerStartPx + markerTextWidthPx; + const suffix = marker.suffix ?? 'tab'; + if (suffix === 'nothing') { - return markerStartPos + finalMarkerTextWidth; + return { + markerStartPx, + markerTextWidthPx, + textStartPx: markerContentEndPx, + suffixWidthPx: 0, + }; } - // Step 6: Handle 'tab' suffix with justification - const markerJustification = marker.justification ?? 'left'; - // Use the larger of box vs glyph as the effective marker width to ensure we clear the rendered box - const markerWidthEffective = Math.max( - typeof marker.markerBoxWidthPx === 'number' && Number.isFinite(marker.markerBoxWidthPx) - ? marker.markerBoxWidthPx - : 0, - finalMarkerTextWidth, - ); + if (suffix === 'space') { + return { + markerStartPx, + markerTextWidthPx, + textStartPx: markerContentEndPx + SPACE_SUFFIX_GAP_PX, + suffixWidthPx: SPACE_SUFFIX_GAP_PX, + }; + } - // Center/right justification: use gutter width - if (markerJustification !== 'left') { - const gutterWidth = - typeof marker.gutterWidthPx === 'number' && Number.isFinite(marker.gutterWidthPx) && marker.gutterWidthPx > 0 - ? marker.gutterWidthPx - : LIST_MARKER_GAP; - return markerStartPos + finalMarkerTextWidth + Math.max(gutterWidth, LIST_MARKER_GAP); + if (justification !== 'left') { + const gutterWidthPx = Math.max(getNonNegativeFiniteNumber(marker.gutterWidthPx) ?? 0, LIST_MARKER_GAP); + return { + markerStartPx, + markerTextWidthPx, + textStartPx: markerContentEndPx + gutterWidthPx, + suffixWidthPx: gutterWidthPx, + }; } - // Step 7: Left justification with 'tab' suffix in first-line indent mode if (wordLayout?.firstLineIndentMode === true) { - // Find next tab stop after marker - let targetTabStop: number | undefined; - if (Array.isArray(wordLayout.tabsPx)) { - for (const tab of wordLayout.tabsPx) { - if (typeof tab === 'number' && tab > currentPos) { - targetTabStop = tab; - break; - } - } - } + const explicitTabStopPx = getNextExplicitTabStopPx(wordLayout.tabsPx, markerContentEndPx); + const textStartTargetPx = getFirstLineTextStartTargetPx(wordLayout, marker); - // Determine text start target (prefer textStartX over textStartPx) - const textStartTarget = - typeof marker.textStartX === 'number' && Number.isFinite(marker.textStartX) - ? marker.textStartX - : wordLayout.textStartPx; - - // Calculate tab width - let tabWidth: number; - if (targetTabStop !== undefined) { - // Use explicit tab stop - tabWidth = targetTabStop - currentPos; - } else if (textStartTarget !== undefined && Number.isFinite(textStartTarget) && textStartTarget > currentPos) { - // Use pre-calculated text start position - tabWidth = textStartTarget - currentPos; + let textStartPx: number; + if (explicitTabStopPx != null) { + textStartPx = explicitTabStopPx; + } else if (textStartTargetPx != null && textStartTargetPx > markerContentEndPx) { + textStartPx = textStartTargetPx; } else { - // Fallback to minimum gap - tabWidth = LIST_MARKER_GAP; + textStartPx = markerContentEndPx + LIST_MARKER_GAP; } - // Enforce minimum tab width - if (tabWidth < LIST_MARKER_GAP) { - tabWidth = LIST_MARKER_GAP; + if (textStartPx - markerContentEndPx < LIST_MARKER_GAP) { + textStartPx = markerContentEndPx + LIST_MARKER_GAP; } - return markerStartPos + finalMarkerTextWidth + tabWidth; + return { + markerStartPx, + markerTextWidthPx, + textStartPx, + suffixWidthPx: textStartPx - markerContentEndPx, + }; } - // Step 8: Left justification with 'tab' suffix in standard mode - const textStartTarget = - typeof wordLayout?.textStartPx === 'number' && Number.isFinite(wordLayout.textStartPx) - ? wordLayout.textStartPx - : undefined; - const gutterWidth = - typeof marker.gutterWidthPx === 'number' && Number.isFinite(marker.gutterWidthPx) && marker.gutterWidthPx > 0 - ? marker.gutterWidthPx - : LIST_MARKER_GAP; - const currentPosStandard = markerStartPos + markerWidthEffective; - - // Check for explicit tab stops past the marker position. - // The renderer uses these to position the tab after the list marker, so the measurer - // must also account for them to avoid a width mismatch that causes extreme negative word-spacing. - let explicitTabStop: number | undefined; - if (Array.isArray(wordLayout?.tabsPx)) { - for (const tab of wordLayout.tabsPx) { - if (typeof tab === 'number' && tab > currentPosStandard) { - explicitTabStop = tab; - break; - } - } + // Standard hanging-indent: text lands at the hanging-indent text start (indent.left), + // NOT at the next paragraph tab stop. Paragraph tab stops in tabsPx are for inline + // w:tab characters later in the run — they must not be consumed here. + // + // Gap: w:doNotUseIndentAsNumberingTabStop and w:noTabHangInd can opt out of this + // behavior, but those compat flags are not yet plumbed through the word-layout + // contract. Until they are, this unconditionally assumes the default Word mode. + // That is correct for the vast majority of documents. + const gutterWidthPx = Math.max(getNonNegativeFiniteNumber(marker.gutterWidthPx) ?? 0, LIST_MARKER_GAP); + const explicitTextStartPx = resolveExplicitStandardTextStartPx( + getFiniteNumber(wordLayout?.textStartPx), + markerContentEndPx, + gutterWidthPx, + ); + if (explicitTextStartPx != null) { + return { + markerStartPx, + markerTextWidthPx, + textStartPx: explicitTextStartPx, + suffixWidthPx: explicitTextStartPx - markerContentEndPx, + }; } - if (explicitTabStop !== undefined) { - // Use the explicit tab stop — this matches the renderer's computeTabWidth() behavior - return explicitTabStop; + const markerBoxEndPx = markerStartPx + markerBoxWidthPx; + const implicitTextStartPx = indentLeft + firstLine; + let textStartPx = implicitTextStartPx; + if (textStartPx <= markerBoxEndPx) { + textStartPx = getNextDefaultTabStopPx(markerBoxEndPx); } - if (textStartTarget !== undefined) { - const gap = Math.max(textStartTarget - currentPosStandard, gutterWidth); - return currentPosStandard + gap; + return { + markerStartPx, + markerTextWidthPx, + textStartPx, + suffixWidthPx: textStartPx - markerContentEndPx, + }; +} + +/** + * Convenience wrapper that returns only the text-start X coordinate from + * {@link resolveListMarkerGeometry}. Falls back to `wordLayout.textStartPx` + * for firstLineIndentMode paragraphs that have no marker. + * + * @param wordLayout - Word list layout metadata for the paragraph + * @param indentLeft - Paragraph left indent in pixels + * @param firstLine - Paragraph first-line indent in pixels + * @param hanging - Paragraph hanging indent in pixels + * @param measureMarkerText - Callback used when marker glyph width is not precomputed + * @returns Horizontal pixel position where text content should begin, or undefined if no marker present + */ +export function resolveListTextStartPx( + wordLayout: MinimalWordLayout | undefined, + indentLeft: number, + firstLine: number, + hanging: number, + measureMarkerText: MarkerTextMeasurer, +): number | undefined { + const geometry = resolveListMarkerGeometry(wordLayout, indentLeft, firstLine, hanging, measureMarkerText); + if (geometry) { + return geometry.textStartPx; } - const textStart = indentLeft + firstLine; - let tabWidth = textStart - currentPosStandard; - - // Hanging-overflow safeguard: marker overruns the hanging space. - // Advance to the next default tab stop, matching the renderer's computeTabWidth() behavior. - // The renderer advances to the next 48px-aligned position when no explicit tab stop - // is found past the marker. Using LIST_MARKER_GAP instead would create a measurer/renderer - // width mismatch that causes incorrect negative word-spacing on justified lines. - if (tabWidth <= 0) { - const nextDefaultTab = - currentPosStandard + DEFAULT_TAB_INTERVAL_PX - (currentPosStandard % DEFAULT_TAB_INTERVAL_PX); - tabWidth = nextDefaultTab - currentPosStandard; + if (wordLayout?.firstLineIndentMode === true) { + return getFiniteNumber(wordLayout.textStartPx); } - return currentPosStandard + tabWidth; + return undefined; }