diff --git a/packages/layout-engine/contracts/src/resolved-layout.ts b/packages/layout-engine/contracts/src/resolved-layout.ts index d0eae8f951..7d28407900 100644 --- a/packages/layout-engine/contracts/src/resolved-layout.ts +++ b/packages/layout-engine/contracts/src/resolved-layout.ts @@ -3,6 +3,7 @@ import type { FlowMode, Fragment, ImageBlock, + ImageFragmentMetadata, Line, PageMargins, SectionVerticalAlign, @@ -105,6 +106,16 @@ export type ResolvedFragmentItem = { blockId: string; /** Index within page.fragments — bridge to legacy content rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; + /** Whether this fragment continues from a previous page. */ + continuesFromPrev?: boolean; + /** Whether this fragment continues on the next page. */ + continuesOnNext?: boolean; + /** List marker box width in pixels (para/list-item only). */ + markerWidth?: number; /** Pre-resolved paragraph content for non-table paragraph fragments. */ content?: ResolvedParagraphContent; }; @@ -205,6 +216,14 @@ export type ResolvedTableItem = { blockId: string; /** Index within page.fragments — bridge to legacy rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; + /** Whether this table fragment continues from a previous page. */ + continuesFromPrev?: boolean; + /** Whether this table fragment continues on the next page. */ + continuesOnNext?: boolean; /** Pre-extracted TableBlock (replaces blockLookup.get()). */ block: TableBlock; /** Pre-extracted TableMeasure (replaces blockLookup.get()). */ @@ -241,8 +260,14 @@ export type ResolvedImageItem = { blockId: string; /** Index within page.fragments — bridge to legacy rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; /** Pre-extracted ImageBlock (replaces blockLookup.get()). */ block: ImageBlock; + /** Image metadata for interactive resizing (original dimensions, aspect ratio). */ + metadata?: ImageFragmentMetadata; }; /** @@ -271,6 +296,10 @@ export type ResolvedDrawingItem = { blockId: string; /** Index within page.fragments — bridge to legacy rendering. */ fragmentIndex: number; + /** ProseMirror start position for click-to-position mapping. */ + pmStart?: number; + /** ProseMirror end position for click-to-position mapping. */ + pmEnd?: number; /** Pre-extracted DrawingBlock (replaces blockLookup.get()). */ block: DrawingBlock; }; diff --git a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts index de0db6741d..9d3d39ff13 100644 --- a/packages/layout-engine/layout-resolved/src/resolveDrawing.ts +++ b/packages/layout-engine/layout-resolved/src/resolveDrawing.ts @@ -17,7 +17,7 @@ export function resolveDrawingItem( ): ResolvedDrawingItem { const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'drawing', 'drawing', 'drawing'); - return { + const item: ResolvedDrawingItem = { kind: 'fragment', fragmentKind: 'drawing', id: resolveDrawingFragmentId(fragment), @@ -31,4 +31,7 @@ export function resolveDrawingItem( fragmentIndex, block, }; + if (fragment.pmStart != null) item.pmStart = fragment.pmStart; + if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd; + return item; } diff --git a/packages/layout-engine/layout-resolved/src/resolveImage.ts b/packages/layout-engine/layout-resolved/src/resolveImage.ts index d1747585f9..e09632c7aa 100644 --- a/packages/layout-engine/layout-resolved/src/resolveImage.ts +++ b/packages/layout-engine/layout-resolved/src/resolveImage.ts @@ -17,7 +17,7 @@ export function resolveImageItem( ): ResolvedImageItem { const { block } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'image', 'image', 'image'); - return { + const item: ResolvedImageItem = { kind: 'fragment', fragmentKind: 'image', id: resolveImageFragmentId(fragment), @@ -31,4 +31,8 @@ export function resolveImageItem( fragmentIndex, block, }; + if (fragment.pmStart != null) item.pmStart = fragment.pmStart; + if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd; + if (fragment.metadata != null) item.metadata = fragment.metadata; + return item; } diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 04a9fc805b..2e935e82a3 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -638,6 +638,369 @@ describe('resolveLayout', () => { }); }); + describe('fragment metadata lifting', () => { + it('lifts pmStart and pmEnd from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + pmStart: 5, + pmEnd: 42, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.pmStart).toBe(5); + expect(item.pmEnd).toBe(42); + }); + + it('omits pmStart and pmEnd when not present on paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.pmStart).toBeUndefined(); + expect(item.pmEnd).toBeUndefined(); + }); + + it('lifts continuesFromPrev and continuesOnNext from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 1, + toLine: 3, + x: 72, + y: 72, + width: 468, + continuesFromPrev: true, + continuesOnNext: true, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.continuesFromPrev).toBe(true); + expect(item.continuesOnNext).toBe(true); + }); + + it('omits continuesFromPrev and continuesOnNext when not set', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.continuesFromPrev).toBeUndefined(); + expect(item.continuesOnNext).toBeUndefined(); + }); + + it('lifts markerWidth from a paragraph fragment', () => { + const paraFragment: ParaFragment = { + kind: 'para', + blockId: 'p1', + fromLine: 0, + toLine: 1, + x: 72, + y: 100, + width: 468, + markerWidth: 36, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const result = resolveLayout({ layout, flowMode: 'paginated', blocks: [], measures: [] }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.markerWidth).toBe(36); + }); + + it('lifts continuesFromPrev, continuesOnNext, and markerWidth from a list-item fragment', () => { + const listItemFragment: ListItemFragment = { + kind: 'list-item', + blockId: 'list1', + itemId: 'item-a', + fromLine: 1, + toLine: 2, + x: 108, + y: 200, + width: 432, + markerWidth: 36, + continuesFromPrev: true, + continuesOnNext: false, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [listItemFragment] }], + }; + const blocks: FlowBlock[] = [ + { + kind: 'list', + id: 'list1', + listType: 'bullet', + items: [ + { + id: 'item-a', + marker: { text: '•', style: {} }, + paragraph: { kind: 'paragraph', id: 'item-a-p', runs: [] }, + }, + ], + }, + ]; + const measures: Measure[] = [ + { + kind: 'list', + items: [ + { + itemId: 'item-a', + markerWidth: 36, + markerTextWidth: 10, + indentLeft: 36, + paragraph: { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, + { fromRun: 0, fromChar: 5, toRun: 0, toChar: 10, width: 180, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 40, + }, + }, + ], + totalHeight: 40, + }, + ]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; + expect(item.continuesFromPrev).toBe(true); + expect(item.continuesOnNext).toBe(false); + expect(item.markerWidth).toBe(36); + }); + + it('lifts pmStart, pmEnd, continuesFromPrev, and continuesOnNext from a table fragment', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 3, + x: 72, + y: 100, + width: 468, + height: 300, + pmStart: 10, + pmEnd: 200, + continuesFromPrev: true, + continuesOnNext: false, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] }; + const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.pmStart).toBe(10); + expect(item.pmEnd).toBe(200); + expect(item.continuesFromPrev).toBe(true); + expect(item.continuesOnNext).toBe(false); + }); + + it('omits pmStart and pmEnd from table fragment when not set', () => { + const tableFragment: TableFragment = { + kind: 'table', + blockId: 'tbl1', + fromRow: 0, + toRow: 1, + x: 72, + y: 100, + width: 468, + height: 30, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [tableFragment] }], + }; + const tableBlock = { kind: 'table' as const, id: 'tbl1', rows: [] }; + const tableMeasure = { kind: 'table' as const, rows: [], columnWidths: [], totalWidth: 0, totalHeight: 0 }; + + const result = resolveLayout({ + layout, + flowMode: 'paginated', + blocks: [tableBlock as any], + measures: [tableMeasure as any], + }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedTableItem; + expect(item.pmStart).toBeUndefined(); + expect(item.pmEnd).toBeUndefined(); + }); + + it('lifts pmStart, pmEnd, and metadata from an image fragment', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 100, + y: 200, + width: 300, + height: 250, + pmStart: 15, + pmEnd: 16, + metadata: { + originalWidth: 600, + originalHeight: 500, + maxWidth: 468, + maxHeight: 700, + aspectRatio: 1.2, + minWidth: 50, + minHeight: 42, + }, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment] }], + }; + const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 }; + const blocks: FlowBlock[] = [imageBlock]; + const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem; + expect(item.pmStart).toBe(15); + expect(item.pmEnd).toBe(16); + expect(item.metadata).toEqual({ + originalWidth: 600, + originalHeight: 500, + maxWidth: 468, + maxHeight: 700, + aspectRatio: 1.2, + minWidth: 50, + minHeight: 42, + }); + }); + + it('omits metadata from image fragment when not set', () => { + const imageFragment: ImageFragment = { + kind: 'image', + blockId: 'img1', + x: 100, + y: 200, + width: 300, + height: 250, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [imageFragment] }], + }; + const imageBlock = { kind: 'image' as const, id: 'img1', src: 'test.png', width: 300, height: 250 }; + const blocks: FlowBlock[] = [imageBlock]; + const measures: Measure[] = [{ kind: 'image', width: 300, height: 250 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedImageItem; + expect(item.metadata).toBeUndefined(); + }); + + it('lifts pmStart and pmEnd from a drawing fragment', () => { + const drawingFragment: DrawingFragment = { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'dr1', + x: 50, + y: 60, + width: 200, + height: 150, + isAnchored: true, + zIndex: 3, + geometry: { width: 200, height: 150 }, + scale: 1, + pmStart: 30, + pmEnd: 31, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [drawingFragment] }], + }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + }; + const blocks: FlowBlock[] = [drawingBlock as any]; + const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem; + expect(item.pmStart).toBe(30); + expect(item.pmEnd).toBe(31); + }); + + it('omits pmStart and pmEnd from drawing fragment when not set', () => { + const drawingFragment: DrawingFragment = { + kind: 'drawing', + drawingKind: 'vectorShape', + blockId: 'dr1', + x: 50, + y: 60, + width: 200, + height: 150, + geometry: { width: 200, height: 150 }, + scale: 1, + }; + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, fragments: [drawingFragment] }], + }; + const drawingBlock = { + kind: 'drawing' as const, + id: 'dr1', + drawingKind: 'vectorShape' as const, + geometry: { width: 200, height: 150 }, + }; + const blocks: FlowBlock[] = [drawingBlock as any]; + const measures: Measure[] = [{ kind: 'drawing', width: 200, height: 150 }]; + + const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); + const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedDrawingItem; + expect(item.pmStart).toBeUndefined(); + expect(item.pmEnd).toBeUndefined(); + }); + }); + describe('paragraph content resolution', () => { const makeLine = ( overrides: Partial = {}, diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 1c7e513981..3f1d19d4de 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -6,11 +6,14 @@ import type { Fragment, DrawingFragment, ImageFragment, + ListItemFragment, + ParaFragment, TableFragment, Line, ResolvedLayout, ResolvedPage, ResolvedPaintItem, + ResolvedFragmentItem, ResolvedParagraphContent, ListMeasure, ParagraphBlock, @@ -136,9 +139,9 @@ function resolveFragmentItem( return resolveImageItem(fragment as ImageFragment, fragmentIndex, pageIndex, blockMap); case 'drawing': return resolveDrawingItem(fragment as DrawingFragment, fragmentIndex, pageIndex, blockMap); - default: + default: { // para, list-item — existing generic resolution - return { + const item: ResolvedFragmentItem = { kind: 'fragment', id: resolveFragmentId(fragment), pageIndex, @@ -152,6 +155,21 @@ function resolveFragmentItem( fragmentIndex, content: resolveParagraphContentIfApplicable(fragment, blockMap), }; + if (fragment.kind === 'para') { + const para = fragment as ParaFragment; + if (para.pmStart != null) item.pmStart = para.pmStart; + if (para.pmEnd != null) item.pmEnd = para.pmEnd; + if (para.continuesFromPrev != null) item.continuesFromPrev = para.continuesFromPrev; + if (para.continuesOnNext != null) item.continuesOnNext = para.continuesOnNext; + if (para.markerWidth != null) item.markerWidth = para.markerWidth; + } else if (fragment.kind === 'list-item') { + const listItem = fragment as ListItemFragment; + if (listItem.continuesFromPrev != null) item.continuesFromPrev = listItem.continuesFromPrev; + if (listItem.continuesOnNext != null) item.continuesOnNext = listItem.continuesOnNext; + if (listItem.markerWidth != null) item.markerWidth = listItem.markerWidth; + } + return item; + } } } diff --git a/packages/layout-engine/layout-resolved/src/resolveTable.ts b/packages/layout-engine/layout-resolved/src/resolveTable.ts index f88a692109..588634987e 100644 --- a/packages/layout-engine/layout-resolved/src/resolveTable.ts +++ b/packages/layout-engine/layout-resolved/src/resolveTable.ts @@ -25,7 +25,7 @@ export function resolveTableItem( ): ResolvedTableItem { const { block, measure } = requireResolvedBlockAndMeasure(blockMap, fragment.blockId, 'table', 'table', 'table'); - return { + const item: ResolvedTableItem = { kind: 'fragment', fragmentKind: 'table', id: resolveTableFragmentId(fragment), @@ -42,4 +42,9 @@ export function resolveTableItem( cellSpacingPx: measure.cellSpacingPx ?? getCellSpacingPx(block.attrs?.cellSpacing), effectiveColumnWidths: fragment.columnWidths ?? measure.columnWidths, }; + if (fragment.pmStart != null) item.pmStart = fragment.pmStart; + if (fragment.pmEnd != null) item.pmEnd = fragment.pmEnd; + if (fragment.continuesFromPrev != null) item.continuesFromPrev = fragment.continuesFromPrev; + if (fragment.continuesOnNext != null) item.continuesOnNext = fragment.continuesOnNext; + return item; } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a588896c3e..c9467ffdb0 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2929,13 +2929,18 @@ export class DomPainter { const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; const content = resolvedItem?.content; + // Prefer resolved item metadata over legacy fragment reads + const paraContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev; + const paraContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext; + const paraMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth; + const fragmentEl = this.doc.createElement('div'); fragmentEl.classList.add(CLASS_NAMES.fragment); // For TOC entries, override white-space to prevent wrapping const isTocEntry = block.attrs?.isTocEntry; // For fragments with markers, allow overflow to show markers positioned at negative left - const hasMarker = !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker; + const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; // SDT containers need overflow visible for tooltips/labels positioned above const hasSdtContainer = block.attrs?.sdt?.type === 'documentSection' || @@ -2962,10 +2967,10 @@ export class DomPainter { fragmentEl.classList.add('superdoc-toc-entry'); } - if (fragment.continuesFromPrev) { + if (paraContinuesFromPrev) { fragmentEl.dataset.continuesFromPrev = 'true'; } - if (fragment.continuesOnNext) { + if (paraContinuesOnNext) { fragmentEl.dataset.continuesOnNext = 'true'; } @@ -3021,7 +3026,7 @@ export class DomPainter { } else { const dropCapDescriptor = block.attrs?.dropCapDescriptor; const dropCapMeasure = measure.dropCap; - if (dropCapDescriptor && dropCapMeasure && !fragment.continuesFromPrev) { + if (dropCapDescriptor && dropCapMeasure && !paraContinuesFromPrev) { const dropCapEl = this.renderDropCap(dropCapDescriptor, dropCapMeasure); fragmentEl.appendChild(dropCapEl); } @@ -3147,7 +3152,7 @@ export class DomPainter { const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; const listFirstLineTextStartPx = - !fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker + !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker ? resolvePainterListTextStartPx({ wordLayout, indentLeftPx: paraIndentLeft, @@ -3158,8 +3163,8 @@ export class DomPainter { : undefined; const shouldUseSharedInlinePrefixGeometry = - !fragment.continuesFromPrev && - fragment.markerWidth && + !paraContinuesFromPrev && + paraMarkerWidth && wordLayout?.marker?.justification === 'left' && wordLayout.firstLineIndentMode !== true && typeof fragment.markerTextWidth === 'number' && @@ -3177,7 +3182,7 @@ export class DomPainter { let listTabWidth = 0; let markerStartPos = 0; - if (!fragment.continuesFromPrev && fragment.markerWidth && wordLayout?.marker) { + if (!paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker) { const markerTextWidth = fragment.markerTextWidth!; const anchorPoint = paraIndentLeft - (paraIndent?.hanging ?? 0) + (paraIndent?.firstLine ?? 0); const markerJustification = wordLayout.marker.justification ?? 'left'; @@ -3212,8 +3217,7 @@ export class DomPainter { 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 hasListFirstLineMarker = index === 0 && !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; const shouldUseResolvedListTextStart = hasListFirstLineMarker && hasExplicitSegmentPositioning && listFirstLineTextStartPx != null; @@ -3238,7 +3242,7 @@ export class DomPainter { } const isLastLineOfFragment = index === lines.length - 1; - const isLastLineOfParagraph = isLastLineOfFragment && !fragment.continuesOnNext; + const isLastLineOfParagraph = isLastLineOfFragment && !paraContinuesOnNext; const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; const lineEl = this.renderLine( @@ -3274,7 +3278,7 @@ export class DomPainter { if (paraIndentRight && paraIndentRight > 0) { lineEl.style.paddingRight = `${paraIndentRight}px`; } - if (!fragment.continuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) { + if (!paraContinuesFromPrev && index === 0 && firstLineOffset && !isListFirstLine) { if (!hasExplicitSegmentPositioning) { lineEl.style.textIndent = `${firstLineOffset}px`; } @@ -3470,6 +3474,11 @@ export class DomPainter { throw new Error(`DomPainter: missing list item ${fragment.itemId}`); } + // Prefer resolved item metadata over legacy fragment reads + const listContinuesFromPrev = resolvedItem?.continuesFromPrev ?? fragment.continuesFromPrev; + const listContinuesOnNext = resolvedItem?.continuesOnNext ?? fragment.continuesOnNext; + const listMarkerWidth = resolvedItem?.markerWidth ?? fragment.markerWidth; + const fragmentEl = this.doc.createElement('div'); fragmentEl.classList.add(CLASS_NAMES.fragment, `${CLASS_NAMES.fragment}-list-item`); applyStyles(fragmentEl, fragmentStyles); @@ -3495,10 +3504,10 @@ export class DomPainter { sdtBoundary, ); - if (fragment.continuesFromPrev) { + if (listContinuesFromPrev) { fragmentEl.dataset.continuesFromPrev = 'true'; } - if (fragment.continuesOnNext) { + if (listContinuesOnNext) { fragmentEl.dataset.continuesOnNext = 'true'; } @@ -3513,7 +3522,7 @@ export class DomPainter { if (marker) { markerEl.textContent = marker.markerText ?? null; markerEl.style.display = 'inline-block'; - markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`; + markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`; markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`; markerEl.style.textAlign = marker.justification ?? 'left'; @@ -3528,7 +3537,7 @@ export class DomPainter { // Fallback: legacy behavior markerEl.textContent = item.marker.text; markerEl.style.display = 'inline-block'; - markerEl.style.width = `${Math.max(0, fragment.markerWidth - LIST_MARKER_GAP)}px`; + markerEl.style.width = `${Math.max(0, listMarkerWidth - LIST_MARKER_GAP)}px`; markerEl.style.paddingRight = `${LIST_MARKER_GAP}px`; if (item.marker.align) { markerEl.style.textAlign = item.marker.align; @@ -3628,16 +3637,19 @@ export class DomPainter { } // Add PM position markers for transaction targeting - if (fragment.pmStart != null) { - fragmentEl.dataset.pmStart = String(fragment.pmStart); + const imgPmStart = resolvedItem?.pmStart ?? fragment.pmStart; + if (imgPmStart != null) { + fragmentEl.dataset.pmStart = String(imgPmStart); } - if (fragment.pmEnd != null) { - fragmentEl.dataset.pmEnd = String(fragment.pmEnd); + const imgPmEnd = resolvedItem?.pmEnd ?? fragment.pmEnd; + if (imgPmEnd != null) { + fragmentEl.dataset.pmEnd = String(imgPmEnd); } // Add metadata for interactive image resizing (skip watermarks - they should not be interactive) - if (fragment.metadata && !block.attrs?.vmlWatermark) { - fragmentEl.setAttribute('data-image-metadata', JSON.stringify(fragment.metadata)); + const imgMetadata = resolvedItem?.metadata ?? fragment.metadata; + if (imgMetadata && !block.attrs?.vmlWatermark) { + fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); } // behindDoc images are supported via z-index; suppress noisy debug logs @@ -6518,8 +6530,14 @@ export class DomPainter { /** * Applies PM position data attributes from a legacy Fragment. * Extracted from applyFragmentFrame for use in the resolved wrapper path. + * When a resolvedItem is provided, its fields take precedence over fragment fields. */ - private applyFragmentPmAttributes(el: HTMLElement, fragment: Fragment, section?: 'body' | 'header' | 'footer'): void { + private applyFragmentPmAttributes( + el: HTMLElement, + fragment: Fragment, + section?: 'body' | 'header' | 'footer', + resolvedItem?: ResolvedFragmentItem | ResolvedTableItem | ResolvedImageItem | ResolvedDrawingItem, + ): void { // Footnote content is read-only: prevent cursor placement and typing if (typeof fragment.blockId === 'string' && fragment.blockId.startsWith('footnote-')) { el.setAttribute('contenteditable', 'false'); @@ -6529,22 +6547,28 @@ export class DomPainter { if (section === 'body' || section === undefined) { assertFragmentPmPositions(fragment, 'paragraph fragment'); } - if (fragment.pmStart != null) { - el.dataset.pmStart = String(fragment.pmStart); + // Narrow to ResolvedFragmentItem to access para-specific resolved fields + const resolvedFrag = resolvedItem as ResolvedFragmentItem | undefined; + const pmStart = resolvedFrag?.pmStart ?? (fragment as ParaFragment).pmStart; + if (pmStart != null) { + el.dataset.pmStart = String(pmStart); } else { delete el.dataset.pmStart; } - if (fragment.pmEnd != null) { - el.dataset.pmEnd = String(fragment.pmEnd); + const pmEnd = resolvedFrag?.pmEnd ?? (fragment as ParaFragment).pmEnd; + if (pmEnd != null) { + el.dataset.pmEnd = String(pmEnd); } else { delete el.dataset.pmEnd; } - if (fragment.continuesFromPrev) { + const continuesFromPrev = resolvedFrag?.continuesFromPrev ?? (fragment as ParaFragment).continuesFromPrev; + if (continuesFromPrev) { el.dataset.continuesFromPrev = 'true'; } else { delete el.dataset.continuesFromPrev; } - if (fragment.continuesOnNext) { + const continuesOnNext = resolvedFrag?.continuesOnNext ?? (fragment as ParaFragment).continuesOnNext; + if (continuesOnNext) { el.dataset.continuesOnNext = 'true'; } else { delete el.dataset.continuesOnNext; @@ -6594,7 +6618,7 @@ export class DomPainter { el.style.height = `${item.height}px`; } - this.applyFragmentPmAttributes(el, fragment, section); + this.applyFragmentPmAttributes(el, fragment, section, item); } /** @@ -6611,8 +6635,9 @@ export class DomPainter { section?: 'body' | 'header' | 'footer', ): void { this.applyResolvedFragmentFrame(el, item, fragment, section); - el.style.left = `${item.x - fragment.markerWidth}px`; - el.style.width = `${item.width + fragment.markerWidth}px`; + const mw = item.markerWidth ?? fragment.markerWidth; + el.style.left = `${item.x - mw}px`; + el.style.width = `${item.width + mw}px`; } /**