From aa8a98c6e0731499efefbc10f1b42a199c31dfb3 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 14 Apr 2026 15:39:56 -0300 Subject: [PATCH] refactor(layout): lift fragment metadata into resolved paint items Add pmStart, pmEnd, continuesFromPrev, continuesOnNext, markerWidth, and metadata fields to resolved paint item types. Populate them in the resolvers and update the painter to prefer resolved item data over legacy Fragment reads with fallbacks. --- .../contracts/src/resolved-layout.ts | 29 ++ .../layout-resolved/src/resolveDrawing.ts | 5 +- .../layout-resolved/src/resolveImage.ts | 6 +- .../layout-resolved/src/resolveLayout.test.ts | 363 ++++++++++++++++++ .../layout-resolved/src/resolveLayout.ts | 22 +- .../layout-resolved/src/resolveTable.ts | 7 +- .../painters/dom/src/renderer.ts | 89 +++-- 7 files changed, 484 insertions(+), 37 deletions(-) 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`; } /**