diff --git a/packages/layout-engine/contracts/src/graphic-placement.test.ts b/packages/layout-engine/contracts/src/graphic-placement.test.ts new file mode 100644 index 0000000000..374d787397 --- /dev/null +++ b/packages/layout-engine/contracts/src/graphic-placement.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAnchoredGraphicY, resolveAnchoredGraphicX } from './graphic-placement.js'; + +const yBase = { + objectHeight: 100, + contentTop: 72, + contentBottom: 720, + pageBottomMargin: 72, +}; + +const columns = { width: 200, gap: 20, count: 2 }; +const margins = { left: 72, right: 72 }; +const pageWidth = 600; +const objectWidth = 80; + +describe('resolveAnchoredGraphicY', () => { + it('positions margin-relative top with offset', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { vRelativeFrom: 'margin', alignV: 'top', offsetV: 10 }, + }), + ).toBe(82); + }); + + it('positions page-relative bottom with page margin', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { vRelativeFrom: 'page', alignV: 'bottom', offsetV: 5 }, + }), + ).toBe(720 + 72 - 100 + 5); + }); + + it('positions paragraph-relative center on first line', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { vRelativeFrom: 'paragraph', alignV: 'center', offsetV: 0 }, + anchorParagraphY: 200, + firstLineHeight: 24, + }), + ).toBe(200 + (24 - 100) / 2); + }); + + it('uses pre-registered fallback when vRelativeFrom is paragraph without paragraph context', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { vRelativeFrom: 'paragraph', offsetV: 20 }, + preRegisteredFallbackToContentTop: true, + }), + ).toBe(92); + }); + + it('ignores paragraph alignV when pre-registered fallback has no paragraph context', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { vRelativeFrom: 'paragraph', alignV: 'center', offsetV: 0 }, + preRegisteredFallbackToContentTop: true, + }), + ).toBe(72); + expect( + resolveAnchoredGraphicY({ + ...yBase, + objectHeight: 50, + anchor: { vRelativeFrom: 'paragraph', alignV: 'bottom', offsetV: 10 }, + preRegisteredFallbackToContentTop: true, + }), + ).toBe(82); + }); + + it('legacy undefined vRelativeFrom uses anchor paragraph Y plus offsetV', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { offsetV: 15 }, + anchorParagraphY: 300, + }), + ).toBe(315); + }); + + it('legacy undefined vRelativeFrom with preRegisteredFallbackToContentTop uses contentTop', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { alignV: 'center', offsetV: 20 }, + anchorParagraphY: 300, + preRegisteredFallbackToContentTop: true, + }), + ).toBe(92); + }); + + it('legacy undefined vRelativeFrom does not use paragraph alignV without vRelativeFrom paragraph', () => { + expect( + resolveAnchoredGraphicY({ + ...yBase, + anchor: { alignV: 'bottom', offsetV: 0 }, + anchorParagraphY: 200, + firstLineHeight: 24, + }), + ).toBe(200); + }); +}); + +describe('resolveAnchoredGraphicX', () => { + const columnIndex = 1; + const columnLeft = margins.left + columnIndex * (columns.width + columns.gap); + + describe('column-relative (default)', () => { + it.each([ + { alignH: 'left' as const, offsetH: 10, expected: columnLeft + 10 }, + { alignH: 'center' as const, offsetH: 5, expected: columnLeft + (columns.width - objectWidth) / 2 + 5 }, + { alignH: 'right' as const, offsetH: 3, expected: columnLeft + columns.width - objectWidth - 3 }, + ])('alignH=$alignH offsetH=$offsetH', ({ alignH, offsetH, expected }) => { + expect(resolveAnchoredGraphicX({ alignH, offsetH }, columnIndex, columns, objectWidth, margins, pageWidth)).toBe( + expected, + ); + }); + }); + + describe('margin-relative', () => { + const baseX = margins.left; + const availableWidth = pageWidth - margins.left - margins.right; + + it.each([ + { alignH: 'left' as const, offsetH: 10, expected: baseX + 10 }, + { alignH: 'center' as const, offsetH: 5, expected: baseX + (availableWidth - objectWidth) / 2 + 5 }, + { alignH: 'right' as const, offsetH: 3, expected: baseX + availableWidth - objectWidth - 3 }, + ])('alignH=$alignH offsetH=$offsetH', ({ alignH, offsetH, expected }) => { + expect( + resolveAnchoredGraphicX( + { hRelativeFrom: 'margin', alignH, offsetH }, + columnIndex, + columns, + objectWidth, + margins, + pageWidth, + ), + ).toBe(expected); + }); + }); + + describe('page-relative', () => { + const baseX = 0; + const availableWidth = pageWidth; + + it.each([ + { alignH: 'left' as const, offsetH: 10, expected: baseX + 10 }, + { alignH: 'center' as const, offsetH: 5, expected: baseX + (availableWidth - objectWidth) / 2 + 5 }, + { alignH: 'right' as const, offsetH: 3, expected: baseX + availableWidth - objectWidth - 3 }, + ])('alignH=$alignH offsetH=$offsetH', ({ alignH, offsetH, expected }) => { + expect( + resolveAnchoredGraphicX( + { hRelativeFrom: 'page', alignH, offsetH }, + columnIndex, + columns, + objectWidth, + margins, + pageWidth, + ), + ).toBe(expected); + }); + }); + + it('defaults alignH to left and offsetH to zero', () => { + expect(resolveAnchoredGraphicX({}, 0, columns, objectWidth, margins, pageWidth)).toBe(margins.left); + }); +}); diff --git a/packages/layout-engine/contracts/src/graphic-placement.ts b/packages/layout-engine/contracts/src/graphic-placement.ts new file mode 100644 index 0000000000..47da91ccc9 --- /dev/null +++ b/packages/layout-engine/contracts/src/graphic-placement.ts @@ -0,0 +1,155 @@ +type AnchorVRelative = 'paragraph' | 'page' | 'margin'; +type AnchorHRelative = 'column' | 'page' | 'margin'; +type AnchorAlignH = 'left' | 'center' | 'right'; +type AnchorAlignV = 'top' | 'center' | 'bottom'; + +export type ColumnLayoutForAnchor = { + width: number; + gap: number; + count: number; +}; + +/** + * Inputs for resolving the paint Y of an anchored image, drawing, or floating table. + * `offsetV` is applied inside this function; callers must pass the resolved value to + * text-wrap registration without adding `offsetV` again. + */ +export type ResolveAnchoredGraphicYInput = { + anchor?: { + vRelativeFrom?: AnchorVRelative; + alignV?: AnchorAlignV; + offsetV?: number; + }; + objectHeight: number; + contentTop: number; + contentBottom: number; + /** Bottom page margin in px (used when vRelativeFrom is `page`). */ + pageBottomMargin?: number; + /** + * Anchor paragraph top Y (body cursor when laying out the anchor paragraph). + * Used for `paragraph` and legacy (undefined vRelativeFrom) positioning. + */ + anchorParagraphY?: number; + /** First line height of the anchor paragraph (paragraph-relative alignV). */ + firstLineHeight?: number; + /** + * When true, anchor has no host paragraph (pre-registered / paragraphless layout). + * For `vRelativeFrom: 'paragraph'`, use `contentTop + offsetV` instead of alignV on a + * synthetic paragraph (defaults would wrongly center/bottom against contentTop). + */ + preRegisteredFallbackToContentTop?: boolean; +}; + +/** + * Resolve the vertical paint position for an anchored graphic (image, drawing, or table). + */ +export function resolveAnchoredGraphicY(input: ResolveAnchoredGraphicYInput): number { + const { + anchor, + objectHeight, + contentTop, + contentBottom, + pageBottomMargin = 0, + anchorParagraphY = contentTop, + firstLineHeight = 0, + preRegisteredFallbackToContentTop = false, + } = input; + + const offsetV = anchor?.offsetV ?? 0; + const vRelativeFrom = anchor?.vRelativeFrom; + const alignV = anchor?.alignV; + const contentHeight = Math.max(0, contentBottom - contentTop); + + if (vRelativeFrom === 'margin') { + if (alignV === 'bottom') { + return contentBottom - objectHeight + offsetV; + } + if (alignV === 'center') { + return contentTop + (contentHeight - objectHeight) / 2 + offsetV; + } + return contentTop + offsetV; + } + + if (vRelativeFrom === 'page') { + const pageHeight = contentBottom + pageBottomMargin; + if (alignV === 'bottom') { + return pageHeight - objectHeight + offsetV; + } + if (alignV === 'center') { + return (pageHeight - objectHeight) / 2 + offsetV; + } + return offsetV; + } + + if (vRelativeFrom === 'paragraph') { + if (preRegisteredFallbackToContentTop) { + return contentTop + offsetV; + } + const baseAnchorY = anchorParagraphY; + if (alignV === 'bottom') { + return baseAnchorY + firstLineHeight - objectHeight + offsetV; + } + if (alignV === 'center') { + return baseAnchorY + (firstLineHeight - objectHeight) / 2 + offsetV; + } + return baseAnchorY + offsetV; + } + + if (preRegisteredFallbackToContentTop) { + return contentTop + offsetV; + } + + return anchorParagraphY + offsetV; +} + +/** + * Resolve horizontal paint position for an anchored graphic. + */ +export function resolveAnchoredGraphicX( + anchor: { + hRelativeFrom?: AnchorHRelative; + alignH?: AnchorAlignH; + offsetH?: number; + }, + columnIndex: number, + columns: ColumnLayoutForAnchor, + objectWidth: number, + margins?: { left?: number; right?: number }, + pageWidth?: number, +): number { + const alignH = anchor.alignH ?? 'left'; + const offsetH = anchor.offsetH ?? 0; + + const marginLeft = Math.max(0, margins?.left ?? 0); + const marginRight = Math.max(0, margins?.right ?? 0); + const contentWidth = pageWidth != null ? Math.max(1, pageWidth - (marginLeft + marginRight)) : columns.width; + + const contentLeft = marginLeft; + const columnLeft = contentLeft + columnIndex * (columns.width + columns.gap); + + const relativeFrom = anchor.hRelativeFrom ?? 'column'; + + let baseX: number; + let availableWidth: number; + if (relativeFrom === 'page') { + baseX = 0; + availableWidth = pageWidth != null ? pageWidth : contentWidth + marginLeft + marginRight; + } else if (relativeFrom === 'margin') { + baseX = contentLeft; + availableWidth = contentWidth; + } else { + baseX = columnLeft; + availableWidth = columns.width; + } + + if (alignH === 'left') { + return baseX + offsetH; + } + if (alignH === 'right') { + return baseX + availableWidth - objectWidth - offsetH; + } + if (alignH === 'center') { + return baseX + (availableWidth - objectWidth) / 2 + offsetH; + } + return baseX; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index add3729af8..f498aeab07 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -92,6 +92,13 @@ export { export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; +export { + resolveAnchoredGraphicY, + resolveAnchoredGraphicX, + type ColumnLayoutForAnchor, + type ResolveAnchoredGraphicYInput, +} from './graphic-placement.js'; + // Editor-neutral layout identity primitives (prep-001). // Additive only — `pmStart`/`pmEnd` and PM-shaped fields remain available // alongside these on every fragment/run. diff --git a/packages/layout-engine/layout-engine/src/floating-objects.test.ts b/packages/layout-engine/layout-engine/src/floating-objects.test.ts index 266a4fec00..f4890bf465 100644 --- a/packages/layout-engine/layout-engine/src/floating-objects.test.ts +++ b/packages/layout-engine/layout-engine/src/floating-objects.test.ts @@ -132,7 +132,7 @@ describe('FloatingObjectManager', () => { expect(zones[0].bounds.x).toBe((600 - 200) / 2 + 10); // (columnWidth - imageWidth) / 2 + offsetH }); - it('applies vertical offset to image Y position', () => { + it('uses fully resolved anchor Y for exclusion bounds (offset applied upstream)', () => { const manager = createFloatingObjectManager(mockColumns, { left: 0, right: 0 }, 600); const imageBlock = createMockImageBlock({ anchor: { @@ -141,10 +141,11 @@ describe('FloatingObjectManager', () => { }, }); - manager.registerDrawing(imageBlock, createMockMeasure(), 100, 0, 1); + // resolvedAnchorY already includes offsetV from resolveAnchoredGraphicY + manager.registerDrawing(imageBlock, createMockMeasure(), 150, 0, 1); const zones = manager.getAllFloatsForPage(1); - expect(zones[0].bounds.y).toBe(150); // anchorY(100) + offsetV(50) + expect(zones[0].bounds.y).toBe(150); }); }); @@ -236,8 +237,8 @@ describe('FloatingObjectManager', () => { manager.registerDrawing(imageBlock, createMockMeasure(), 100, 0, 1); const result = manager.computeAvailableWidth(120, 20, 600, 0, 1); - expect(result.width).toBe(600 - 200 - 5 - 10); // baseWidth - imageWidth - distLeft - distRight - expect(result.offsetX).toBe(200 + 5 + 10); // Image width + distances + expect(result.width).toBe(600 - 200 - 10); // baseWidth - imageWidth - distRight + expect(result.offsetX).toBe(200 + 10); // Image width + right-side text gap }); it('reduces width for right-side image (wrapText=left)', () => { @@ -258,7 +259,7 @@ describe('FloatingObjectManager', () => { manager.registerDrawing(imageBlock, createMockMeasure(), 100, 0, 1); const result = manager.computeAvailableWidth(120, 20, 600, 0, 1); - expect(result.width).toBe(600 - 200 - 5 - 10); + expect(result.width).toBe(600 - 200 - 5); // baseWidth - imageWidth - distLeft expect(result.offsetX).toBe(0); // No offset for right-side image }); @@ -554,9 +555,9 @@ describe('FloatingObjectManager', () => { const result = manager.computeAvailableWidth(120, 20, 600, 0, 1); // Float center is at 50, which is < 300 (baseWidth/2), so it's a left float - // Boundary: 0 + 100 + 5 + 10 = 115 (full exclusion width) - expect(result.width).toBe(600 - 115); - expect(result.offsetX).toBe(115); + // Boundary: 0 + 100 + 10 = 110 (image width + distRight) + expect(result.width).toBe(600 - 110); + expect(result.offsetX).toBe(110); }); it('handles bothSides wrapText for float on right', () => { @@ -583,8 +584,8 @@ describe('FloatingObjectManager', () => { const result = manager.computeAvailableWidth(120, 20, 600, 0, 1); // Float is at X = 600 - 100 = 500, center at 550 > 300, so it's a right float - // Boundary: 500 - 10 - 5 = 485 (subtract both distances for symmetry) - expect(result.width).toBe(485); + // Boundary: 500 - 10 = 490 (image left edge - distLeft) + expect(result.width).toBe(490); expect(result.offsetX).toBe(0); }); @@ -612,9 +613,9 @@ describe('FloatingObjectManager', () => { const result = manager.computeAvailableWidth(120, 20, 600, 0, 1); // Float on left side (center < baseWidth/2) - // Exclusion width: 0 + 100 + 5 + 10 = 115 - expect(result.width).toBe(600 - 115); - expect(result.offsetX).toBe(115); + // Exclusion width: 0 + 100 + 10 = 110 + expect(result.width).toBe(600 - 110); + expect(result.offsetX).toBe(110); }); it('returns full width when all exclusions are non-wrapping', () => { diff --git a/packages/layout-engine/layout-engine/src/floating-objects.ts b/packages/layout-engine/layout-engine/src/floating-objects.ts index 88e4595d37..c7ee8ccb9e 100644 --- a/packages/layout-engine/layout-engine/src/floating-objects.ts +++ b/packages/layout-engine/layout-engine/src/floating-objects.ts @@ -22,7 +22,9 @@ import type { TableMeasure, TableAnchor, TableWrap, + ColumnLayoutForAnchor, } from '@superdoc/contracts'; +import { resolveAnchoredGraphicX } from '@superdoc/contracts'; type FloatBlock = ImageBlock | DrawingBlock; type FloatMeasure = ImageMeasure | DrawingMeasure; @@ -31,11 +33,14 @@ export type FloatingObjectManager = { /** * Register an anchored drawing as an exclusion zone. * Should be called before laying out paragraphs. + * + * @param resolvedAnchorY — Fully resolved paint Y from {@link resolveAnchoredGraphicY} + * (already includes `offsetV`). Must not add vertical offset again. */ registerDrawing( drawingBlock: FloatBlock, measure: FloatMeasure, - anchorParagraphY: number, + resolvedAnchorY: number, columnIndex: number, pageNumber: number, ): void; @@ -44,10 +49,13 @@ export type FloatingObjectManager = { * Register an anchored/floating table as an exclusion zone. * Should be called during Layout Pass 1 before laying out paragraphs. */ + /** + * @param resolvedAnchorY — Fully resolved paint Y (already includes `offsetV`). + */ registerTable( tableBlock: TableBlock, measure: TableMeasure, - anchorParagraphY: number, + resolvedAnchorY: number, columnIndex: number, pageNumber: number, ): void; @@ -86,11 +94,7 @@ export type FloatingObjectManager = { setLayoutContext(columns: ColumnLayout, margins?: { left?: number; right?: number }, pageWidth?: number): void; }; -type ColumnLayout = { - width: number; - gap: number; - count: number; -}; +type ColumnLayout = ColumnLayoutForAnchor; export function createFloatingObjectManager( columns: ColumnLayout, @@ -104,7 +108,7 @@ export function createFloatingObjectManager( let marginLeft = Math.max(0, currentMargins?.left ?? 0); return { - registerDrawing(drawingBlock, measure, anchorY, columnIndex, pageNumber) { + registerDrawing(drawingBlock, measure, resolvedAnchorY, columnIndex, pageNumber) { if (!drawingBlock.anchor?.isAnchored) { return; // Not anchored, no exclusion } @@ -124,16 +128,13 @@ export function createFloatingObjectManager( const x = computeAnchorX(anchor, columnIndex, currentColumns, objectWidth, currentMargins, currentPageWidth); - // Compute image Y position (anchor Y + vertical offset) - const y = anchorY + (anchor.offsetV ?? 0); - const zone: ExclusionZone = { imageBlockId: drawingBlock.id, pageNumber, columnIndex, bounds: { x, - y, + y: resolvedAnchorY, width: objectWidth, height: objectHeight, }, @@ -150,7 +151,7 @@ export function createFloatingObjectManager( zones.push(zone); }, - registerTable(tableBlock, measure, anchorY, columnIndex, pageNumber) { + registerTable(tableBlock, measure, resolvedAnchorY, columnIndex, pageNumber) { if (!tableBlock.anchor?.isAnchored) { return; // Not anchored, no exclusion } @@ -171,16 +172,13 @@ export function createFloatingObjectManager( // Compute table X position based on anchor alignment const x = computeTableAnchorX(anchor, columnIndex, currentColumns, tableWidth, currentMargins, currentPageWidth); - // Compute table Y position (anchor Y + vertical offset) - const y = anchorY + (anchor.offsetV ?? 0); - const zone: ExclusionZone = { imageBlockId: tableBlock.id, // Reusing imageBlockId field for table id pageNumber, columnIndex, bounds: { x, - y, + y: resolvedAnchorY, width: tableWidth, height: tableHeight, }, @@ -261,24 +259,20 @@ export function createFloatingObjectManager( } // Find the rightmost boundary from left floats (most intrusive on left) - // The total exclusion width includes all wrap distances: distLeft + width + distRight - // For left floats, text should start after this exclusion zone + // distRight is the gap between the image's right edge and text wrapping on its right. let leftBoundary = 0; for (const zone of leftFloats) { - // Text starts after: image position + width + all distances - const boundary = zone.bounds.x + zone.bounds.width + zone.distances.left + zone.distances.right; + const boundary = zone.bounds.x + zone.bounds.width + zone.distances.right; leftBoundary = Math.max(leftBoundary, boundary); } const columnRightEdge = columnOrigin + baseWidth; // Find the leftmost boundary from right floats (most intrusive on right) - // For right floats, text should end before the full exclusion zone + // distLeft is the gap between the image's left edge and text wrapping on its left. let rightBoundary = columnRightEdge; for (const zone of rightFloats) { - // Text ends before: image position - all distances - // This maintains symmetry with left floats (full exclusion width) - const boundary = zone.bounds.x - zone.distances.left - zone.distances.right; + const boundary = zone.bounds.x - zone.distances.left; rightBoundary = Math.min(rightBoundary, boundary); } @@ -324,9 +318,7 @@ export function createFloatingObjectManager( }; } -/** - * Compute horizontal position of anchored image based on alignment and offsets. - */ +/** @deprecated Use {@link resolveAnchoredGraphicX} from `@superdoc/contracts`. */ export function computeAnchorX( anchor: NonNullable, columnIndex: number, @@ -335,49 +327,7 @@ export function computeAnchorX( margins?: { left?: number; right?: number }, pageWidth?: number, ): number { - const alignH = anchor.alignH ?? 'left'; - const offsetH = anchor.offsetH ?? 0; - - const marginLeft = Math.max(0, margins?.left ?? 0); - const marginRight = Math.max(0, margins?.right ?? 0); - const contentWidth = pageWidth != null ? Math.max(1, pageWidth - (marginLeft + marginRight)) : columns.width; - - // Column origin is the content box in Word semantics. In single-column docs, - // this equals the left margin. For multi-column, each column starts at - // content-left + columnIndex * (columnWidth + gap). - const contentLeft = marginLeft; - const columnLeft = contentLeft + columnIndex * (columns.width + columns.gap); - - const relativeFrom = anchor.hRelativeFrom ?? 'column'; - - // Base origin and available width based on relativeFrom - let baseX: number; - let availableWidth: number; - if (relativeFrom === 'page') { - // Word's page-relative origin is always the physical page edge (x=0). - // Even for single-column layouts we honor the true page coordinate so - // anchors can extend into the margins (e.g., full-bleed covers). - baseX = 0; - availableWidth = pageWidth != null ? pageWidth : contentWidth + marginLeft + marginRight; - } else if (relativeFrom === 'margin') { - baseX = contentLeft; - availableWidth = contentWidth; - } else { - // 'column' (default) - baseX = columnLeft; - availableWidth = columns.width; - } - - const result = - alignH === 'left' - ? baseX + offsetH - : alignH === 'right' - ? baseX + availableWidth - imageWidth - offsetH - : alignH === 'center' - ? baseX + (availableWidth - imageWidth) / 2 + offsetH - : baseX; - - return result; + return resolveAnchoredGraphicX(anchor, columnIndex, columns, imageWidth, margins, pageWidth); } /** diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index dd76b14f1a..3f3b6c9a68 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -643,10 +643,9 @@ describe('layoutDocument', () => { expect(paraFragment.kind).toBe('para'); // The image is positioned at left margin (50px) - // Exclusion boundary: imageX + imageWidth + distLeft + distRight - // = 50 + 200 + 5 + 10 = 265px + // Exclusion boundary: imageX + imageWidth + distRight = 50 + 200 + 10 = 260px const imageX = DEFAULT_OPTIONS.margins!.left; - const exclusionBoundary = imageX + 200 + 5 + 10; + const exclusionBoundary = imageX + 200 + 10; // Paragraph should start after the exclusion boundary expect(paraFragment.x).toBe(exclusionBoundary); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 01363fb6ac..5e910c012d 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -35,6 +35,7 @@ import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex, + resolveAnchoredGraphicY, resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage, } from '@superdoc/contracts'; @@ -1952,86 +1953,32 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const resolveParagraphlessAnchoredTableY = (block: TableBlock, measure: TableMeasure, state: PageState): number => { const contentTop = state.topMargin; const contentBottom = state.contentBottom; - const contentHeight = Math.max(0, contentBottom - contentTop); const tableHeight = measure.totalHeight ?? 0; - const anchor = block.anchor; - const offsetV = anchor?.offsetV ?? 0; - const vRelativeFrom = anchor?.vRelativeFrom; - const alignV = anchor?.alignV; - - if (vRelativeFrom === 'margin') { - if (alignV === 'bottom') { - return contentBottom - tableHeight + offsetV; - } - if (alignV === 'center') { - return contentTop + (contentHeight - tableHeight) / 2 + offsetV; - } - return contentTop + offsetV; - } - if (vRelativeFrom === 'page') { - if (alignV === 'bottom') { - const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); - return pageHeight - tableHeight + offsetV; - } - if (alignV === 'center') { - const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); - return (pageHeight - tableHeight) / 2 + offsetV; - } - return offsetV; - } - - // Paragraph-relative floating tables normally anchor to a body paragraph. - // When a document has no body paragraphs at all, fall back to the top of the - // content area so the table can still render on page 1. - return contentTop + offsetV; + return resolveAnchoredGraphicY({ + anchor: block.anchor as Parameters[0]['anchor'], + objectHeight: tableHeight, + contentTop, + contentBottom, + pageBottomMargin: state.page.margins?.bottom ?? activeBottomMargin, + preRegisteredFallbackToContentTop: true, + }); }; for (const entry of preRegisteredAnchors) { // Ensure first page exists const state = paginator.ensurePage(); - // Calculate anchor Y position based on vRelativeFrom and alignV - const vRelativeFrom = entry.block.anchor?.vRelativeFrom ?? 'paragraph'; - const alignV = entry.block.anchor?.alignV ?? 'top'; - const offsetV = entry.block.anchor?.offsetV ?? 0; - const imageHeight = entry.measure.height ?? 0; - - // Calculate the content area boundaries const contentTop = state.topMargin; const contentBottom = state.contentBottom; - const contentHeight = Math.max(0, contentBottom - contentTop); - - let anchorY: number; - - if (vRelativeFrom === 'margin') { - // Position relative to the content area (margin box) - if (alignV === 'top') { - anchorY = contentTop + offsetV; - } else if (alignV === 'bottom') { - anchorY = contentBottom - imageHeight + offsetV; - } else if (alignV === 'center') { - anchorY = contentTop + (contentHeight - imageHeight) / 2 + offsetV; - } else { - anchorY = contentTop + offsetV; - } - } else if (vRelativeFrom === 'page') { - // Position relative to the physical page (0 = top edge) - if (alignV === 'top') { - anchorY = offsetV; - } else if (alignV === 'bottom') { - const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); - anchorY = pageHeight - imageHeight + offsetV; - } else if (alignV === 'center') { - const pageHeight = contentBottom + (state.page.margins?.bottom ?? activeBottomMargin); - anchorY = (pageHeight - imageHeight) / 2 + offsetV; - } else { - anchorY = offsetV; - } - } else { - // Shouldn't happen for pre-registered anchors, but fallback - anchorY = contentTop + offsetV; - } + const anchorY = resolveAnchoredGraphicY({ + anchor: entry.block.anchor, + objectHeight: entry.measure.height ?? 0, + contentTop, + contentBottom, + pageBottomMargin: state.page.margins?.bottom ?? activeBottomMargin, + preRegisteredFallbackToContentTop: true, + }); // Compute anchor X position const anchorX = entry.block.anchor @@ -2579,10 +2526,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } - // Paragraph start Y (OOXML: anchor for vertAnchor="text"). Captured before layout so - // paragraph-anchored tables use it as base; offsetV (tblpY) positions below start to avoid overlap. - const paragraphStartY = paginator.ensurePage().cursorY; - layoutParagraphBlock( { block, @@ -2622,16 +2565,26 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (tablesForPara) { const state = paginator.ensurePage(); const columnWidthForTable = getCurrentColumnWidth(); + + // Paragraph top after layout (first fragment on this page). Pre-layout cursorY can still + // sit on the previous page when the anchor paragraph breaks across pages. + let anchorParagraphTopY = state.cursorY; + for (const fragment of state.page.fragments) { + if (fragment.kind === 'para' && fragment.blockId === block.id) { + anchorParagraphTopY = Math.min(anchorParagraphTopY, fragment.y); + } + } + let tableBottomY = state.cursorY; + let nextStackY = state.cursorY; for (const { block: tableBlock, measure: tableMeasure } of tablesForPara) { if (placedAnchoredTableIds.has(tableBlock.id)) continue; const totalWidth = tableMeasure.totalWidth ?? 0; if (columnWidthForTable > 0 && totalWidth >= columnWidthForTable * ANCHORED_TABLE_FULL_WIDTH_RATIO) continue; - // OOXML anchor base is paragraph-relative. Clamp to paragraph bottom so the table never overlaps - // paragraph text, then apply offsetV from that resolved anchor position. + // OOXML anchor base is paragraph-relative. Clamp below laid-out paragraph text, then offsetV. const offsetV = tableBlock.anchor?.offsetV ?? 0; - const anchorBaseY = Math.max(paragraphStartY, state.cursorY); + const anchorBaseY = Math.max(anchorParagraphTopY, nextStackY); const anchorY = anchorBaseY + offsetV; floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); @@ -2646,7 +2599,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const wrapType = tableBlock.wrap?.type ?? 'None'; if (wrapType !== 'None') { const bottom = anchorY + (tableMeasure.totalHeight ?? 0); + const distBottom = tableBlock.wrap?.distBottom ?? 0; if (bottom > tableBottomY) tableBottomY = bottom; + nextStackY = bottom + distBottom; } } state.cursorY = tableBottomY; diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts index 9070732112..73eef8d711 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.test.ts @@ -553,6 +553,46 @@ describe('layoutParagraphBlock - remeasurement with list markers', () => { expect(remeasureParagraph).toHaveBeenCalledWith(block, 120, 24); }); + + it('does not expand fragment width past column when negative indents meet float wrap', () => { + const remeasureParagraph = mock((_block, maxWidth) => makeMeasure([{ width: 100, lineHeight: 20, maxWidth }])); + + const floatManager = makeFloatManager(); + floatManager.computeAvailableWidth = mock(() => ({ + width: 400, + offsetX: 80, + })); + + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'negative-indent-float', + runs: [{ text: 'Wrapped text', fontFamily: 'Arial', fontSize: 12 }], + attrs: { + indent: { left: -8, right: -2 }, + }, + }; + + const measure = makeMeasure([{ width: 100, lineHeight: 20, maxWidth: 500 }]); + const pageState = makePageState(); + + layoutParagraphBlock({ + block, + measure, + columnWidth: 500, + ensurePage: mock(() => pageState), + advanceColumn: mock((state) => state), + columnX: mock(() => 50), + floatManager, + remeasureParagraph, + }); + + const fragment = pageState.page.fragments[0]; + expect(fragment?.kind).toBe('para'); + if (fragment?.kind !== 'para') return; + expect(fragment.x).toBe(130); + expect(fragment.width).toBe(400); + expect(fragment.x + fragment.width).toBe(530); + }); }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index cbcf546492..ac616e712f 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -20,9 +20,11 @@ import { extractBlockPmRange, isEmptyTextParagraph, shouldSuppressOwnSpacing, + collapseSpacingBefore, + rewindPreviousParagraphTrailing, + computeParagraphLayoutStartY, } from './layout-utils.js'; -import { computeAnchorX } from './floating-objects.js'; -import { getFragmentZIndex } from '@superdoc/contracts'; +import { resolveAnchoredGraphicY, resolveAnchoredGraphicX, getFragmentZIndex } from '@superdoc/contracts'; /** Points → CSS pixels (96 dpi / 72 pt-per-inch). */ const PX_PER_PT = 96 / 72; @@ -375,73 +377,117 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para const blockAttrs = getParagraphAttrs(block); const frame = blockAttrs?.frame; - if (anchors?.anchoredDrawings?.length) { + let lines = normalizeLines(measure); + + // Check if paragraph was measured at a wider width than the current column. + // This happens when a document has sections with different column counts - + // text measured for a single-column section may need remeasurement when + // placed in a multi-column section with narrower columns. + const measurementWidth = lines[0]?.maxWidth; + const paraIndent = (block.attrs as { indent?: { left?: number; right?: number } } | undefined)?.indent; + const indentLeft = typeof paraIndent?.left === 'number' && Number.isFinite(paraIndent.left) ? paraIndent.left : 0; + const indentRight = typeof paraIndent?.right === 'number' && Number.isFinite(paraIndent.right) ? paraIndent.right : 0; + const negativeLeftIndent = indentLeft < 0 ? indentLeft : 0; + const negativeRightIndent = indentRight < 0 ? indentRight : 0; + // Paragraph content width should honor paragraph indents (including negative values). + const remeasureWidth = Math.max(1, columnWidth - indentLeft - indentRight); + let didRemeasureForColumnWidth = false; + // Track remeasured marker info to ensure fragment gets accurate marker text width + let remeasuredMarkerInfo: ParagraphMeasure['marker'] | undefined; + if ( + typeof remeasureParagraph === 'function' && + typeof measurementWidth === 'number' && + measurementWidth > remeasureWidth + ) { + // Use the proper helper to calculate firstLineIndent based on list marker mode. + // This ensures correct handling of firstLineIndentMode vs standard hanging indent. + const firstLineIndent = calculateFirstLineIndent(block, measure); + // Pass columnWidth (not remeasureWidth) because the measurer handles indent subtraction internally. + // Using remeasureWidth would cause double-subtraction, making line.maxWidth too small for justify calculations. + const newMeasure = remeasureParagraph(block, columnWidth, firstLineIndent); + const newLines = normalizeLines(newMeasure); + lines = newLines; + didRemeasureForColumnWidth = true; + // Capture marker info from remeasure (may have updated markerTextWidth) + if (newMeasure.marker) { + remeasuredMarkerInfo = newMeasure.marker; + } + } + + let fromLine = 0; + const attrs = getParagraphAttrs(block); + const spacing = attrs?.spacing ?? {}; + const spacingExplicit = attrs?.spacingExplicit; + const styleId = asString(attrs?.styleId); + const contextualSpacing = asBoolean(attrs?.contextualSpacing); + let spacingBefore = Math.max(0, Number(spacing.before ?? spacing.lineSpaceBefore ?? 0)); + let spacingAfter = ctx.overrideSpacingAfter ?? Math.max(0, Number(spacing.after ?? spacing.lineSpaceAfter ?? 0)); + const emptyTextParagraph = isEmptyTextParagraph(block); + if (emptyTextParagraph && spacingExplicit) { + if (!spacingExplicit.before) spacingBefore = 0; + if (!spacingExplicit.after) spacingAfter = 0; + } + /** Original spacing before value, preserved for blank page calculations where no trailing collapse occurs. */ + const baseSpacingBefore = spacingBefore; + let appliedSpacingBefore = spacingBefore === 0; + let lastState: PageState | null = null; + if (spacingDebugEnabled) { + spacingDebugLog('paragraph spacing attrs', { + blockId: block.id, + spacingAttrs: spacing, + spacingBefore, + spacingAfter, + }); + } + + const previewState = ensurePage(); + + // Border expansion must be included in anchor Y and float-scan line Y so they match + // fragment placement (`state.cursorY + borderExpansion.top` in PHASE 2). + const rawBorderExpansion = computeBorderVerticalExpansion(attrs?.borders); + const currentBorderHash = hashBorders(attrs?.borders); + const inBorderGroup = currentBorderHash != null && currentBorderHash === previewState.lastParagraphBorderHash; + const borderExpansion = { + top: inBorderGroup ? 0 : rawBorderExpansion.top, + bottom: rawBorderExpansion.bottom, + }; + + const floatScanParagraphStartY = computeParagraphLayoutStartY({ + cursorY: previewState.cursorY, + spacingBefore, + trailingSpacing: previewState.trailingSpacing, + suppressSpacingBefore: shouldSuppressOwnSpacing(styleId, contextualSpacing, previewState.lastParagraphStyleId), + rewindTrailingFromPrevious: shouldSuppressOwnSpacing( + previewState.lastParagraphStyleId, + previewState.lastParagraphContextualSpacing, + styleId, + ), + }); + const paragraphAnchorBaseY = + floatScanParagraphStartY + borderExpansion.top - (inBorderGroup ? rawBorderExpansion.bottom : 0); + + const registerAnchoredDrawingsAt = (paragraphContentStartY: number) => { + if (!anchors?.anchoredDrawings?.length) return; for (const entry of anchors.anchoredDrawings) { if (anchors.placedAnchoredIds.has(entry.block.id)) continue; const state = ensurePage(); - // Calculate anchor Y position based on vRelativeFrom and alignV - const vRelativeFrom = entry.block.anchor?.vRelativeFrom; - const alignV = entry.block.anchor?.alignV; - const offsetV = entry.block.anchor?.offsetV ?? 0; - const imageHeight = entry.measure.height; - - // Calculate the content area boundaries const contentTop = state.topMargin; const contentBottom = state.contentBottom; - const contentHeight = Math.max(0, contentBottom - contentTop); - - let anchorY: number; - - if (vRelativeFrom === 'margin') { - // Position relative to the content area (margin box) - if (alignV === 'top') { - anchorY = contentTop + offsetV; - } else if (alignV === 'bottom') { - anchorY = contentBottom - imageHeight + offsetV; - } else if (alignV === 'center') { - anchorY = contentTop + (contentHeight - imageHeight) / 2 + offsetV; - } else { - // No alignV specified, use offset from content top - anchorY = contentTop + offsetV; - } - } else if (vRelativeFrom === 'page') { - // Position relative to the physical page (0 = top edge) - if (alignV === 'top') { - anchorY = offsetV; - } else if (alignV === 'bottom') { - // Would need page height here, approximate with contentBottom + bottom margin - const pageHeight = contentBottom + (anchors.pageMargins.bottom ?? 0); - anchorY = pageHeight - imageHeight + offsetV; - } else if (alignV === 'center') { - const pageHeight = contentBottom + (anchors.pageMargins.bottom ?? 0); - anchorY = (pageHeight - imageHeight) / 2 + offsetV; - } else { - anchorY = offsetV; - } - } else if (vRelativeFrom === 'paragraph') { - // vRelativeFrom === 'paragraph' - position relative to anchor paragraph - const baseAnchorY = state.cursorY; - const firstLineHeight = measure.lines?.[0]?.lineHeight ?? 0; - if (alignV === 'top') { - anchorY = baseAnchorY + offsetV; - } else if (alignV === 'bottom') { - anchorY = baseAnchorY + firstLineHeight - imageHeight + offsetV; - } else if (alignV === 'center') { - anchorY = baseAnchorY + (firstLineHeight - imageHeight) / 2 + offsetV; - } else { - anchorY = baseAnchorY + offsetV; - } - } else { - // vRelativeFrom is undefined/null - use simple offset from current cursor (legacy behavior) - const baseAnchorY = state.cursorY; - anchorY = baseAnchorY + offsetV; - } + const anchorY = resolveAnchoredGraphicY({ + anchor: entry.block.anchor, + objectHeight: entry.measure.height, + contentTop, + contentBottom, + pageBottomMargin: anchors.pageMargins.bottom ?? 0, + anchorParagraphY: paragraphContentStartY, + firstLineHeight: measure.lines?.[0]?.lineHeight ?? 0, + }); floatManager.registerDrawing(entry.block, entry.measure, anchorY, state.columnIndex, state.page.number); const anchorX = entry.block.anchor - ? computeAnchorX( + ? resolveAnchoredGraphicX( entry.block.anchor, state.columnIndex, anchors.columns, @@ -521,70 +567,9 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para anchors.placedAnchoredIds.add(entry.block.id); } - } - - let lines = normalizeLines(measure); - - // Check if paragraph was measured at a wider width than the current column. - // This happens when a document has sections with different column counts - - // text measured for a single-column section may need remeasurement when - // placed in a multi-column section with narrower columns. - const measurementWidth = lines[0]?.maxWidth; - const paraIndent = (block.attrs as { indent?: { left?: number; right?: number } } | undefined)?.indent; - const indentLeft = typeof paraIndent?.left === 'number' && Number.isFinite(paraIndent.left) ? paraIndent.left : 0; - const indentRight = typeof paraIndent?.right === 'number' && Number.isFinite(paraIndent.right) ? paraIndent.right : 0; - const negativeLeftIndent = indentLeft < 0 ? indentLeft : 0; - const negativeRightIndent = indentRight < 0 ? indentRight : 0; - // Paragraph content width should honor paragraph indents (including negative values). - const remeasureWidth = Math.max(1, columnWidth - indentLeft - indentRight); - let didRemeasureForColumnWidth = false; - // Track remeasured marker info to ensure fragment gets accurate marker text width - let remeasuredMarkerInfo: ParagraphMeasure['marker'] | undefined; - if ( - typeof remeasureParagraph === 'function' && - typeof measurementWidth === 'number' && - measurementWidth > remeasureWidth - ) { - // Use the proper helper to calculate firstLineIndent based on list marker mode. - // This ensures correct handling of firstLineIndentMode vs standard hanging indent. - const firstLineIndent = calculateFirstLineIndent(block, measure); - // Pass columnWidth (not remeasureWidth) because the measurer handles indent subtraction internally. - // Using remeasureWidth would cause double-subtraction, making line.maxWidth too small for justify calculations. - const newMeasure = remeasureParagraph(block, columnWidth, firstLineIndent); - const newLines = normalizeLines(newMeasure); - lines = newLines; - didRemeasureForColumnWidth = true; - // Capture marker info from remeasure (may have updated markerTextWidth) - if (newMeasure.marker) { - remeasuredMarkerInfo = newMeasure.marker; - } - } + }; - let fromLine = 0; - const attrs = getParagraphAttrs(block); - const spacing = attrs?.spacing ?? {}; - const spacingExplicit = attrs?.spacingExplicit; - const styleId = asString(attrs?.styleId); - const contextualSpacing = asBoolean(attrs?.contextualSpacing); - let spacingBefore = Math.max(0, Number(spacing.before ?? spacing.lineSpaceBefore ?? 0)); - let spacingAfter = ctx.overrideSpacingAfter ?? Math.max(0, Number(spacing.after ?? spacing.lineSpaceAfter ?? 0)); - const emptyTextParagraph = isEmptyTextParagraph(block); - if (emptyTextParagraph && spacingExplicit) { - if (!spacingExplicit.before) spacingBefore = 0; - if (!spacingExplicit.after) spacingAfter = 0; - } - /** Original spacing before value, preserved for blank page calculations where no trailing collapse occurs. */ - const baseSpacingBefore = spacingBefore; - let appliedSpacingBefore = spacingBefore === 0; - let lastState: PageState | null = null; - if (spacingDebugEnabled) { - spacingDebugLog('paragraph spacing attrs', { - blockId: block.id, - spacingAttrs: spacing, - spacingBefore, - spacingAfter, - }); - } + registerAnchoredDrawingsAt(paragraphAnchorBaseY); const isPositionedFrame = frame?.wrap === 'none'; if (isPositionedFrame) { @@ -644,14 +629,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (typeof remeasureParagraph === 'function') { const tempState = ensurePage(); - let tempY = tempState.cursorY; - - // Apply spacing before to get accurate starting Y position for scanning - if (!appliedSpacingBefore && spacingBefore > 0) { - const prevTrailing = tempState.trailingSpacing ?? 0; - const neededSpacingBefore = Math.max(spacingBefore - prevTrailing, 0); - tempY += neededSpacingBefore; - } + let tempY = paragraphAnchorBaseY; // Scan through all lines to find the narrowest width for (let i = 0; i < lines.length; i++) { @@ -675,8 +653,11 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para } // If we found a narrower width, remeasure the entire paragraph once with that width - const narrowestRemeasureWidth = Math.max(1, narrowestWidth - indentLeft - indentRight); - if (narrowestRemeasureWidth < remeasureWidth) { + const floatConstrained = narrowestWidth < columnWidth || narrowestOffsetX > 0; + const narrowestRemeasureWidth = floatConstrained + ? Math.max(1, narrowestWidth - Math.max(indentLeft, 0) - Math.max(indentRight, 0)) + : Math.max(1, narrowestWidth - indentLeft - indentRight); + if (narrowestRemeasureWidth < remeasureWidth || narrowestOffsetX > 0) { // Use the proper helper to calculate firstLineIndent based on list marker mode. const firstLineIndent = calculateFirstLineIndent(block, measure); @@ -691,21 +672,6 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para } } - // Compute border expansion once per paragraph (constant across fragments). - // Border space overlaps with paragraph spacing per ECMA-376 §17.3.1.42: - // "the space above the text (ignoring any spacing above)" - const rawBorderExpansion = computeBorderVerticalExpansion(attrs?.borders); - - // Between-border group detection (ECMA-376 §17.3.1.5): when adjacent paragraphs - // have identical borders, they form a group — top/bottom borders are suppressed - // between group members, so the layout engine should not reserve space for them. - const currentBorderHash = hashBorders(attrs?.borders); - const inBorderGroup = currentBorderHash != null && currentBorderHash === ensurePage().lastParagraphBorderHash; - const borderExpansion = { - top: inBorderGroup ? 0 : rawBorderExpansion.top, - bottom: rawBorderExpansion.bottom, // bottom suppression is handled when the NEXT paragraph joins the group - }; - // PHASE 2: Layout the paragraph with the remeasured lines while (fromLine < lines.length) { let state = ensurePage(); @@ -743,7 +709,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (shouldSuppressOwnSpacing(state.lastParagraphStyleId, state.lastParagraphContextualSpacing, styleId)) { const prevTrailing = asSafeNumber(state.trailingSpacing); if (prevTrailing > 0) { - state.cursorY -= prevTrailing; + state.cursorY = rewindPreviousParagraphTrailing(state.cursorY, prevTrailing); state.trailingSpacing = 0; } } @@ -762,8 +728,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para const keepLines = attrs?.keepLines === true; if (keepLines && fromLine === 0) { - const prevTrailing = state.trailingSpacing ?? 0; - const neededSpacingBefore = Math.max(spacingBefore - prevTrailing, 0); + const neededSpacingBefore = collapseSpacingBefore(spacingBefore, state.trailingSpacing); const pageContentHeight = state.contentBottom - state.topMargin; const linesHeight = lines.reduce((sum, line) => sum + (line.lineHeight || 0), 0); const fullHeight = linesHeight + borderExpansion.top + borderExpansion.bottom; @@ -780,7 +745,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (!appliedSpacingBefore && spacingBefore > 0) { while (!appliedSpacingBefore) { const prevTrailing = state.trailingSpacing ?? 0; - const neededSpacingBefore = Math.max(spacingBefore - prevTrailing, 0); + const neededSpacingBefore = collapseSpacingBefore(spacingBefore, state.trailingSpacing); if (spacingDebugEnabled) { spacingDebugLog('spacingBefore pending', { blockId: block.id, @@ -878,6 +843,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para } else { state.trailingSpacing = 0; } + // SD-2656: footnote band overhead. Source of truth is the planner // (incrementalLayout.ts), which derives overhead from data-driven // separator dimensions (`topPadding`, `dividerHeight`, @@ -1090,11 +1056,19 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para // Apply negative indent adjustment to fragment position and width (similar to table indent handling). // Negative left indent shifts content left into page margin; negative right indent extends into right margin. // This matches Word's behavior where paragraphs with negative indents extend beyond the content area. - // Adjust x position: negative indent shifts left (e.g., -48px moves fragment 48px left) - const adjustedX = columnX(state.columnIndex) + offsetX + negativeLeftIndent; - // Expand width: negative indents on both sides expand the fragment width - // (e.g., -48px left + -72px right = 120px wider) - const adjustedWidth = effectiveColumnWidth - negativeLeftIndent - negativeRightIndent; + // Adjust x position: negative indent shifts left (e.g., -48px moves fragment 48px left). + // When text was remeasured around floats, do not pull lines back into exclusion zones. + const floatAdjustedX = columnX(state.columnIndex) + offsetX; + const adjustedX = didRemeasureForFloats + ? floatAdjustedX + Math.max(negativeLeftIndent, 0) + : floatAdjustedX + negativeLeftIndent; + const columnRight = columnX(state.columnIndex) + columnWidth; + let adjustedWidth = didRemeasureForFloats + ? effectiveColumnWidth + : effectiveColumnWidth - negativeLeftIndent - negativeRightIndent; + if (didRemeasureForFloats) { + adjustedWidth = Math.min(adjustedWidth, Math.max(1, columnRight - adjustedX)); + } const fragment: ParaFragment = { kind: 'para', blockId: block.id, @@ -1109,7 +1083,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para // Store remeasured lines in fragment so renderer can use them. // This is needed because the original measure has different line breaks. - if (didRemeasureForColumnWidth) { + if (didRemeasureForColumnWidth || didRemeasureForFloats) { fragment.lines = lines.slice(fromLine, slice.toLine); } diff --git a/packages/layout-engine/layout-engine/src/layout-utils.test.ts b/packages/layout-engine/layout-engine/src/layout-utils.test.ts index 926ed7b129..ce08acda33 100644 --- a/packages/layout-engine/layout-engine/src/layout-utils.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-utils.test.ts @@ -5,7 +5,15 @@ import { describe, it, expect } from 'bun:test'; import type { ParagraphBlock, TextRun, ImageRun } from '@superdoc/contracts'; -import { isEmptyTextParagraph, shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; +import { + isEmptyTextParagraph, + shouldSuppressSpacingForEmpty, + shouldSuppressOwnSpacing, + collapseSpacingBefore, + rewindPreviousParagraphTrailing, + computeParagraphContentStartY, + computeParagraphLayoutStartY, +} from './layout-utils.js'; // ============================================================================ // Empty Paragraph Detection Tests @@ -187,3 +195,51 @@ describe('shouldSuppressOwnSpacing', () => { expect(shouldSuppressOwnSpacing('Normal', true, 'Normal')).toBe(true); }); }); + +describe('collapseSpacingBefore', () => { + it('subtracts trailing from spacing-before floored at zero', () => { + expect(collapseSpacingBefore(24, 8)).toBe(16); + expect(collapseSpacingBefore(10, 20)).toBe(0); + }); +}); + +describe('rewindPreviousParagraphTrailing', () => { + it('moves cursor up by trailing when positive', () => { + expect(rewindPreviousParagraphTrailing(120, 12)).toBe(108); + expect(rewindPreviousParagraphTrailing(120, 0)).toBe(120); + }); +}); + +describe('computeParagraphLayoutStartY', () => { + it('rewinds trailing then applies full spacing-before without double collapse', () => { + expect( + computeParagraphLayoutStartY({ + cursorY: 120, + spacingBefore: 24, + trailingSpacing: 12, + rewindTrailingFromPrevious: true, + }), + ).toBe(132); + }); + + it('collapses spacing-before against trailing when previous after-spacing is kept', () => { + expect( + computeParagraphLayoutStartY({ + cursorY: 100, + spacingBefore: 24, + trailingSpacing: 8, + rewindTrailingFromPrevious: false, + }), + ).toBe(116); + }); +}); + +describe('computeParagraphContentStartY', () => { + it('adds spacing-before minus trailing collapse', () => { + expect(computeParagraphContentStartY(100, 24, false, 8)).toBe(116); + }); + + it('returns cursorY when spacing already applied', () => { + expect(computeParagraphContentStartY(100, 24, true, 0)).toBe(100); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/layout-utils.ts b/packages/layout-engine/layout-engine/src/layout-utils.ts index 215b5367c3..5cdb484215 100644 --- a/packages/layout-engine/layout-engine/src/layout-utils.ts +++ b/packages/layout-engine/layout-engine/src/layout-utils.ts @@ -175,6 +175,61 @@ export function shouldSuppressOwnSpacing( return ownContextualSpacing && !!ownStyleId && !!adjacentStyleId && ownStyleId === adjacentStyleId; } +// ============================================================================ +// Paragraph spacing-before Y (shared by layout-paragraph preview + PHASE 2) +// ============================================================================ + +/** Pixels of spacing.before to add after collapsing against previous trailingSpacing. */ +export function collapseSpacingBefore(spacingBefore: number, trailingSpacing: number | undefined): number { + const prevTrailing = trailingSpacing ?? 0; + return Math.max(spacingBefore - prevTrailing, 0); +} + +/** OOXML contextual spacing: previous paragraph rewinds its after-gap from cursorY. */ +export function rewindPreviousParagraphTrailing(cursorY: number, trailingSpacing: number | undefined): number { + const prevTrailing = trailingSpacing ?? 0; + return prevTrailing > 0 ? cursorY - prevTrailing : cursorY; +} + +/** + * Y coordinate where paragraph text begins (after spacing-before collapse). + * Does not advance pages — pagination stays in layout-paragraph PHASE 2. + */ +export function computeParagraphContentStartY( + cursorY: number, + spacingBefore: number, + appliedSpacingBefore: boolean, + trailingSpacing: number | undefined, +): number { + if (appliedSpacingBefore || spacingBefore <= 0) { + return cursorY; + } + return cursorY + collapseSpacingBefore(spacingBefore, trailingSpacing); +} + +/** + * Paragraph text start Y including contextual-spacing rewind from the previous paragraph. + * Used for float-scan preview at paragraph entry; PHASE 2 uses the same primitives inline. + */ +export function computeParagraphLayoutStartY(input: { + cursorY: number; + spacingBefore: number; + trailingSpacing?: number; + suppressSpacingBefore?: boolean; + rewindTrailingFromPrevious?: boolean; +}): number { + let y = input.cursorY; + let trailingForCollapse = input.trailingSpacing; + if (input.rewindTrailingFromPrevious) { + y = rewindPreviousParagraphTrailing(y, input.trailingSpacing); + if ((input.trailingSpacing ?? 0) > 0) { + trailingForCollapse = 0; + } + } + const effectiveSpacingBefore = input.suppressSpacingBefore ? 0 : input.spacingBefore; + return computeParagraphContentStartY(y, effectiveSpacingBefore, effectiveSpacingBefore === 0, trailingForCollapse); +} + export const extractBlockPmRange = (block: { attrs?: Record } | null | undefined): LinePmRange => { if (!block || !block.attrs) { return {}; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.test.ts index f11582d8cc..a0b704743f 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.test.ts @@ -241,6 +241,27 @@ describe('image converter', () => { }); }); + it('fills wrap distances from padding when wrap attrs omit them', () => { + const node: PMNode = { + type: 'image', + attrs: { + src: 'image.jpg', + padding: { top: 0, bottom: 0, left: 12, right: 15 }, + wrap: { + type: 'Square', + attrs: { + wrapText: 'bothSides', + }, + }, + }, + }; + + const result = imageNodeToBlock(node, mockBlockIdGenerator, mockPositionMap) as ImageBlock; + + expect(result.wrap?.distLeft).toBe(12); + expect(result.wrap?.distRight).toBe(15); + }); + it('handles wrap configuration', () => { const node: PMNode = { type: 'image', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.ts index 92b84d8909..f87c315f95 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/image.ts @@ -14,6 +14,7 @@ import { normalizeZIndex, resolveFloatingZIndex, readImageHyperlink, + mergeWrapDistancesFromPadding, } from '../utilities.js'; // ============================================================================ @@ -254,6 +255,9 @@ export function imageNodeToBlock( const explicitDisplay = typeof attrs.display === 'string' ? (attrs.display as string) : undefined; const normalizedWrap = normalizeWrap(attrs.wrap); + if (normalizedWrap) { + mergeWrapDistancesFromPadding(normalizedWrap, toBoxSpacing(attrs.padding as Record | undefined)); + } let anchor = normalizeAnchorData(attrs.anchorData, attrs, normalizedWrap?.behindDoc); if (!anchor && normalizedWrap) { anchor = { isAnchored: true }; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts index 4563963ddd..0aca1e06ab 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.test.ts @@ -144,6 +144,30 @@ describe('shapes converter', () => { expect(result.wrap?.distTop).toBe(5); }); + it('fills wrap distances from padding when wrap attrs omit them', () => { + const node: PMNode = { + type: 'vectorShape', + attrs: { + width: 100, + height: 100, + padding: { top: 4, bottom: 6, left: 12, right: 15 }, + wrap: { + type: 'Square', + attrs: { + wrapText: 'bothSides', + }, + }, + }, + }; + + const result = vectorShapeNodeToDrawingBlock(node, mockBlockIdGenerator, mockPositionMap) as DrawingBlock; + + expect(result.wrap?.distTop).toBe(4); + expect(result.wrap?.distBottom).toBe(6); + expect(result.wrap?.distLeft).toBe(12); + expect(result.wrap?.distRight).toBe(15); + }); + it('handles anchor data', () => { const node: PMNode = { type: 'vectorShape', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts index d7405bac57..65d5bf6ff9 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/shapes.ts @@ -37,6 +37,7 @@ import { normalizeTextInsets, normalizeZIndex, resolveFloatingZIndex, + mergeWrapDistancesFromPadding, } from '../utilities.js'; // ============================================================================ @@ -338,6 +339,12 @@ export const buildDrawingBlock = ( }, ): ShapeDrawingBlock => { const normalizedWrap = normalizeWrap(rawAttrs.wrap); + if (normalizedWrap) { + mergeWrapDistancesFromPadding( + normalizedWrap, + toBoxSpacing(rawAttrs.padding as Record | undefined), + ); + } const sourceAnchor = isPlainObject(rawAttrs.sourceAnchor) ? (rawAttrs.sourceAnchor as SourceAnchor) : undefined; const baseAnchor = normalizeAnchorData(rawAttrs.anchorData, rawAttrs, normalizedWrap?.behindDoc); const pos = positions.get(node); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts index 4a3bf636d7..c6e1ea0255 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/utilities.ts @@ -179,6 +179,17 @@ export const pickNumber = (value: unknown): number | undefined => { return undefined; }; +/** + * Apply wp:anchor dist* padding to wrap distances when wrap.attrs omitted them at import. + */ +export const mergeWrapDistancesFromPadding = (wrap: NonNullable, padding?: BoxSpacing): void => { + if (!padding || wrap.type === 'None' || wrap.type === 'Inline') return; + if (wrap.distTop == null && padding.top != null) wrap.distTop = padding.top; + if (wrap.distBottom == null && padding.bottom != null) wrap.distBottom = padding.bottom; + if (wrap.distLeft == null && padding.left != null) wrap.distLeft = padding.left; + if (wrap.distRight == null && padding.right != null) wrap.distRight = padding.right; +}; + /** * Normalizes a color string, ensuring it has a leading '#' symbol. * diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js index 2c702b0910..f1a4f698ba 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.js @@ -125,6 +125,28 @@ const buildClipPathFromSrcRect = (srcRectAttrs = {}) => { return `inset(${top}% ${right}% ${bottom}% ${left}%)`; }; +/** + * Fill wrap.attrs distance fields from wp:anchor dist* when the wrap element omits them. + * + * @param {{ type: string, attrs: Record }} wrap + * @param {{ top?: number, right?: number, bottom?: number, left?: number }} padding + */ +const mergeAnchorPaddingIntoWrapDistances = (wrap, padding) => { + if (!wrap?.attrs || !padding) return; + if (wrap.attrs.distTop == null && Number.isFinite(padding.top) && padding.top !== 0) { + wrap.attrs.distTop = padding.top; + } + if (wrap.attrs.distBottom == null && Number.isFinite(padding.bottom) && padding.bottom !== 0) { + wrap.attrs.distBottom = padding.bottom; + } + if (wrap.attrs.distLeft == null && Number.isFinite(padding.left) && padding.left !== 0) { + wrap.attrs.distLeft = padding.left; + } + if (wrap.attrs.distRight == null && Number.isFinite(padding.right) && padding.right !== 0) { + wrap.attrs.distRight = padding.right; + } +}; + /** * Encodes image XML into Editor node. * @@ -263,6 +285,12 @@ export function handleImageNode(node, params, isAnchor) { break; } + // OOXML stores wrap distances on wp:anchor (distL/distR/distT/distB); wrap child elements + // may omit them. Merge into wrap.attrs for wrap modes that affect text flow. + if (wrap.type === 'Square' || wrap.type === 'Tight' || wrap.type === 'Through' || wrap.type === 'TopAndBottom') { + mergeAnchorPaddingIntoWrapDistances(wrap, padding); + } + const docPr = node.elements.find((el) => el.name === 'wp:docPr'); const isHidden = isDocPrHidden(docPr); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js index 3ef9df4015..78dccc5573 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/wp/helpers/encode-image-node-helpers.test.js @@ -679,8 +679,32 @@ describe('handleImageNode', () => { expect(result.attrs.wrap.attrs.distRight).toBe(4); }); + it('uses anchor distL/distR when wrapSquare omits distance attributes', () => { + const node = makeNode({ + attributes: { + distT: '0', + distB: '0', + distL: '12000', + distR: '15130', + }, + }); + node.elements.push({ + name: 'wp:wrapSquare', + attributes: { wrapText: 'bothSides' }, + }); + + const result = handleImageNode(node, makeParams(), true); + + expect(result.attrs.wrap.type).toBe('Square'); + expect(result.attrs.wrap.attrs.wrapText).toBe('bothSides'); + expect(result.attrs.wrap.attrs.distLeft).toBeCloseTo(12, 0); + expect(result.attrs.wrap.attrs.distRight).toBeCloseTo(15.13, 1); + }); + it('handles wrap type TopAndBottom without distance attributes', () => { - const node = makeNode(); + const node = makeNode({ + attributes: { distT: '0', distB: '0', distL: '0', distR: '0' }, + }); node.elements.push({ name: 'wp:wrapTopAndBottom' }); const result = handleImageNode(node, makeParams(), true);