diff --git a/packages/layout-engine/layout-engine/src/floating-objects.ts b/packages/layout-engine/layout-engine/src/floating-objects.ts index e0db232a68..a373c0ce88 100644 --- a/packages/layout-engine/layout-engine/src/floating-objects.ts +++ b/packages/layout-engine/layout-engine/src/floating-objects.ts @@ -20,11 +20,11 @@ import type { DrawingMeasure, TableBlock, TableMeasure, - TableAnchor, TableWrap, ColumnLayoutForAnchor, } from '@superdoc/contracts'; -import { resolveAnchoredGraphicX, getColumnGeometry, getColumnX } from '@superdoc/contracts'; +import { getColumnGeometry, getColumnX } from '@superdoc/contracts'; +import { resolveGraphicPlacement, resolveTablePlacement, type ResolvedGraphicPlacement } from './graphic-placement.js'; type FloatBlock = ImageBlock | DrawingBlock; type FloatMeasure = ImageMeasure | DrawingMeasure; @@ -34,13 +34,13 @@ 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. + * @param placement — Fully resolved paint/exclusion placement. Legacy numeric Y is accepted + * for older tests only; layout code should pass a ResolvedGraphicPlacement. */ registerDrawing( drawingBlock: FloatBlock, measure: FloatMeasure, - resolvedAnchorY: number, + placement: ResolvedGraphicPlacement | number, columnIndex: number, pageNumber: number, ): void; @@ -50,12 +50,13 @@ export type FloatingObjectManager = { * Should be called during Layout Pass 1 before laying out paragraphs. */ /** - * @param resolvedAnchorY — Fully resolved paint Y (already includes `offsetV`). + * @param placement — Fully resolved paint/exclusion placement. Legacy numeric Y is accepted + * for older tests only; layout code should pass a ResolvedGraphicPlacement. */ registerTable( tableBlock: TableBlock, measure: TableMeasure, - resolvedAnchorY: number, + placement: ResolvedGraphicPlacement | number, columnIndex: number, pageNumber: number, ): void; @@ -107,8 +108,64 @@ export function createFloatingObjectManager( let currentPageWidth = pageWidth; let marginLeft = Math.max(0, currentMargins?.left ?? 0); + const coerceDrawingPlacement = ( + block: FloatBlock, + measure: FloatMeasure, + placement: ResolvedGraphicPlacement | number, + columnIndex: number, + ): ResolvedGraphicPlacement => { + if (typeof placement !== 'number') { + return placement; + } + const objectHeight = measure.height ?? 0; + const anchor = block.anchor + ? { ...block.anchor, vRelativeFrom: 'paragraph' as const, alignV: 'top' as const, offsetV: 0 } + : undefined; + return resolveGraphicPlacement({ + anchor, + objectWidth: measure.width ?? 0, + objectHeight: measure.height ?? 0, + columnIndex, + columns: currentColumns, + pageMargins: currentMargins, + pageWidth: currentPageWidth, + contentTop: placement, + contentBottom: placement + objectHeight, + anchorParagraphY: placement, + firstLineHeight: objectHeight, + fallbackX: marginLeft, + wrapType: block.wrap?.type, + }); + }; + + const coerceTablePlacement = ( + block: TableBlock, + measure: TableMeasure, + placement: ResolvedGraphicPlacement | number, + columnIndex: number, + ): ResolvedGraphicPlacement => { + if (typeof placement !== 'number') { + return placement; + } + const objectHeight = measure.totalHeight ?? 0; + const anchor = block.anchor + ? { ...block.anchor, vRelativeFrom: 'paragraph' as const, alignV: 'top' as const, offsetV: 0 } + : undefined; + return resolveTablePlacement(anchor, measure, block.wrap, { + columnIndex, + columns: currentColumns, + pageMargins: currentMargins, + pageWidth: currentPageWidth, + contentTop: placement, + contentBottom: placement + objectHeight, + anchorParagraphY: placement, + firstLineHeight: objectHeight, + fallbackX: marginLeft, + }); + }; + return { - registerDrawing(drawingBlock, measure, resolvedAnchorY, columnIndex, pageNumber) { + registerDrawing(drawingBlock, measure, placementOrY, columnIndex, pageNumber) { if (!drawingBlock.anchor?.isAnchored) { return; // Not anchored, no exclusion } @@ -122,21 +179,20 @@ export function createFloatingObjectManager( return; } - // Compute image X position based on anchor alignment, respecting margins - const objectWidth = measure.width ?? 0; - const objectHeight = measure.height ?? 0; - - const x = computeAnchorX(anchor, columnIndex, currentColumns, objectWidth, currentMargins, currentPageWidth); + const placement = coerceDrawingPlacement(drawingBlock, measure, placementOrY, columnIndex); + if (!placement.exclusion) { + return; + } const zone: ExclusionZone = { imageBlockId: drawingBlock.id, pageNumber, columnIndex, bounds: { - x, - y: resolvedAnchorY, - width: objectWidth, - height: objectHeight, + x: placement.exclusion.x, + y: placement.exclusion.y, + width: placement.exclusion.width, + height: placement.exclusion.height, }, distances: { top: wrap?.distTop ?? 0, @@ -151,7 +207,7 @@ export function createFloatingObjectManager( zones.push(zone); }, - registerTable(tableBlock, measure, resolvedAnchorY, columnIndex, pageNumber) { + registerTable(tableBlock, measure, placementOrY, columnIndex, pageNumber) { if (!tableBlock.anchor?.isAnchored) { return; // Not anchored, no exclusion } @@ -165,22 +221,20 @@ export function createFloatingObjectManager( return; } - // Compute table dimensions from measure - const tableWidth = measure.totalWidth ?? 0; - const tableHeight = measure.totalHeight ?? 0; - - // Compute table X position based on anchor alignment - const x = computeTableAnchorX(anchor, columnIndex, currentColumns, tableWidth, currentMargins, currentPageWidth); + const placement = coerceTablePlacement(tableBlock, measure, placementOrY, columnIndex); + if (!placement.exclusion) { + return; + } const zone: ExclusionZone = { imageBlockId: tableBlock.id, // Reusing imageBlockId field for table id pageNumber, columnIndex, bounds: { - x, - y: resolvedAnchorY, - width: tableWidth, - height: tableHeight, + x: placement.exclusion.x, + y: placement.exclusion.y, + width: placement.exclusion.width, + height: placement.exclusion.height, }, distances: { top: wrap?.distTop ?? 0, @@ -319,18 +373,6 @@ export function createFloatingObjectManager( }; } -/** @deprecated Use {@link resolveAnchoredGraphicX} from `@superdoc/contracts`. */ -export function computeAnchorX( - anchor: NonNullable, - columnIndex: number, - columns: ColumnLayout, - imageWidth: number, - margins?: { left?: number; right?: number }, - pageWidth?: number, -): number { - return resolveAnchoredGraphicX(anchor, columnIndex, columns, imageWidth, margins, pageWidth); -} - /** * Map ImageWrap.wrapText to ExclusionZone.wrapMode. * Determines which side of the image text should wrap. @@ -355,71 +397,6 @@ function computeWrapMode(wrap: ImageBlock['wrap'], _anchor: ImageBlock['anchor'] return 'both'; } -/** - * Compute horizontal position of anchored table based on alignment and offsets. - * Similar to computeAnchorX but uses TableAnchor type. - */ -function computeTableAnchorX( - anchor: TableAnchor, - columnIndex: number, - columns: ColumnLayout, - tableWidth: 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 geometry = getColumnGeometry(columns); - const columnLeft = getColumnX(geometry, columnIndex, contentLeft); - - const relativeFrom = anchor.hRelativeFrom ?? 'column'; - - // Base origin and available width based on relativeFrom - let baseX: number; - let availableWidth: number; - if (relativeFrom === 'page') { - if (columns.count === 1) { - baseX = contentLeft; - availableWidth = contentWidth; - } else { - baseX = 0; - availableWidth = pageWidth != null ? pageWidth : contentWidth; - } - } else if (relativeFrom === 'margin') { - baseX = contentLeft; - availableWidth = contentWidth; - } else { - // 'column' (default) - baseX = columnLeft; - // Scalar (max) column width, matching anchored-object measurement (clamped to columns.width). - // Per-column origin above is honored; per-column available width waits on per-column measurement - // so a max-sized object is not centered/right-aligned into the margin or gap. (SD-2629) - availableWidth = columns.width; - } - - // Handle table-specific alignment values (inside/outside map to left/right for now) - let effectiveAlignH = alignH; - if (alignH === 'inside') effectiveAlignH = 'left'; - if (alignH === 'outside') effectiveAlignH = 'right'; - - const result = - effectiveAlignH === 'left' - ? baseX + offsetH - : effectiveAlignH === 'right' - ? baseX + availableWidth - tableWidth - offsetH - : effectiveAlignH === 'center' - ? baseX + (availableWidth - tableWidth) / 2 + offsetH - : baseX; - - return result; -} - /** * Map TableWrap.wrapText to ExclusionZone.wrapMode. * Determines which side of the table text should wrap. diff --git a/packages/layout-engine/layout-engine/src/graphic-placement.test.ts b/packages/layout-engine/layout-engine/src/graphic-placement.test.ts new file mode 100644 index 0000000000..2008963d35 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/graphic-placement.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveGraphicPlacement, resolveTablePlacement } from './graphic-placement.js'; +import type { TableMeasure } from '@superdoc/contracts'; + +describe('resolveGraphicPlacement', () => { + const columns = { width: 400, gap: 20, count: 1 }; + const pageMargins = { left: 50, right: 50, bottom: 60 }; + + it('uses one resolved coordinate set for paint and exclusion bounds', () => { + const placement = resolveGraphicPlacement({ + anchor: { + isAnchored: true, + hRelativeFrom: 'margin', + vRelativeFrom: 'paragraph', + offsetH: 25, + offsetV: 30, + }, + objectWidth: 120, + objectHeight: 80, + columnIndex: 0, + columns, + pageMargins, + pageWidth: 500, + contentTop: 40, + contentBottom: 700, + anchorParagraphY: 100, + firstLineHeight: 20, + wrapType: 'Square', + layer: { zIndex: 7 }, + }); + + expect(placement.paint).toEqual({ x: 75, y: 130, width: 120, height: 80 }); + expect(placement.exclusion).toEqual(placement.paint); + expect(placement.exclusion).not.toBe(placement.paint); + expect(placement.layer).toEqual({ behindDoc: false, zIndex: 7 }); + }); + + it('does not expose exclusion bounds for overlay or inline graphics', () => { + const placement = resolveGraphicPlacement({ + anchor: { isAnchored: true, behindDoc: true, offsetV: 200 }, + objectWidth: 100, + objectHeight: 50, + columnIndex: 0, + columns, + pageMargins, + pageWidth: 500, + contentTop: 40, + contentBottom: 700, + anchorParagraphY: 100, + wrapType: 'None', + }); + + expect(placement.paint.y).toBe(300); + expect(placement.exclusion).toBeNull(); + expect(placement.layer.behindDoc).toBe(true); + }); + + it('normalizes table inside/outside alignment through the shared horizontal path', () => { + const measure: TableMeasure = { + kind: 'table', + rows: [], + columnWidths: [100], + totalWidth: 100, + totalHeight: 40, + }; + + const inside = resolveTablePlacement( + { isAnchored: true, hRelativeFrom: 'margin', alignH: 'inside', offsetH: 15 }, + measure, + { type: 'Square' }, + { + columnIndex: 0, + columns, + pageMargins, + pageWidth: 500, + contentTop: 40, + contentBottom: 700, + anchorParagraphY: 100, + }, + ); + const outside = resolveTablePlacement( + { isAnchored: true, hRelativeFrom: 'margin', alignH: 'outside', offsetH: 15 }, + measure, + { type: 'Square' }, + { + columnIndex: 0, + columns, + pageMargins, + pageWidth: 500, + contentTop: 40, + contentBottom: 700, + anchorParagraphY: 100, + }, + ); + + expect(inside.paint.x).toBe(65); + expect(outside.paint.x).toBe(335); + expect(inside.exclusion).toEqual(inside.paint); + expect(outside.exclusion).toEqual(outside.paint); + }); +}); diff --git a/packages/layout-engine/layout-engine/src/graphic-placement.ts b/packages/layout-engine/layout-engine/src/graphic-placement.ts new file mode 100644 index 0000000000..87c4ba1eb4 --- /dev/null +++ b/packages/layout-engine/layout-engine/src/graphic-placement.ts @@ -0,0 +1,187 @@ +import type { + ColumnLayoutForAnchor, + DrawingBlock, + DrawingMeasure, + GraphicPlacement, + ImageBlock, + ImageMeasure, + ImageWrap, + PageMargins, + TableAnchor, + TableMeasure, + TableWrap, +} from '@superdoc/contracts'; +import { getFragmentZIndex, resolveAnchoredGraphicX, resolveAnchoredGraphicY } from '@superdoc/contracts'; + +type GraphicAnchor = GraphicPlacement | TableAnchor; +type GraphicWrapType = ImageWrap['type'] | TableWrap['type']; + +export type ResolveGraphicPlacementInput = { + anchor?: GraphicAnchor; + objectWidth: number; + objectHeight: number; + columnIndex: number; + columns: ColumnLayoutForAnchor; + pageMargins?: PageMargins; + pageWidth?: number; + contentTop: number; + contentBottom: number; + pageBottomMargin?: number; + anchorParagraphY?: number; + firstLineHeight?: number; + preRegisteredFallbackToContentTop?: boolean; + fallbackX?: number; + wrapType?: GraphicWrapType; + layer?: { + behindDoc?: boolean; + zIndex?: number; + }; +}; + +export type ResolvedGraphicPlacement = { + paint: { + x: number; + y: number; + width: number; + height: number; + }; + exclusion: { + x: number; + y: number; + width: number; + height: number; + } | null; + layer: { + behindDoc: boolean; + zIndex?: number; + }; +}; + +function normalizeHorizontalAnchor(anchor: GraphicAnchor): Parameters[0] { + const alignH = anchor.alignH; + const mappedAlignH = + alignH === 'left' || alignH === 'center' || alignH === 'right' + ? alignH + : alignH === 'inside' + ? 'left' + : alignH === 'outside' + ? 'right' + : undefined; + + return { + hRelativeFrom: anchor.hRelativeFrom, + alignH: mappedAlignH, + offsetH: anchor.offsetH, + }; +} + +function normalizeVerticalAnchor(anchor: GraphicAnchor): Parameters[0]['anchor'] { + const alignV = anchor.alignV; + const mappedAlignV = alignV === 'top' || alignV === 'center' || alignV === 'bottom' ? alignV : undefined; + + return { + vRelativeFrom: anchor.vRelativeFrom, + alignV: mappedAlignV, + offsetV: anchor.offsetV, + }; +} + +function wrapAffectsTextFlow(wrapType: GraphicWrapType | undefined): boolean { + return wrapType !== 'Inline' && wrapType !== 'None'; +} + +/** + * Resolve anchored graphic placement once for all downstream layout consumers. + * + * The returned paint and exclusion coordinates intentionally share the same + * origin. Callers should not add anchor offsets again when registering text-wrap + * exclusion zones or creating paint fragments. + */ +export function resolveGraphicPlacement(input: ResolveGraphicPlacementInput): ResolvedGraphicPlacement { + const { + anchor, + objectWidth, + objectHeight, + columnIndex, + columns, + pageMargins, + pageWidth, + contentTop, + contentBottom, + pageBottomMargin, + anchorParagraphY, + firstLineHeight, + preRegisteredFallbackToContentTop, + fallbackX = pageMargins?.left ?? 0, + wrapType, + layer, + } = input; + + const x = anchor + ? resolveAnchoredGraphicX( + normalizeHorizontalAnchor(anchor), + columnIndex, + columns, + objectWidth, + { left: pageMargins?.left, right: pageMargins?.right }, + pageWidth, + ) + : fallbackX; + + const y = resolveAnchoredGraphicY({ + anchor: anchor ? normalizeVerticalAnchor(anchor) : undefined, + objectHeight, + contentTop, + contentBottom, + pageBottomMargin, + anchorParagraphY, + firstLineHeight, + preRegisteredFallbackToContentTop, + }); + + const behindDoc = anchor != null && 'behindDoc' in anchor ? anchor.behindDoc === true : layer?.behindDoc === true; + const affectsTextWrap = wrapAffectsTextFlow(wrapType); + const bounds = { x, y, width: objectWidth, height: objectHeight }; + + return { + paint: bounds, + exclusion: affectsTextWrap ? { ...bounds } : null, + layer: { + behindDoc, + zIndex: layer?.zIndex, + }, + }; +} + +export function resolveDrawingPlacement( + block: ImageBlock | DrawingBlock, + measure: ImageMeasure | DrawingMeasure, + context: Omit, +): ResolvedGraphicPlacement { + return resolveGraphicPlacement({ + ...context, + anchor: block.anchor, + objectWidth: measure.width ?? 0, + objectHeight: measure.height ?? 0, + wrapType: block.wrap?.type, + layer: { + behindDoc: block.anchor?.behindDoc, + zIndex: getFragmentZIndex(block), + }, + }); +} + +export function resolveTablePlacement( + anchor: TableAnchor | undefined, + measure: TableMeasure, + wrap: TableWrap | undefined, + context: Omit, +): ResolvedGraphicPlacement { + return resolveGraphicPlacement({ + ...context, + anchor, + objectWidth: measure.totalWidth ?? 0, + objectHeight: measure.totalHeight ?? 0, + wrapType: wrap?.type, + }); +} diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 208db77358..7c18d74ae4 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -3816,9 +3816,8 @@ describe('layoutHeaderFooter', () => { expect(layout.pages).toHaveLength(1); const imageFragment = layout.pages[0].fragments.find((f) => f.kind === 'image'); expect(imageFragment).toBeDefined(); - // offsetH is passed through to inner layout's computeAnchorX; the result - // includes the offset plus margin-left added by computeAnchorX for page-relative. - // Inner layout has margins=0, so computeAnchorX returns offsetH + 0 = 545. + // offsetH is passed through to the inner layout's shared graphic placement resolver. + // Inner layout has margins=0, so page-relative x resolves to offsetH + 0 = 545. expect(imageFragment!.x).toBe(545); }); diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 71d14836b6..141404f11b 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -36,18 +36,16 @@ import type { import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, - getFragmentZIndex, getColumnGeometry, getColumnWidth, getColumnX, columnRenderLayoutsEqual, resolveColumnCount, resolveColumnLayout, - resolveAnchoredGraphicY, resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage, } from '@superdoc/contracts'; -import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; +import { createFloatingObjectManager } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { scheduleSectionBreak as scheduleSectionBreakExport, @@ -77,6 +75,7 @@ import { type SectionColumnLayout, } from './column-balancing.js'; import { cloneColumnLayout } from './column-utils.js'; +import { resolveDrawingPlacement, resolveTablePlacement, type ResolvedGraphicPlacement } from './graphic-placement.js'; type PageSize = { w: number; h: number }; type Margins = { @@ -2045,88 +2044,56 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // Map to store pre-computed positions for page-relative anchors (for fragment creation later). // Page placement is resolved at encounter time so anchors follow pagination (e.g., after page breaks). - const preRegisteredPositions = new Map(); - - const resolveParagraphlessAnchoredTableY = (block: TableBlock, measure: TableMeasure, state: PageState): number => { - const contentTop = state.topMargin; - const contentBottom = state.contentBottom; - const tableHeight = measure.totalHeight ?? 0; - - return resolveAnchoredGraphicY({ - anchor: block.anchor as Parameters[0]['anchor'], - objectHeight: tableHeight, - contentTop, - contentBottom, + const preRegisteredPositions = new Map(); + + const resolveParagraphlessAnchoredTablePlacement = ( + block: TableBlock, + measure: TableMeasure, + state: PageState, + ): ResolvedGraphicPlacement => + resolveTablePlacement(block.anchor, measure, block.wrap, { + columnIndex: state.columnIndex, + columns: normalizeColumns(activeColumns, activePageSize.w - (activeLeftMargin + activeRightMargin)), + pageMargins: { left: activeLeftMargin, right: activeRightMargin, bottom: activeBottomMargin }, + pageWidth: activePageSize.w, + contentTop: state.topMargin, + contentBottom: state.contentBottom, pageBottomMargin: state.page.margins?.bottom ?? activeBottomMargin, preRegisteredFallbackToContentTop: true, + fallbackX: columnX(state), }); - }; - const resolveParagraphlessAnchoredDrawingY = ( + const resolveParagraphlessAnchoredDrawingPlacement = ( block: ImageBlock | DrawingBlock, measure: ImageMeasure | DrawingMeasure, state: PageState, - ): number => - resolveAnchoredGraphicY({ - anchor: block.anchor, - objectHeight: measure.height ?? 0, + ): ResolvedGraphicPlacement => + resolveDrawingPlacement(block, measure, { + columnIndex: state.columnIndex, + columns: normalizeColumns(activeColumns, activePageSize.w - (activeLeftMargin + activeRightMargin)), + pageMargins: { left: activeLeftMargin, right: activeRightMargin, bottom: activeBottomMargin }, + pageWidth: activePageSize.w, contentTop: state.topMargin, contentBottom: state.contentBottom, pageBottomMargin: state.page.margins?.bottom ?? activeBottomMargin, preRegisteredFallbackToContentTop: true, + fallbackX: columnX(state), }); - const resolveParagraphlessAnchoredDrawingX = ( - block: ImageBlock | DrawingBlock, - measure: ImageMeasure | DrawingMeasure, - state: PageState, - ): number => - block.anchor - ? computeAnchorX( - block.anchor, - state.columnIndex, - normalizeColumns(activeColumns, activePageSize.w - (activeLeftMargin + activeRightMargin)), - measure.width, - { left: activeLeftMargin, right: activeRightMargin }, - activePageSize.w, - ) - : columnX(state); - for (const entry of preRegisteredAnchors) { // Ensure first page exists const state = paginator.ensurePage(); - const contentTop = state.topMargin; - const contentBottom = state.contentBottom; - 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 - ? computeAnchorX( - entry.block.anchor, - state.columnIndex, - normalizeColumns(activeColumns, activePageSize.w - (activeLeftMargin + activeRightMargin)), - entry.measure.width, - { left: activeLeftMargin, right: activeRightMargin }, - activePageSize.w, - ) - : activeLeftMargin; + const placement = resolveParagraphlessAnchoredDrawingPlacement(entry.block, entry.measure, state); // Register with float manager so all paragraphs see this exclusion // NOTE: We only register exclusion zones here, NOT fragments. // Fragments will be created when the image block is encountered in the layout loop. // This prevents the section break logic from seeing "content" on the page and creating a new page. - floatManager.registerDrawing(entry.block, entry.measure, anchorY, state.columnIndex, state.page.number); + floatManager.registerDrawing(entry.block, entry.measure, placement, state.columnIndex, state.page.number); // Store pre-computed position for later use when creating the fragment. - preRegisteredPositions.set(entry.block.id, { anchorX, anchorY }); + preRegisteredPositions.set(entry.block.id, placement); } // Pre-compute keepNext chains for correct pagination grouping. @@ -2709,8 +2676,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } // Check if this is a pre-registered page-relative anchor - const preRegPos = preRegisteredPositions.get(block.id); - if (preRegPos && Number.isFinite(preRegPos.anchorX) && Number.isFinite(preRegPos.anchorY)) { + const preRegPlacement = preRegisteredPositions.get(block.id); + if (preRegPlacement && Number.isFinite(preRegPlacement.paint.x) && Number.isFinite(preRegPlacement.paint.y)) { // Use pre-computed coordinates, but place on the current pagination page where this block is encountered. const state = paginator.ensurePage(); const imgBlock = block as ImageBlock; @@ -2745,13 +2712,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const fragment: ImageFragment = { kind: 'image', blockId: imgBlock.id, - x: preRegPos.anchorX, - y: preRegPos.anchorY, + x: preRegPlacement.paint.x, + y: preRegPlacement.paint.y, width: imgMeasure.width, height: imgMeasure.height, isAnchored: true, - behindDoc: imgBlock.anchor?.behindDoc === true, - zIndex: getFragmentZIndex(imgBlock), + behindDoc: preRegPlacement.layer.behindDoc, + zIndex: preRegPlacement.layer.zIndex, metadata, sourceAnchor: imgBlock.sourceAnchor, }; @@ -2781,8 +2748,8 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } // Check if this is a pre-registered page-relative anchor - const preRegPos = preRegisteredPositions.get(block.id); - if (preRegPos && Number.isFinite(preRegPos.anchorX) && Number.isFinite(preRegPos.anchorY)) { + const preRegPlacement = preRegisteredPositions.get(block.id); + if (preRegPlacement && Number.isFinite(preRegPlacement.paint.x) && Number.isFinite(preRegPlacement.paint.y)) { // Use pre-computed coordinates, but place on the current pagination page where this block is encountered. const state = paginator.ensurePage(); const drawBlock = block as DrawingBlock; @@ -2796,15 +2763,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options kind: 'drawing', blockId: drawBlock.id, drawingKind: drawBlock.drawingKind, - x: preRegPos.anchorX, - y: preRegPos.anchorY, + x: preRegPlacement.paint.x, + y: preRegPlacement.paint.y, width: drawMeasure.width, height: drawMeasure.height, geometry: drawMeasure.geometry, scale: drawMeasure.scale, isAnchored: true, - behindDoc: drawBlock.anchor?.behindDoc === true, - zIndex: getFragmentZIndex(drawBlock), + behindDoc: preRegPlacement.layer.behindDoc, + zIndex: preRegPlacement.layer.zIndex, drawingContentId: drawBlock.drawingContentId, sourceAnchor: drawBlock.sourceAnchor, }; @@ -2924,8 +2891,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options for (const { block, measure } of paragraphlessAnchoredDrawings) { if (placedAnchoredIds.has(block.id)) continue; - const anchorX = resolveParagraphlessAnchoredDrawingX(block, measure, state); - const anchorY = resolveParagraphlessAnchoredDrawingY(block, measure, state); + const placement = resolveParagraphlessAnchoredDrawingPlacement(block, measure, state); if (block.kind === 'image' && measure.kind === 'image') { const pageContentHeight = Math.max(0, state.contentBottom - state.topMargin); @@ -2935,13 +2901,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const fragment: ImageFragment = { kind: 'image', blockId: block.id, - x: anchorX, - y: anchorY, + x: placement.paint.x, + y: placement.paint.y, width: measure.width, height: measure.height, isAnchored: true, - behindDoc: block.anchor?.behindDoc === true, - zIndex: getFragmentZIndex(block), + behindDoc: placement.layer.behindDoc, + zIndex: placement.layer.zIndex, metadata: { originalWidth: measure.width, originalHeight: measure.height, @@ -2970,15 +2936,15 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options kind: 'drawing', blockId: block.id, drawingKind: block.drawingKind, - x: anchorX, - y: anchorY, + x: placement.paint.x, + y: placement.paint.y, width: measure.width, height: measure.height, geometry: measure.geometry, scale: measure.scale, isAnchored: true, - behindDoc: block.anchor?.behindDoc === true, - zIndex: getFragmentZIndex(block), + behindDoc: placement.layer.behindDoc, + zIndex: placement.layer.zIndex, drawingContentId: block.drawingContentId, sourceAnchor: block.sourceAnchor, }; @@ -3006,11 +2972,12 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options continue; } - const anchorY = resolveParagraphlessAnchoredTableY(tableBlock, tableMeasure, state); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state); + const placement = resolveParagraphlessAnchoredTablePlacement(tableBlock, tableMeasure, state); - floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); - state.page.fragments.push(createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY)); + floatManager.registerTable(tableBlock, tableMeasure, placement, state.columnIndex, state.page.number); + state.page.fragments.push( + createAnchoredTableFragment(tableBlock, tableMeasure, placement.paint.x, placement.paint.y), + ); } } @@ -3847,5 +3814,12 @@ export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTok export { getCellLines, getEmbeddedRowLines, resolveTableFrame, resolveRenderedTableWidth } from './layout-table.js'; export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; export { layoutTextboxContent } from './layout-textbox.js'; +export { + resolveGraphicPlacement, + resolveDrawingPlacement, + resolveTablePlacement, + type ResolvedGraphicPlacement, + type ResolveGraphicPlacementInput, +} from './graphic-placement.js'; export { SINGLE_COLUMN_DEFAULT } from './section-breaks.js'; diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 22d98b83ee..2b6b4f7ba5 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -27,9 +27,9 @@ import { computeParagraphLayoutStartY, } from './layout-utils.js'; import { layoutTextboxContent } from './layout-textbox.js'; -import { resolveAnchoredGraphicY, resolveAnchoredGraphicX, getFragmentZIndex } from '@superdoc/contracts'; import { createAnchoredTableFragment, isAnchoredTableFullWidth } from './layout-table.js'; import type { AnchoredTable } from './anchors.js'; +import { resolveDrawingPlacement, resolveTablePlacement } from './graphic-placement.js'; /** Points → CSS pixels (96 dpi / 72 pt-per-inch). */ const PX_PER_PT = 96 / 72; @@ -61,25 +61,6 @@ function anchorForLineScopedFormField( return { ...anchor, vRelativeFrom: 'paragraph', alignV: 'center', offsetV: 0 }; } -type GraphicPlacementAnchorY = Parameters[0]['anchor']; - -function graphicAnchorY(anchor: TableAnchor | undefined): GraphicPlacementAnchorY { - if (!anchor) return undefined; - const alignV = anchor.alignV; - const mappedAlignV = alignV === 'top' || alignV === 'center' || alignV === 'bottom' ? alignV : undefined; - return { vRelativeFrom: anchor.vRelativeFrom, alignV: mappedAlignV, offsetV: anchor.offsetV }; -} - -function graphicAnchorH(anchor: TableAnchor): Parameters[0] { - const alignH = anchor.alignH; - const mappedAlignH = alignH === 'left' || alignH === 'center' || alignH === 'right' ? alignH : undefined; - return { - hRelativeFrom: anchor.hRelativeFrom, - alignH: mappedAlignH, - offsetH: anchor.offsetH, - }; -} - /** * SD-2656: ordered footnote anchor entry. The body slicer reads the candidate * anchors for a given PM range and pushes them onto `PageState.footnoteAnchorsThisPage` @@ -524,30 +505,20 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para if (anchors.placedAnchoredIds.has(entry.block.id)) continue; const state = ensurePage(); - const contentTop = state.topMargin; - const contentBottom = state.contentBottom; - const anchorY = resolveAnchoredGraphicY({ - anchor: entry.block.anchor, - objectHeight: entry.measure.height, - contentTop, - contentBottom, + const placement = resolveDrawingPlacement(entry.block, entry.measure, { + columnIndex: state.columnIndex, + columns: anchors.columns, + pageMargins: anchors.pageMargins, + pageWidth: anchors.pageWidth, + contentTop: state.topMargin, + contentBottom: state.contentBottom, pageBottomMargin: anchors.pageMargins.bottom ?? 0, anchorParagraphY: paragraphContentStartY, firstLineHeight: measure.lines?.[0]?.lineHeight ?? 0, + fallbackX: columnX(state), }); - floatManager.registerDrawing(entry.block, entry.measure, anchorY, state.columnIndex, state.page.number); - - const anchorX = entry.block.anchor - ? resolveAnchoredGraphicX( - entry.block.anchor, - state.columnIndex, - anchors.columns, - entry.measure.width, - { left: anchors.pageMargins.left, right: anchors.pageMargins.right }, - anchors.pageWidth, - ) - : columnX(state); + floatManager.registerDrawing(entry.block, entry.measure, placement, state.columnIndex, state.page.number); const pmRange = extractBlockPmRange(entry.block); if (entry.block.kind === 'image' && entry.measure.kind === 'image') { @@ -582,13 +553,13 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para const fragment: ImageFragment = { kind: 'image', blockId: entry.block.id, - x: anchorX, - y: anchorY, + x: placement.paint.x, + y: placement.paint.y, width: entry.measure.width, height: entry.measure.height, isAnchored: true, - behindDoc: entry.block.anchor?.behindDoc === true, - zIndex: getFragmentZIndex(entry.block), + behindDoc: placement.layer.behindDoc, + zIndex: placement.layer.zIndex, metadata, sourceAnchor: entry.block.sourceAnchor, }; @@ -604,15 +575,15 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para kind: 'drawing', blockId: entry.block.id, drawingKind: entry.block.drawingKind, - x: anchorX, - y: anchorY, + x: placement.paint.x, + y: placement.paint.y, width: entry.measure.width, height: entry.measure.height, geometry: entry.measure.geometry, scale: entry.measure.scale, isAnchored: true, - behindDoc: entry.block.anchor?.behindDoc === true, - zIndex: getFragmentZIndex(entry.block), + behindDoc: placement.layer.behindDoc, + zIndex: placement.layer.zIndex, drawingContentId: entry.block.drawingContentId, sourceAnchor: entry.block.sourceAnchor, }; @@ -636,14 +607,11 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para let nextStackY = paragraphContentStartY; for (const entry of entries) { if (anchors!.placedAnchoredIds.has(entry.block.id)) continue; - const totalWidth = entry.measure.totalWidth ?? 0; if (isAnchoredTableFullWidth(entry.block, entry.measure, columnWidthForTable)) { continue; } const state = ensurePage(); - const contentTop = state.topMargin; - const contentBottom = state.contentBottom; const layoutOffsetV = entry.layoutOffsetV; const firstLineHeight = measure.lines?.[0]?.lineHeight ?? 0; const wrapType = entry.block.wrap?.type ?? 'None'; @@ -657,34 +625,29 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para entry.lineScopedOnAnchor === true, wrapType, ); - const anchorY = resolveAnchoredGraphicY({ - anchor: graphicAnchorY(anchorForY), - objectHeight: entry.measure.totalHeight ?? 0, - contentTop, - contentBottom, + + const placement = resolveTablePlacement(anchorForY, entry.measure, entry.block.wrap, { + columnIndex: state.columnIndex, + columns: anchors!.columns, + pageMargins: anchors!.pageMargins, + pageWidth: anchors!.pageWidth, + contentTop: state.topMargin, + contentBottom: state.contentBottom, pageBottomMargin: anchors!.pageMargins.bottom ?? 0, anchorParagraphY: nextStackY, firstLineHeight: measure.lines?.[0]?.lineHeight ?? 0, + fallbackX: columnX(state), }); - floatManager.registerTable(entry.block, entry.measure, anchorY, state.columnIndex, state.page.number); + floatManager.registerTable(entry.block, entry.measure, placement, state.columnIndex, state.page.number); - const anchorX = entry.block.anchor - ? resolveAnchoredGraphicX( - graphicAnchorH(entry.block.anchor), - state.columnIndex, - anchors!.columns, - totalWidth, - { left: anchors!.pageMargins.left, right: anchors!.pageMargins.right }, - anchors!.pageWidth, - ) - : columnX(state); - - state.page.fragments.push(createAnchoredTableFragment(entry.block, entry.measure, anchorX, anchorY)); + state.page.fragments.push( + createAnchoredTableFragment(entry.block, entry.measure, placement.paint.x, placement.paint.y), + ); anchors!.placedAnchoredIds.add(entry.block.id); if (wrapType !== 'None') { - const bottom = anchorY + (entry.measure.totalHeight ?? 0); + const bottom = placement.paint.y + (entry.measure.totalHeight ?? 0); const distBottom = entry.block.wrap?.distBottom ?? 0; nextStackY = Math.max(nextStackY, bottom + distBottom); } diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index cd22ae83dd..4627c78e9f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -397,6 +397,43 @@ describe('renderTableCell', () => { expect(imgEl?.parentElement?.style.top).toBe('5px'); }); + it('keeps wrap-only behindDoc anchored image blocks behind table-cell content', () => { + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-wrap-behind', + runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 16 }], + }; + + const anchoredImage: ImageBlock = { + kind: 'image', + id: 'img-wrap-behind', + src: 'data:image/png;base64,AAA', + anchor: { isAnchored: true, alignH: 'left', offsetH: 10, vRelativeFrom: 'paragraph', offsetV: 5 }, + wrap: { type: 'None', behindDoc: true }, + attrs: { anchorParagraphId: 'para-wrap-behind' }, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, { kind: 'image' as const, width: 20, height: 10 }], + width: 80, + height: 30, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-wrap-behind', + blocks: [para, anchoredImage], + attrs: {}, + }, + }); + + const imgEl = cellElement.querySelector('img.superdoc-table-image') as HTMLImageElement | null; + expect(imgEl?.parentElement?.style.zIndex).toBe('0'); + }); + it('applies top-level clipPath to anchored image blocks inside table cells', () => { const para: ParagraphBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index ff51462e4f..9450c135bf 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -18,7 +18,7 @@ import type { WrapExclusion, WrapTextMode, } from '@superdoc/contracts'; -import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; +import { rescaleColumnWidths, getCellSpacingPx, getFragmentZIndex } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; @@ -1081,10 +1081,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const behindDoc = anchor.behindDoc === true || (anchoredBlock.wrap?.type === 'None' && anchoredBlock.wrap?.behindDoc); - const zIndex = - typeof anchoredBlock.zIndex === 'number' - ? anchoredBlock.zIndex - : (normalizeZIndex(anchoredBlock.attrs?.originalAttributes) ?? (behindDoc ? -1 : 1)); + const zIndex = behindDoc ? 0 : getFragmentZIndex(anchoredBlock); const wrap = anchoredBlock.wrap; if (!behindDoc && wrap?.type === 'Square') { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.ts index 94d3a03b0a..9a13c95aa0 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/chart.ts @@ -13,10 +13,8 @@ import { toBoolean, toBoxSpacing, toDrawingContentSnapshot, - normalizeZIndex, - resolveFloatingZIndex, } from '../utilities.js'; -import { normalizeGraphicAnchor } from '../graphic-placement.js'; +import { normalizeGraphicPlacement } from '../graphic-placement.js'; // ============================================================================ // Constants @@ -103,11 +101,13 @@ export function chartNodeToDrawingBlock( const normalizedWrap = normalizeWrap(rawAttrs.wrap); const sourceAnchor = isPlainObject(rawAttrs.sourceAnchor) ? (rawAttrs.sourceAnchor as SourceAnchor) : undefined; - const anchor = normalizeGraphicAnchor({ + const placement = normalizeGraphicPlacement({ anchorData: rawAttrs.anchorData, attrs: rawAttrs, wrapBehindDoc: normalizedWrap?.behindDoc, + fallbackZIndex: 1, }); + const anchor = placement.anchor; const pos = positions.get(node); const attrsWithPm: Record = { ...rawAttrs }; @@ -116,10 +116,6 @@ export function chartNodeToDrawingBlock( attrsWithPm.pmEnd = pos.end; } - const behindDoc = anchor?.behindDoc === true || normalizedWrap?.behindDoc === true; - const zIndexFromRelativeHeight = normalizeZIndex(rawAttrs.originalAttributes); - const resolvedZIndex = resolveFloatingZIndex(behindDoc, zIndexFromRelativeHeight, 1); - return { kind: 'drawing', id: nextBlockId('drawing'), @@ -132,7 +128,7 @@ export function chartNodeToDrawingBlock( margin: toBoxSpacing(rawAttrs.marginOffset as Record | undefined), anchor, wrap: normalizedWrap, - zIndex: resolvedZIndex, + zIndex: placement.zIndex, drawingContentId: typeof rawAttrs.drawingContentId === 'string' ? rawAttrs.drawingContentId : undefined, drawingContent: toDrawingContentSnapshot(rawAttrs.drawingContent), attrs: attrsWithPm, 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 61e8edcc58..b82eca8d2c 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 @@ -11,13 +11,11 @@ import { shouldHideTrackedNode, annotateBlockWithTrackedChange } from '../tracke import { isFiniteNumber, pickNumber, - normalizeZIndex, - resolveFloatingZIndex, readImageHyperlink, mergeWrapDistancesFromPadding, toBoolean, } from '../utilities.js'; -import { normalizeGraphicAnchor } from '../graphic-placement.js'; +import { normalizeGraphicPlacement } from '../graphic-placement.js'; // ============================================================================ // Constants @@ -180,17 +178,13 @@ export function imageNodeToBlock( if (normalizedWrap) { mergeWrapDistancesFromPadding(normalizedWrap, toBoxSpacing(attrs.padding as Record | undefined)); } - let anchor = normalizeGraphicAnchor({ + const placement = normalizeGraphicPlacement({ anchorData: attrs.anchorData, attrs, wrapBehindDoc: normalizedWrap?.behindDoc, + forceAnchor: Boolean(normalizedWrap), }); - if (!anchor && normalizedWrap) { - anchor = { isAnchored: true }; - if (normalizedWrap.behindDoc != null) { - anchor.behindDoc = normalizedWrap.behindDoc; - } - } + const anchor = placement.anchor; const isInline = normalizedWrap?.type === 'Inline' || (typeof attrs.inline === 'boolean' && attrs.inline); const display: 'inline' | 'block' = explicitDisplay === 'inline' || explicitDisplay === 'block' ? explicitDisplay : isInline ? 'inline' : 'block'; @@ -214,9 +208,7 @@ export function imageNodeToBlock( ? 'contain' : 'contain'; - // Same z-index as editor: from OOXML relativeHeight (Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE)) - const zIndexFromRelativeHeight = normalizeZIndex(attrs.originalAttributes as Record | undefined); - const zIndex = resolveFloatingZIndex(anchor?.behindDoc === true, zIndexFromRelativeHeight); + const zIndex = placement.zIndex; // Extract rotation/flip transforms from transformData const transformData = isPlainObject(attrs.transformData) ? attrs.transformData : undefined; 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 d459471e48..566573d434 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 @@ -39,13 +39,11 @@ import { normalizeTextContent, normalizeTextVerticalAlign, normalizeTextInsets, - normalizeZIndex, - resolveFloatingZIndex, mergeWrapDistancesFromPadding, ptToPx, } from '../utilities.js'; import { getLastParagraphFont } from './paragraph.js'; -import { normalizeGraphicAnchor } from '../graphic-placement.js'; +import { normalizeGraphicPlacement } from '../graphic-placement.js'; // ============================================================================ // Constants @@ -494,11 +492,13 @@ export const buildDrawingBlock = ( ); } const sourceAnchor = isPlainObject(rawAttrs.sourceAnchor) ? (rawAttrs.sourceAnchor as SourceAnchor) : undefined; - const baseAnchor = normalizeGraphicAnchor({ + const placement = normalizeGraphicPlacement({ anchorData: rawAttrs.anchorData, attrs: rawAttrs, wrapBehindDoc: normalizedWrap?.behindDoc, + fallbackZIndex: coerceNumber(rawAttrs.zIndex) ?? 1, }); + const baseAnchor = placement.anchor; const pos = positions.get(node); const attrsWithPm: Record = { ...rawAttrs }; if (pos) { @@ -506,11 +506,6 @@ export const buildDrawingBlock = ( attrsWithPm.pmEnd = pos.end; } - const behindDoc = baseAnchor?.behindDoc === true || normalizedWrap?.behindDoc === true; - // Try to get zIndex from relativeHeight first, fallback to direct zIndex attribute - const zIndexFromRelativeHeight = normalizeZIndex(rawAttrs.originalAttributes); - const resolvedZIndex = resolveFloatingZIndex(behindDoc, zIndexFromRelativeHeight, coerceNumber(rawAttrs.zIndex) ?? 1); - return { kind: 'drawing', id: nextBlockId('drawing'), @@ -521,7 +516,7 @@ export const buildDrawingBlock = ( toBoxSpacing(rawAttrs.margin as Record | undefined), anchor: baseAnchor, wrap: normalizedWrap, - zIndex: resolvedZIndex, + zIndex: placement.zIndex, drawingContentId: typeof rawAttrs.drawingContentId === 'string' ? rawAttrs.drawingContentId : undefined, drawingContent: toDrawingContentSnapshot(rawAttrs.drawingContent), attrs: attrsWithPm, diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.test.ts index 968b91c8aa..cac51592ba 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeGraphicAnchor } from './graphic-placement.js'; +import { normalizeGraphicAnchor, normalizeGraphicPlacement } from './graphic-placement.js'; describe('normalizeGraphicAnchor', () => { it('returns undefined when there is no authored placement data', () => { @@ -115,3 +115,52 @@ describe('normalizeGraphicAnchor', () => { }); }); }); + +describe('normalizeGraphicPlacement', () => { + const OOXML_Z_INDEX_BASE = 251658240; + + it('centralizes relativeHeight z-index normalization', () => { + const placement = normalizeGraphicPlacement({ + anchorData: { isAnchored: true }, + attrs: { + originalAttributes: { + relativeHeight: OOXML_Z_INDEX_BASE + 25, + }, + }, + fallbackZIndex: 1, + }); + + expect(placement.anchor).toEqual({ isAnchored: true }); + expect(placement.behindDoc).toBe(false); + expect(placement.zIndex).toBe(25); + }); + + it('forces behind-doc graphics to z-index zero through typed placement data', () => { + const placement = normalizeGraphicPlacement({ + anchorData: { isAnchored: true, behindDoc: true }, + attrs: { + originalAttributes: { + relativeHeight: OOXML_Z_INDEX_BASE + 25, + }, + }, + fallbackZIndex: 1, + }); + + expect(placement.anchor).toEqual({ isAnchored: true, behindDoc: true }); + expect(placement.behindDoc).toBe(true); + expect(placement.zIndex).toBe(0); + }); + + it('can force anchored placement for wrap-only graphics', () => { + const placement = normalizeGraphicPlacement({ + anchorData: undefined, + attrs: {}, + wrapBehindDoc: true, + forceAnchor: true, + }); + + expect(placement.anchor).toEqual({ isAnchored: true, behindDoc: true }); + expect(placement.behindDoc).toBe(true); + expect(placement.zIndex).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.ts index a256aab361..d6061ad9d4 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/graphic-placement.ts @@ -1,5 +1,5 @@ import type { GraphicPlacement } from '@superdoc/contracts'; -import { isPlainObject, pickNumber, toBoolean } from './utilities.js'; +import { isPlainObject, normalizeZIndex, pickNumber, resolveFloatingZIndex, toBoolean } from './utilities.js'; const H_RELATIVE_VALUES = new Set(['column', 'page', 'margin']); const V_RELATIVE_VALUES = new Set(['paragraph', 'page', 'margin']); @@ -22,6 +22,17 @@ export type NormalizeGraphicAnchorInput = { wrapBehindDoc?: boolean; }; +export type NormalizeGraphicPlacementInput = NormalizeGraphicAnchorInput & { + forceAnchor?: boolean; + fallbackZIndex?: number; +}; + +export type NormalizedGraphicPlacement = { + anchor?: GraphicPlacement; + behindDoc: boolean; + zIndex?: number; +}; + export const normalizeGraphicAnchor = ({ anchorData, attrs, @@ -71,3 +82,30 @@ export const normalizeGraphicAnchor = ({ return hasData ? anchor : undefined; }; + +export const normalizeGraphicPlacement = ({ + anchorData, + attrs, + wrapBehindDoc, + forceAnchor = false, + fallbackZIndex, +}: NormalizeGraphicPlacementInput): NormalizedGraphicPlacement => { + let anchor = normalizeGraphicAnchor({ anchorData, attrs, wrapBehindDoc }); + + if (!anchor && forceAnchor) { + anchor = { isAnchored: true }; + } else if (anchor && forceAnchor) { + anchor.isAnchored = true; + } + + if (anchor && anchor.behindDoc == null && wrapBehindDoc != null) { + anchor.behindDoc = wrapBehindDoc; + } + + const behindDoc = anchor?.behindDoc === true || wrapBehindDoc === true; + const originalAttrs = isPlainObject(attrs.originalAttributes) ? attrs.originalAttributes : undefined; + const zIndexFromRelativeHeight = normalizeZIndex(originalAttrs); + const zIndex = resolveFloatingZIndex(behindDoc, zIndexFromRelativeHeight, fallbackZIndex); + + return { anchor, behindDoc, zIndex }; +};