Skip to content

Commit 7b77d0d

Browse files
committed
refactor: centralizing anchored graphic placement
1 parent 4c74ec6 commit 7b77d0d

15 files changed

Lines changed: 615 additions & 313 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
resolveAnchoredGraphicY,
4+
computeParagraphContentStartY,
5+
computeParagraphLayoutStartY,
6+
} from './graphic-placement.js';
7+
8+
const base = {
9+
objectHeight: 100,
10+
contentTop: 72,
11+
contentBottom: 720,
12+
pageBottomMargin: 72,
13+
};
14+
15+
describe('resolveAnchoredGraphicY', () => {
16+
it('positions margin-relative top with offset', () => {
17+
expect(
18+
resolveAnchoredGraphicY({
19+
...base,
20+
anchor: { vRelativeFrom: 'margin', alignV: 'top', offsetV: 10 },
21+
}),
22+
).toBe(82);
23+
});
24+
25+
it('positions page-relative bottom with page margin', () => {
26+
expect(
27+
resolveAnchoredGraphicY({
28+
...base,
29+
anchor: { vRelativeFrom: 'page', alignV: 'bottom', offsetV: 5 },
30+
}),
31+
).toBe(720 + 72 - 100 + 5);
32+
});
33+
34+
it('positions paragraph-relative center on first line', () => {
35+
expect(
36+
resolveAnchoredGraphicY({
37+
...base,
38+
anchor: { vRelativeFrom: 'paragraph', alignV: 'center', offsetV: 0 },
39+
anchorParagraphY: 200,
40+
firstLineHeight: 24,
41+
}),
42+
).toBe(200 + (24 - 100) / 2);
43+
});
44+
45+
it('uses pre-registered fallback when vRelativeFrom is paragraph without paragraph context', () => {
46+
expect(
47+
resolveAnchoredGraphicY({
48+
...base,
49+
anchor: { vRelativeFrom: 'paragraph', offsetV: 20 },
50+
preRegisteredFallbackToContentTop: true,
51+
}),
52+
).toBe(92);
53+
});
54+
55+
it('legacy undefined vRelativeFrom uses anchor paragraph Y', () => {
56+
expect(
57+
resolveAnchoredGraphicY({
58+
...base,
59+
anchor: { offsetV: 15 },
60+
anchorParagraphY: 300,
61+
}),
62+
).toBe(315);
63+
});
64+
});
65+
66+
describe('computeParagraphLayoutStartY', () => {
67+
it('rewinds trailing then applies full spacing-before without double collapse', () => {
68+
expect(
69+
computeParagraphLayoutStartY({
70+
cursorY: 120,
71+
spacingBefore: 24,
72+
trailingSpacing: 12,
73+
rewindTrailingFromPrevious: true,
74+
}),
75+
).toBe(132);
76+
});
77+
78+
it('collapses spacing-before against trailing when previous after-spacing is kept', () => {
79+
expect(
80+
computeParagraphLayoutStartY({
81+
cursorY: 100,
82+
spacingBefore: 24,
83+
trailingSpacing: 8,
84+
rewindTrailingFromPrevious: false,
85+
}),
86+
).toBe(116);
87+
});
88+
});
89+
90+
describe('computeParagraphContentStartY', () => {
91+
it('adds spacing-before minus trailing collapse', () => {
92+
expect(computeParagraphContentStartY(100, 24, false, 8)).toBe(116);
93+
});
94+
95+
it('returns cursorY when spacing already applied', () => {
96+
expect(computeParagraphContentStartY(100, 24, true, 0)).toBe(100);
97+
});
98+
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
type AnchorVRelative = 'paragraph' | 'page' | 'margin';
2+
type AnchorHRelative = 'column' | 'page' | 'margin';
3+
type AnchorAlignH = 'left' | 'center' | 'right';
4+
type AnchorAlignV = 'top' | 'center' | 'bottom';
5+
6+
export type ColumnLayoutForAnchor = {
7+
width: number;
8+
gap: number;
9+
count: number;
10+
};
11+
12+
/**
13+
* Inputs for resolving the paint Y of an anchored image, drawing, or floating table.
14+
* `offsetV` is applied inside this function; callers must pass the resolved value to
15+
* text-wrap registration without adding `offsetV` again.
16+
*/
17+
export type ResolveAnchoredGraphicYInput = {
18+
anchor?: {
19+
vRelativeFrom?: AnchorVRelative;
20+
alignV?: AnchorAlignV;
21+
offsetV?: number;
22+
};
23+
objectHeight: number;
24+
contentTop: number;
25+
contentBottom: number;
26+
/** Bottom page margin in px (used when vRelativeFrom is `page`). */
27+
pageBottomMargin?: number;
28+
/**
29+
* Anchor paragraph top Y (body cursor when laying out the anchor paragraph).
30+
* Used for `paragraph` and legacy (undefined vRelativeFrom) positioning.
31+
*/
32+
anchorParagraphY?: number;
33+
/** First line height of the anchor paragraph (paragraph-relative alignV). */
34+
firstLineHeight?: number;
35+
/**
36+
* When true and vRelativeFrom is not margin/page, fall back to contentTop + offsetV
37+
* (page/margin pre-registered anchors that lack paragraph context).
38+
*/
39+
preRegisteredFallbackToContentTop?: boolean;
40+
};
41+
42+
/**
43+
* Resolve the vertical paint position for an anchored graphic (image, drawing, or table).
44+
*/
45+
export function resolveAnchoredGraphicY(input: ResolveAnchoredGraphicYInput): number {
46+
const {
47+
anchor,
48+
objectHeight,
49+
contentTop,
50+
contentBottom,
51+
pageBottomMargin = 0,
52+
anchorParagraphY = contentTop,
53+
firstLineHeight = 0,
54+
preRegisteredFallbackToContentTop = false,
55+
} = input;
56+
57+
const offsetV = anchor?.offsetV ?? 0;
58+
const vRelativeFrom = anchor?.vRelativeFrom;
59+
const alignV = anchor?.alignV;
60+
const contentHeight = Math.max(0, contentBottom - contentTop);
61+
62+
if (vRelativeFrom === 'margin') {
63+
if (alignV === 'bottom') {
64+
return contentBottom - objectHeight + offsetV;
65+
}
66+
if (alignV === 'center') {
67+
return contentTop + (contentHeight - objectHeight) / 2 + offsetV;
68+
}
69+
return contentTop + offsetV;
70+
}
71+
72+
if (vRelativeFrom === 'page') {
73+
const pageHeight = contentBottom + pageBottomMargin;
74+
if (alignV === 'bottom') {
75+
return pageHeight - objectHeight + offsetV;
76+
}
77+
if (alignV === 'center') {
78+
return (pageHeight - objectHeight) / 2 + offsetV;
79+
}
80+
return offsetV;
81+
}
82+
83+
if (vRelativeFrom === 'paragraph') {
84+
const baseAnchorY = anchorParagraphY;
85+
if (alignV === 'bottom') {
86+
return baseAnchorY + firstLineHeight - objectHeight + offsetV;
87+
}
88+
if (alignV === 'center') {
89+
return baseAnchorY + (firstLineHeight - objectHeight) / 2 + offsetV;
90+
}
91+
return baseAnchorY + offsetV;
92+
}
93+
94+
if (preRegisteredFallbackToContentTop) {
95+
return contentTop + offsetV;
96+
}
97+
98+
return anchorParagraphY + offsetV;
99+
}
100+
101+
/**
102+
* Y coordinate where paragraph text begins (after spacing-before collapse).
103+
*/
104+
export function computeParagraphContentStartY(
105+
cursorY: number,
106+
spacingBefore: number,
107+
appliedSpacingBefore: boolean,
108+
trailingSpacing: number | undefined,
109+
): number {
110+
if (appliedSpacingBefore || spacingBefore <= 0) {
111+
return cursorY;
112+
}
113+
const prevTrailing = trailingSpacing ?? 0;
114+
return cursorY + Math.max(spacingBefore - prevTrailing, 0);
115+
}
116+
117+
/**
118+
* Paragraph text start Y including contextual-spacing rewind from the previous paragraph.
119+
*/
120+
export function computeParagraphLayoutStartY(input: {
121+
cursorY: number;
122+
spacingBefore: number;
123+
trailingSpacing?: number;
124+
suppressSpacingBefore?: boolean;
125+
rewindTrailingFromPrevious?: boolean;
126+
}): number {
127+
let y = input.cursorY;
128+
let trailingForCollapse = input.trailingSpacing;
129+
if (input.rewindTrailingFromPrevious) {
130+
const prevTrailing = input.trailingSpacing ?? 0;
131+
if (prevTrailing > 0) {
132+
y -= prevTrailing;
133+
// Match layout-paragraph.ts: after rewind, trailingSpacing is cleared before
134+
// spacing-before is applied — do not collapse against the rewound gap again.
135+
trailingForCollapse = 0;
136+
}
137+
}
138+
const effectiveSpacingBefore = input.suppressSpacingBefore ? 0 : input.spacingBefore;
139+
return computeParagraphContentStartY(y, effectiveSpacingBefore, effectiveSpacingBefore === 0, trailingForCollapse);
140+
}
141+
142+
/**
143+
* Resolve horizontal paint position for an anchored graphic.
144+
*/
145+
export function resolveAnchoredGraphicX(
146+
anchor: {
147+
hRelativeFrom?: AnchorHRelative;
148+
alignH?: AnchorAlignH;
149+
offsetH?: number;
150+
},
151+
columnIndex: number,
152+
columns: ColumnLayoutForAnchor,
153+
objectWidth: number,
154+
margins?: { left?: number; right?: number },
155+
pageWidth?: number,
156+
): number {
157+
const alignH = anchor.alignH ?? 'left';
158+
const offsetH = anchor.offsetH ?? 0;
159+
160+
const marginLeft = Math.max(0, margins?.left ?? 0);
161+
const marginRight = Math.max(0, margins?.right ?? 0);
162+
const contentWidth = pageWidth != null ? Math.max(1, pageWidth - (marginLeft + marginRight)) : columns.width;
163+
164+
const contentLeft = marginLeft;
165+
const columnLeft = contentLeft + columnIndex * (columns.width + columns.gap);
166+
167+
const relativeFrom = anchor.hRelativeFrom ?? 'column';
168+
169+
let baseX: number;
170+
let availableWidth: number;
171+
if (relativeFrom === 'page') {
172+
baseX = 0;
173+
availableWidth = pageWidth != null ? pageWidth : contentWidth + marginLeft + marginRight;
174+
} else if (relativeFrom === 'margin') {
175+
baseX = contentLeft;
176+
availableWidth = contentWidth;
177+
} else {
178+
baseX = columnLeft;
179+
availableWidth = columns.width;
180+
}
181+
182+
if (alignH === 'left') {
183+
return baseX + offsetH;
184+
}
185+
if (alignH === 'right') {
186+
return baseX + availableWidth - objectWidth - offsetH;
187+
}
188+
if (alignH === 'center') {
189+
return baseX + (availableWidth - objectWidth) / 2 + offsetH;
190+
}
191+
return baseX;
192+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ export {
7979

8080
export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js';
8181

82+
export {
83+
resolveAnchoredGraphicY,
84+
resolveAnchoredGraphicX,
85+
computeParagraphContentStartY,
86+
computeParagraphLayoutStartY,
87+
type ColumnLayoutForAnchor,
88+
type ResolveAnchoredGraphicYInput,
89+
} from './graphic-placement.js';
90+
8291
// Editor-neutral layout identity primitives (prep-001).
8392
// Additive only — `pmStart`/`pmEnd` and PM-shaped fields remain available
8493
// alongside these on every fragment/run.

packages/layout-engine/layout-engine/src/floating-objects.test.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ describe('FloatingObjectManager', () => {
132132
expect(zones[0].bounds.x).toBe((600 - 200) / 2 + 10); // (columnWidth - imageWidth) / 2 + offsetH
133133
});
134134

135-
it('applies vertical offset to image Y position', () => {
135+
it('uses fully resolved anchor Y for exclusion bounds (offset applied upstream)', () => {
136136
const manager = createFloatingObjectManager(mockColumns, { left: 0, right: 0 }, 600);
137137
const imageBlock = createMockImageBlock({
138138
anchor: {
@@ -141,10 +141,11 @@ describe('FloatingObjectManager', () => {
141141
},
142142
});
143143

144-
manager.registerDrawing(imageBlock, createMockMeasure(), 100, 0, 1);
144+
// resolvedAnchorY already includes offsetV from resolveAnchoredGraphicY
145+
manager.registerDrawing(imageBlock, createMockMeasure(), 150, 0, 1);
145146

146147
const zones = manager.getAllFloatsForPage(1);
147-
expect(zones[0].bounds.y).toBe(150); // anchorY(100) + offsetV(50)
148+
expect(zones[0].bounds.y).toBe(150);
148149
});
149150
});
150151

@@ -236,8 +237,8 @@ describe('FloatingObjectManager', () => {
236237
manager.registerDrawing(imageBlock, createMockMeasure(), 100, 0, 1);
237238

238239
const result = manager.computeAvailableWidth(120, 20, 600, 0, 1);
239-
expect(result.width).toBe(600 - 200 - 5 - 10); // baseWidth - imageWidth - distLeft - distRight
240-
expect(result.offsetX).toBe(200 + 5 + 10); // Image width + distances
240+
expect(result.width).toBe(600 - 200 - 10); // baseWidth - imageWidth - distRight
241+
expect(result.offsetX).toBe(200 + 10); // Image width + right-side text gap
241242
});
242243

243244
it('reduces width for right-side image (wrapText=left)', () => {
@@ -258,7 +259,7 @@ describe('FloatingObjectManager', () => {
258259
manager.registerDrawing(imageBlock, createMockMeasure(), 100, 0, 1);
259260

260261
const result = manager.computeAvailableWidth(120, 20, 600, 0, 1);
261-
expect(result.width).toBe(600 - 200 - 5 - 10);
262+
expect(result.width).toBe(600 - 200 - 5); // baseWidth - imageWidth - distLeft
262263
expect(result.offsetX).toBe(0); // No offset for right-side image
263264
});
264265

@@ -554,9 +555,9 @@ describe('FloatingObjectManager', () => {
554555
const result = manager.computeAvailableWidth(120, 20, 600, 0, 1);
555556

556557
// Float center is at 50, which is < 300 (baseWidth/2), so it's a left float
557-
// Boundary: 0 + 100 + 5 + 10 = 115 (full exclusion width)
558-
expect(result.width).toBe(600 - 115);
559-
expect(result.offsetX).toBe(115);
558+
// Boundary: 0 + 100 + 10 = 110 (image width + distRight)
559+
expect(result.width).toBe(600 - 110);
560+
expect(result.offsetX).toBe(110);
560561
});
561562

562563
it('handles bothSides wrapText for float on right', () => {
@@ -583,8 +584,8 @@ describe('FloatingObjectManager', () => {
583584
const result = manager.computeAvailableWidth(120, 20, 600, 0, 1);
584585

585586
// Float is at X = 600 - 100 = 500, center at 550 > 300, so it's a right float
586-
// Boundary: 500 - 10 - 5 = 485 (subtract both distances for symmetry)
587-
expect(result.width).toBe(485);
587+
// Boundary: 500 - 10 = 490 (image left edge - distLeft)
588+
expect(result.width).toBe(490);
588589
expect(result.offsetX).toBe(0);
589590
});
590591

@@ -612,9 +613,9 @@ describe('FloatingObjectManager', () => {
612613
const result = manager.computeAvailableWidth(120, 20, 600, 0, 1);
613614

614615
// Float on left side (center < baseWidth/2)
615-
// Exclusion width: 0 + 100 + 5 + 10 = 115
616-
expect(result.width).toBe(600 - 115);
617-
expect(result.offsetX).toBe(115);
616+
// Exclusion width: 0 + 100 + 10 = 110
617+
expect(result.width).toBe(600 - 110);
618+
expect(result.offsetX).toBe(110);
618619
});
619620

620621
it('returns full width when all exclusions are non-wrapping', () => {

0 commit comments

Comments
 (0)