Skip to content

Commit 9a91465

Browse files
authored
fix(layout-engine): remove remaining pm-adapter boundary leaks (#2618)
* fix(layout-engine): remove remaining pm-adapter boundary leaks * chore: update locks
1 parent ddcabf6 commit 9a91465

10 files changed

Lines changed: 239 additions & 68 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ export { rescaleColumnWidths } from './table-column-rescale.js';
1616
export { getCellSpacingPx } from './cell-spacing.js';
1717

1818
// OOXML z-index normalization (moved from pm-adapter for cross-stage use)
19-
export { normalizeZIndex, coerceRelativeHeight, isPlainObject, OOXML_Z_INDEX_BASE } from './ooxml-z-index.js';
19+
export {
20+
normalizeZIndex,
21+
coerceRelativeHeight,
22+
isPlainObject,
23+
OOXML_Z_INDEX_BASE,
24+
resolveFloatingZIndex,
25+
getFragmentZIndex,
26+
} from './ooxml-z-index.js';
2027

2128
// Export justify utilities
2229
export {
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
coerceRelativeHeight,
4+
normalizeZIndex,
5+
OOXML_Z_INDEX_BASE,
6+
resolveFloatingZIndex,
7+
getFragmentZIndex,
8+
} from './ooxml-z-index.js';
9+
10+
describe('ooxml-z-index', () => {
11+
describe('coerceRelativeHeight', () => {
12+
it('returns number when given a finite number', () => {
13+
expect(coerceRelativeHeight(251658240)).toBe(251658240);
14+
expect(coerceRelativeHeight(0)).toBe(0);
15+
});
16+
17+
it('returns number when given a numeric string', () => {
18+
expect(coerceRelativeHeight('251658240')).toBe(251658240);
19+
expect(coerceRelativeHeight('251659318')).toBe(251659318);
20+
});
21+
22+
it('returns undefined for non-finite number', () => {
23+
expect(coerceRelativeHeight(NaN)).toBeUndefined();
24+
expect(coerceRelativeHeight(Infinity)).toBeUndefined();
25+
});
26+
27+
it('returns undefined for empty or invalid string', () => {
28+
expect(coerceRelativeHeight('')).toBeUndefined();
29+
expect(coerceRelativeHeight(' ')).toBeUndefined();
30+
expect(coerceRelativeHeight('abc')).toBeUndefined();
31+
});
32+
33+
it('returns undefined for null, undefined, or non-number/string', () => {
34+
expect(coerceRelativeHeight(null)).toBeUndefined();
35+
expect(coerceRelativeHeight(undefined)).toBeUndefined();
36+
expect(coerceRelativeHeight({})).toBeUndefined();
37+
});
38+
});
39+
40+
describe('normalizeZIndex', () => {
41+
it('returns 0 for OOXML base relativeHeight', () => {
42+
expect(normalizeZIndex({ relativeHeight: OOXML_Z_INDEX_BASE })).toBe(0);
43+
expect(normalizeZIndex({ relativeHeight: '251658240' })).toBe(0);
44+
});
45+
46+
it('returns positive z-index for relativeHeight above base', () => {
47+
expect(normalizeZIndex({ relativeHeight: OOXML_Z_INDEX_BASE + 2 })).toBe(2);
48+
expect(normalizeZIndex({ relativeHeight: OOXML_Z_INDEX_BASE + 51 })).toBe(51);
49+
expect(normalizeZIndex({ relativeHeight: '251658291' })).toBe(51);
50+
});
51+
52+
it('returns undefined when relativeHeight is missing or invalid', () => {
53+
expect(normalizeZIndex({})).toBeUndefined();
54+
expect(normalizeZIndex(null)).toBeUndefined();
55+
expect(normalizeZIndex(undefined)).toBeUndefined();
56+
expect(normalizeZIndex({ relativeHeight: '' })).toBeUndefined();
57+
});
58+
});
59+
60+
describe('resolveFloatingZIndex', () => {
61+
it('returns 0 when behindDoc is true', () => {
62+
expect(resolveFloatingZIndex(true, 42)).toBe(0);
63+
expect(resolveFloatingZIndex(true, undefined)).toBe(0);
64+
expect(resolveFloatingZIndex(true, 0)).toBe(0);
65+
});
66+
67+
it('returns raw value when non-behindDoc and raw >= 1', () => {
68+
expect(resolveFloatingZIndex(false, 5)).toBe(5);
69+
expect(resolveFloatingZIndex(false, 100)).toBe(100);
70+
});
71+
72+
it('clamps raw 0 to 1 for non-behindDoc', () => {
73+
expect(resolveFloatingZIndex(false, 0)).toBe(1);
74+
});
75+
76+
it('returns fallback when raw is undefined', () => {
77+
expect(resolveFloatingZIndex(false, undefined)).toBe(1);
78+
expect(resolveFloatingZIndex(false, undefined, 5)).toBe(5);
79+
});
80+
81+
it('clamps fallback to at least 1', () => {
82+
expect(resolveFloatingZIndex(false, undefined, 0)).toBe(1);
83+
expect(resolveFloatingZIndex(false, undefined, -1)).toBe(1);
84+
});
85+
});
86+
87+
describe('getFragmentZIndex', () => {
88+
it('uses block.zIndex when set', () => {
89+
const block = {
90+
kind: 'image' as const,
91+
id: 'img-1',
92+
src: 'x.png',
93+
zIndex: 42,
94+
attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE } },
95+
};
96+
expect(getFragmentZIndex(block)).toBe(42);
97+
});
98+
99+
it('derives z-index from attrs.originalAttributes.relativeHeight (number)', () => {
100+
const block = {
101+
kind: 'image' as const,
102+
id: 'img-1',
103+
src: 'x.png',
104+
attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 10 } },
105+
};
106+
expect(getFragmentZIndex(block)).toBe(10);
107+
});
108+
109+
it('derives z-index from attrs.originalAttributes.relativeHeight (string)', () => {
110+
const block = {
111+
kind: 'image' as const,
112+
id: 'img-1',
113+
src: 'x.png',
114+
attrs: { originalAttributes: { relativeHeight: '251658250' } },
115+
};
116+
expect(getFragmentZIndex(block)).toBe(10);
117+
});
118+
119+
it('preserves high z-index for wrapped anchored objects', () => {
120+
const block = {
121+
kind: 'image' as const,
122+
id: 'img-1',
123+
src: 'x.png',
124+
anchor: { isAnchored: true, behindDoc: false },
125+
wrap: { type: 'Through' as const },
126+
zIndex: 7168,
127+
};
128+
expect(getFragmentZIndex(block)).toBe(7168);
129+
});
130+
131+
it('preserves relativeHeight z-index for wrap None anchored objects', () => {
132+
const block = {
133+
kind: 'image' as const,
134+
id: 'img-1',
135+
src: 'x.png',
136+
anchor: { isAnchored: true, behindDoc: false },
137+
wrap: { type: 'None' as const },
138+
attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 10 } },
139+
};
140+
expect(getFragmentZIndex(block)).toBe(10);
141+
});
142+
143+
it('returns 0 when anchor.behindDoc is true and no zIndex/originalAttributes', () => {
144+
const block = {
145+
kind: 'image' as const,
146+
id: 'img-1',
147+
src: 'x.png',
148+
anchor: { isAnchored: true, behindDoc: true },
149+
};
150+
expect(getFragmentZIndex(block)).toBe(0);
151+
});
152+
153+
it('returns 1 when not behindDoc and no zIndex/originalAttributes', () => {
154+
const block = {
155+
kind: 'image' as const,
156+
id: 'img-1',
157+
src: 'x.png',
158+
};
159+
expect(getFragmentZIndex(block)).toBe(1);
160+
});
161+
162+
it('does not treat base relativeHeight as behindDoc when behindDoc is false', () => {
163+
const block = {
164+
kind: 'image' as const,
165+
id: 'img-1',
166+
src: 'x.png',
167+
anchor: { isAnchored: true, behindDoc: false },
168+
attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE } },
169+
};
170+
expect(getFragmentZIndex(block)).toBeGreaterThan(0);
171+
});
172+
173+
it('forces behindDoc fragments to zIndex 0 even with relativeHeight', () => {
174+
const block = {
175+
kind: 'image' as const,
176+
id: 'img-1',
177+
src: 'x.png',
178+
anchor: { isAnchored: true, behindDoc: true },
179+
attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 5 } },
180+
};
181+
expect(getFragmentZIndex(block)).toBe(0);
182+
});
183+
184+
it('works for drawing blocks', () => {
185+
const block = {
186+
kind: 'drawing' as const,
187+
id: 'd-1',
188+
drawingKind: 'vectorShape' as const,
189+
attrs: { originalAttributes: { relativeHeight: OOXML_Z_INDEX_BASE + 5 } },
190+
};
191+
expect(getFragmentZIndex(block)).toBe(5);
192+
});
193+
});
194+
});

packages/layout-engine/contracts/src/ooxml-z-index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* These helpers convert to small positive CSS z-index values.
66
*/
77

8+
import type { ImageBlock, DrawingBlock } from './index.js';
9+
810
/** Checks whether `value` is a non-null, non-array object. */
911
export const isPlainObject = (value: unknown): value is Record<string, unknown> =>
1012
value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -57,3 +59,22 @@ export function normalizeZIndex(originalAttributes: unknown): number | undefined
5759
if (relativeHeight === undefined) return undefined;
5860
return Math.max(0, relativeHeight - OOXML_Z_INDEX_BASE);
5961
}
62+
63+
/**
64+
* Resolves floating z-index based on behindDoc flag and raw OOXML value.
65+
*/
66+
export function resolveFloatingZIndex(behindDoc: boolean, raw: number | undefined, fallback = 1): number {
67+
if (behindDoc) return 0;
68+
if (raw === undefined) return Math.max(1, fallback);
69+
return Math.max(1, raw);
70+
}
71+
72+
/**
73+
* Returns z-index for an image or drawing block.
74+
* Uses block.zIndex when present, otherwise derives from originalAttributes.
75+
*/
76+
export function getFragmentZIndex(block: ImageBlock | DrawingBlock): number {
77+
const attrs = block.attrs as { originalAttributes?: unknown } | undefined;
78+
const raw = typeof block.zIndex === 'number' ? block.zIndex : normalizeZIndex(attrs?.originalAttributes);
79+
return resolveFloatingZIndex(block.anchor?.behindDoc === true, raw);
80+
}

packages/layout-engine/layout-engine/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
},
1515
"dependencies": {
1616
"@superdoc/common": "workspace:*",
17-
"@superdoc/contracts": "workspace:*",
18-
"@superdoc/pm-adapter": "workspace:*"
17+
"@superdoc/contracts": "workspace:*"
1918
}
2019
}

packages/layout-engine/layout-engine/src/index.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type {
2828
FlowMode,
2929
NormalizedColumnLayout,
3030
} from '@superdoc/contracts';
31-
import { normalizeColumnLayout } from '@superdoc/contracts';
31+
import { normalizeColumnLayout, getFragmentZIndex } from '@superdoc/contracts';
3232
import { createFloatingObjectManager, computeAnchorX } from './floating-objects.js';
3333
import { computeNextSectionPropsAtBreak } from './section-props';
3434
import {
@@ -51,7 +51,6 @@ import { createPaginator, type PageState, type ConstraintBoundary } from './pagi
5151
import { formatPageNumber } from './pageNumbering.js';
5252
import { shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js';
5353
import { balancePageColumns } from './column-balancing.js';
54-
import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js';
5554
import { cloneColumnLayout, widthsEqual } from './column-utils.js';
5655

5756
type PageSize = { w: number; h: number };
@@ -2789,13 +2788,6 @@ export type { PageNumberFormat, DisplayPageInfo } from './pageNumbering.js';
27892788
export { resolvePageNumberTokens } from './resolvePageTokens.js';
27902789
export type { NumberingContext, ResolvePageTokensResult } from './resolvePageTokens.js';
27912790

2792-
// Export table utilities for reuse by painter-dom
2793-
export { rescaleColumnWidths, getCellLines, getEmbeddedRowLines } from './layout-table.js';
2794-
export {
2795-
describeCellRenderBlocks,
2796-
computeCellSliceContentHeight,
2797-
computeFullCellContentHeight,
2798-
createCellSliceCursor,
2799-
type CellRenderBlock,
2800-
type CellSliceCursor,
2801-
} from './table-cell-slice.js';
2791+
// Table utilities consumed by layout-bridge and cross-package sync tests
2792+
export { getCellLines, getEmbeddedRowLines } from './layout-table.js';
2793+
export { describeCellRenderBlocks, computeCellSliceContentHeight } from './table-cell-slice.js';

packages/layout-engine/layout-engine/src/layout-drawing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { DrawingBlock, DrawingMeasure, DrawingFragment } from '@superdoc/co
22
import type { NormalizedColumns } from './layout-image.js';
33
import type { PageState } from './paginator.js';
44
import { extractBlockPmRange } from './layout-utils.js';
5-
import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js';
5+
import { getFragmentZIndex } from '@superdoc/contracts';
66

77
/**
88
* Context for laying out a drawing block (vector shape) within the page layout.

packages/layout-engine/layout-engine/src/layout-paragraph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
shouldSuppressOwnSpacing,
2323
} from './layout-utils.js';
2424
import { computeAnchorX } from './floating-objects.js';
25-
import { getFragmentZIndex } from '@superdoc/pm-adapter/utilities.js';
25+
import { getFragmentZIndex } from '@superdoc/contracts';
2626

2727
const spacingDebugEnabled = false;
2828
/**

packages/layout-engine/layout-engine/src/layout-table.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,7 @@ function resolveTableFrame(
172172
*
173173
* @returns Rescaled column widths if clamping occurred, undefined otherwise.
174174
*/
175-
// Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported.
176-
export { rescaleColumnWidths } from '@superdoc/contracts';
175+
// Canonical implementation lives in @superdoc/contracts; imported for local use.
177176
import { rescaleColumnWidths } from '@superdoc/contracts';
178177

179178
const COLUMN_MIN_WIDTH_PX = 25;

packages/layout-engine/pm-adapter/src/utilities.ts

Lines changed: 8 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,49 +1443,11 @@ export function normalizeTextInsets(
14431443
return { top, right, bottom, left };
14441444
}
14451445

1446-
// Canonical implementations moved to @superdoc/contracts; re-imported for local use and re-exported.
1447-
export { OOXML_Z_INDEX_BASE, coerceRelativeHeight, normalizeZIndex } from '@superdoc/contracts';
1448-
import { normalizeZIndex } from '@superdoc/contracts';
1449-
1450-
/**
1451-
* Resolves the CSS z-index for a floating object based on its behindDoc flag
1452-
* and an OOXML-derived raw value.
1453-
*
1454-
* - behindDoc objects always return 0.
1455-
* - Non-behindDoc objects are clamped to at least 1 so they never share the
1456-
* behindDoc sentinel value (0).
1457-
*
1458-
* @param behindDoc - Whether the object is behind body text
1459-
* @param raw - OOXML-derived z-index (from normalizeZIndex or block.zIndex)
1460-
* @param fallback - Value to use when raw is undefined (default: 1)
1461-
* @returns Resolved z-index
1462-
*/
1463-
export function resolveFloatingZIndex(behindDoc: boolean, raw: number | undefined, fallback = 1): number {
1464-
if (behindDoc) return 0;
1465-
if (raw === undefined) return Math.max(1, fallback);
1466-
return Math.max(1, raw);
1467-
}
1468-
1469-
/**
1470-
* Returns z-index for an image or drawing block.
1471-
*
1472-
* We cannot rely on `block.zIndex` only: when the flow-block cache hits, the
1473-
* paragraph handler reuses cached blocks and never calls the image/shape
1474-
* converters, so those blocks never get `zIndex` set. This helper uses
1475-
* `block.zIndex` when present, otherwise derives from
1476-
* `block.attrs.originalAttributes.relativeHeight` via normalizeZIndex,
1477-
* otherwise behindDoc ? 0 : 1.
1478-
*
1479-
* Rendering policy:
1480-
* - behindDoc anchored objects always return 0.
1481-
* - Anchored objects with text wrapping (Square/Tight/Through/TopAndBottom, or
1482-
* missing wrap metadata) keep OOXML relativeHeight ordering but are clamped
1483-
* to at least 1 (never 0 unless behindDoc=true).
1484-
* - Front/no-wrap anchored objects (wrap None) also preserve OOXML relativeHeight order.
1485-
*/
1486-
export function getFragmentZIndex(block: ImageBlock | DrawingBlock): number {
1487-
const attrs = block.attrs as { originalAttributes?: unknown } | undefined;
1488-
const raw = typeof block.zIndex === 'number' ? block.zIndex : normalizeZIndex(attrs?.originalAttributes);
1489-
1490-
return resolveFloatingZIndex(block.anchor?.behindDoc === true, raw);
1491-
}
1446+
// Canonical implementations moved to @superdoc/contracts; re-exported for backward compatibility.
1447+
export {
1448+
OOXML_Z_INDEX_BASE,
1449+
coerceRelativeHeight,
1450+
normalizeZIndex,
1451+
resolveFloatingZIndex,
1452+
getFragmentZIndex,
1453+
} from '@superdoc/contracts';

pnpm-lock.yaml

Lines changed: 0 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)