diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 326af437f9..2f80b5c8ec 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -16,7 +16,14 @@ export { rescaleColumnWidths } from './table-column-rescale.js'; export { getCellSpacingPx } from './cell-spacing.js'; // OOXML z-index normalization (moved from pm-adapter for cross-stage use) -export { normalizeZIndex, coerceRelativeHeight, isPlainObject, OOXML_Z_INDEX_BASE } from './ooxml-z-index.js'; +export { + normalizeZIndex, + coerceRelativeHeight, + isPlainObject, + OOXML_Z_INDEX_BASE, + resolveFloatingZIndex, + getFragmentZIndex, +} from './ooxml-z-index.js'; // Export justify utilities export { diff --git a/packages/layout-engine/contracts/src/ooxml-z-index.test.ts b/packages/layout-engine/contracts/src/ooxml-z-index.test.ts new file mode 100644 index 0000000000..12b4b831c3 --- /dev/null +++ b/packages/layout-engine/contracts/src/ooxml-z-index.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { + coerceRelativeHeight, + normalizeZIndex, + OOXML_Z_INDEX_BASE, + resolveFloatingZIndex, + getFragmentZIndex, +} from './ooxml-z-index.js'; + +describe('ooxml-z-index', () => { + describe('coerceRelativeHeight', () => { + it('returns number when given a finite number', () => { + expect(coerceRelativeHeight(251658240)).toBe(251658240); + expect(coerceRelativeHeight(0)).toBe(0); + }); + + it('returns number when given a numeric string', () => { + expect(coerceRelativeHeight('251658240')).toBe(251658240); + expect(coerceRelativeHeight('251659318')).toBe(251659318); + }); + + it('returns undefined for non-finite number', () => { + expect(coerceRelativeHeight(NaN)).toBeUndefined(); + expect(coerceRelativeHeight(Infinity)).toBeUndefined(); + }); + + it('returns undefined for empty or invalid string', () => { + expect(coerceRelativeHeight('')).toBeUndefined(); + expect(coerceRelativeHeight(' ')).toBeUndefined(); + expect(coerceRelativeHeight('abc')).toBeUndefined(); + }); + + it('returns undefined for null, undefined, or non-number/string', () => { + expect(coerceRelativeHeight(null)).toBeUndefined(); + expect(coerceRelativeHeight(undefined)).toBeUndefined(); + expect(coerceRelativeHeight({})).toBeUndefined(); + }); + }); + + describe('normalizeZIndex', () => { + it('returns 0 for OOXML base relativeHeight', () => { + expect(normalizeZIndex({ relativeHeight: OOXML_Z_INDEX_BASE })).toBe(0); + expect(normalizeZIndex({ relativeHeight: '251658240' })).toBe(0); + }); + + it('returns positive z-index for relativeHeight above base', () => { + expect(normalizeZIndex({ relativeHeight: OOXML_Z_INDEX_BASE + 2 })).toBe(2); + expect(normalizeZIndex({ relativeHeight: OOXML_Z_INDEX_BASE + 51 })).toBe(51); + expect(normalizeZIndex({ relativeHeight: '251658291' })).toBe(51); + }); + + it('returns undefined when relativeHeight is missing or invalid', () => { + expect(normalizeZIndex({})).toBeUndefined(); + expect(normalizeZIndex(null)).toBeUndefined(); + expect(normalizeZIndex(undefined)).toBeUndefined(); + expect(normalizeZIndex({ relativeHeight: '' })).toBeUndefined(); + }); + }); + + describe('resolveFloatingZIndex', () => { + it('returns 0 when behindDoc is true', () => { + expect(resolveFloatingZIndex(true, 42)).toBe(0); + expect(resolveFloatingZIndex(true, undefined)).toBe(0); + expect(resolveFloatingZIndex(true, 0)).toBe(0); + }); + + it('returns raw value when non-behindDoc and raw >= 1', () => { + expect(resolveFloatingZIndex(false, 5)).toBe(5); + expect(resolveFloatingZIndex(false, 100)).toBe(100); + }); + + it('clamps raw 0 to 1 for non-behindDoc', () => { + expect(resolveFloatingZIndex(false, 0)).toBe(1); + }); + + it('returns fallback when raw is undefined', () => { + expect(resolveFloatingZIndex(false, undefined)).toBe(1); + expect(resolveFloatingZIndex(false, undefined, 5)).toBe(5); + }); + + it('clamps fallback to at least 1', () => { + expect(resolveFloatingZIndex(false, undefined, 0)).toBe(1); + expect(resolveFloatingZIndex(false, undefined, -1)).toBe(1); + }); + }); + + describe('getFragmentZIndex', () => { + it('uses block.zIndex when set', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + zIndex: 42, + attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE } }, + }; + expect(getFragmentZIndex(block)).toBe(42); + }); + + it('derives z-index from attrs.originalAttributes.relativeHeight (number)', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 10 } }, + }; + expect(getFragmentZIndex(block)).toBe(10); + }); + + it('derives z-index from attrs.originalAttributes.relativeHeight (string)', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + attrs: { originalAttributes: { relativeHeight: '251658250' } }, + }; + expect(getFragmentZIndex(block)).toBe(10); + }); + + it('preserves high z-index for wrapped anchored objects', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + anchor: { isAnchored: true, behindDoc: false }, + wrap: { type: 'Through' as const }, + zIndex: 7168, + }; + expect(getFragmentZIndex(block)).toBe(7168); + }); + + it('preserves relativeHeight z-index for wrap None anchored objects', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + anchor: { isAnchored: true, behindDoc: false }, + wrap: { type: 'None' as const }, + attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 10 } }, + }; + expect(getFragmentZIndex(block)).toBe(10); + }); + + it('returns 0 when anchor.behindDoc is true and no zIndex/originalAttributes', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + anchor: { isAnchored: true, behindDoc: true }, + }; + expect(getFragmentZIndex(block)).toBe(0); + }); + + it('returns 1 when not behindDoc and no zIndex/originalAttributes', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + }; + expect(getFragmentZIndex(block)).toBe(1); + }); + + it('does not treat base relativeHeight as behindDoc when behindDoc is false', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + anchor: { isAnchored: true, behindDoc: false }, + attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE } }, + }; + expect(getFragmentZIndex(block)).toBeGreaterThan(0); + }); + + it('forces behindDoc fragments to zIndex 0 even with relativeHeight', () => { + const block = { + kind: 'image' as const, + id: 'img-1', + src: 'x.png', + anchor: { isAnchored: true, behindDoc: true }, + attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 5 } }, + }; + expect(getFragmentZIndex(block)).toBe(0); + }); + + it('works for drawing blocks', () => { + const block = { + kind: 'drawing' as const, + id: 'd-1', + drawingKind: 'vectorShape' as const, + attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 5 } }, + }; + expect(getFragmentZIndex(block)).toBe(5); + }); + }); +}); diff --git a/packages/layout-engine/contracts/src/ooxml-z-index.ts b/packages/layout-engine/contracts/src/ooxml-z-index.ts index 1f3764a67a..a62cf4da32 100644 --- a/packages/layout-engine/contracts/src/ooxml-z-index.ts +++ b/packages/layout-engine/contracts/src/ooxml-z-index.ts @@ -5,6 +5,8 @@ * These helpers convert to small positive CSS z-index values. */ +import type { ImageBlock, DrawingBlock } from './index.js'; + /** Checks whether `value` is a non-null, non-array object. */ export const isPlainObject = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); @@ -57,3 +59,22 @@ export function normalizeZIndex(originalAttributes: unknown): number | undefined if (relativeHeight === undefined) return undefined; return Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE); } + +/** + * Resolves floating z-index based on behindDoc flag and raw OOXML value. + */ +export function resolveFloatingZIndex(behindDoc: boolean, raw: number | undefined, fallback = 1): number { + if (behindDoc) return 0; + if (raw === undefined) return Math.max(1, fallback); + return Math.max(1, raw); +} + +/** + * Returns z-index for an image or drawing block. + * Uses block.zIndex when present, otherwise derives from originalAttributes. + */ +export function getFragmentZIndex(block: ImageBlock | DrawingBlock): number { + const attrs = block.attrs as { originalAttributes?: unknown } | undefined; + const raw = typeof block.zIndex === 'number' ? block.zIndex : normalizeZIndex(attrs?.originalAttributes); + return resolveFloatingZIndex(block.anchor?.behindDoc === true, raw); +} diff --git a/packages/layout-engine/layout-engine/package.json b/packages/layout-engine/layout-engine/package.json index 241ef6a60d..1f18eb8924 100644 --- a/packages/layout-engine/layout-engine/package.json +++ b/packages/layout-engine/layout-engine/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "@superdoc/common": "workspace:*", - "@superdoc/contracts": "workspace:*", - "@superdoc/pm-adapter": "workspace:*" + "@superdoc/contracts": "workspace:*" } } diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index d5bb266edd..542a2527b9 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,7 +28,7 @@ import type { FlowMode, NormalizedColumnLayout, } from '@superdoc/contracts'; -import { normalizeColumnLayout } from '@superdoc/contracts'; +import { normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts'; import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js'; import { computeNextSectionPropsAtBreak } from './section-props'; import { @@ -51,7 +51,6 @@ import { createPaginator, type PageState, type ConstraintBoundary } from './pagi import { formatPageNumber } from './pageNumbering.js'; import { shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; import { balancePageColumns } from './column-balancing.js'; -import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js'; import { cloneColumnLayout, widthsEqual } from './column-utils.js'; type PageSize = { w: number; h: number }; @@ -2789,13 +2788,6 @@ export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js'; export { resolvePageNumberTokens } from './resolvePageTokens.js'; export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js'; -// Export table utilities for reuse by painter-dom -export { rescaleColumnWidths, getCellLines, getEmbeddedRowLines } from './layout-table.js'; -export { - describeCellRenderBlocks, - computeCellSliceContentHeight, - computeFullCellContentHeight, - createCellSliceCursor, - type CellRenderBlock, - type CellSliceCursor, -} from './table-cell-slice.js'; +// Table utilities consumed by layout-bridge and cross-package sync tests +export { getCellLines, getEmbeddedRowLines } from './layout-table.js'; +export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js'; diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index a1d7f39731..0adc21465c 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -2,7 +2,7 @@ import type { DrawingBlock, DrawingMeasure, DrawingFragment } from '@superdoc/co import type { NormalizedColumns } from './layout-image.js'; import type { PageState } from './paginator.js'; import { extractBlockPmRange } from './layout-utils.js'; -import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js'; +import { getFragmentZIndex } from '@superdoc/contracts'; /** * Context for laying out a drawing block (vector shape) within the page layout. diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index 45676f50f2..bbc862aecc 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -22,7 +22,7 @@ import { shouldSuppressOwnSpacing, } from './layout-utils.js'; import { computeAnchorX } from './floating-objects.js'; -import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js'; +import { getFragmentZIndex } from '@superdoc/contracts'; const spacingDebugEnabled = false; /** diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 3a51aed0ea..7f37f86d40 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -172,8 +172,7 @@ function resolveTableFrame( * * @returns Rescaled column widths if clamping occurred, undefined otherwise. */ -// Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported. -export { rescaleColumnWidths } from '@superdoc/contracts'; +// Canonical implementation lives in @superdoc/contracts; imported for local use. import { rescaleColumnWidths } from '@superdoc/contracts'; const COLUMN_MIN_WIDTH_PX = 25; diff --git a/packages/layout-engine/pm-adapter/src/utilities.ts b/packages/layout-engine/pm-adapter/src/utilities.ts index f657e1aa56..d768b85671 100644 --- a/packages/layout-engine/pm-adapter/src/utilities.ts +++ b/packages/layout-engine/pm-adapter/src/utilities.ts @@ -1443,49 +1443,11 @@ export function normalizeTextInsets( return { top, right, bottom, left }; } -// Canonical implementations moved to @superdoc/contracts; re-imported for local use and re-exported. -export { OOXML_Z_INDEX_BASE, coerceRelativeHeight, normalizeZIndex } from '@superdoc/contracts'; -import { normalizeZIndex } from '@superdoc/contracts'; - -/** - * Resolves the CSS z-index for a floating object based on its behindDoc flag - * and an OOXML-derived raw value. - * - * - behindDoc objects always return 0. - * - Non-behindDoc objects are clamped to at least 1 so they never share the - * behindDoc sentinel value (0). - * - * @param behindDoc - Whether the object is behind body text - * @param raw - OOXML-derived z-index (from normalizeZIndex or block.zIndex) - * @param fallback - Value to use when raw is undefined (default: 1) - * @returns Resolved z-index - */ -export function resolveFloatingZIndex(behindDoc: boolean, raw: number | undefined, fallback = 1): number { - if (behindDoc) return 0; - if (raw === undefined) return Math.max(1, fallback); - return Math.max(1, raw); -} - -/** - * Returns z-index for an image or drawing block. - * - * We cannot rely on `block.zIndex` only: when the flow-block cache hits, the - * paragraph handler reuses cached blocks and never calls the image/shape - * converters, so those blocks never get `zIndex` set. This helper uses - * `block.zIndex` when present, otherwise derives from - * `block.attrs.originalAttributes.relativeHeight` via normalizeZIndex, - * otherwise behindDoc ? 0 : 1. - * - * Rendering policy: - * - behindDoc anchored objects always return 0. - * - Anchored objects with text wrapping (Square/Tight/Through/TopAndBottom, or - * missing wrap metadata) keep OOXML relativeHeight ordering but are clamped - * to at least 1 (never 0 unless behindDoc=true). - * - Front/no-wrap anchored objects (wrap None) also preserve OOXML relativeHeight order. - */ -export function getFragmentZIndex(block: ImageBlock | DrawingBlock): number { - const attrs = block.attrs as { originalAttributes?: unknown } | undefined; - const raw = typeof block.zIndex === 'number' ? block.zIndex : normalizeZIndex(attrs?.originalAttributes); - - return resolveFloatingZIndex(block.anchor?.behindDoc === true, raw); -} +// Canonical implementations moved to @superdoc/contracts; re-exported for backward compatibility. +export { + OOXML_Z_INDEX_BASE, + coerceRelativeHeight, + normalizeZIndex, + resolveFloatingZIndex, + getFragmentZIndex, +} from '@superdoc/contracts'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8acc0eda74..8f4dfe0416 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2135,9 +2135,6 @@ importers: '@superdoc/contracts': specifier: workspace:* version: link:../contracts - '@superdoc/pm-adapter': - specifier: workspace:* - version: link:../pm-adapter packages/layout-engine/layout-resolved: dependencies: