Skip to content

Commit 40e04c2

Browse files
authored
fix(layout-engine): require bilateral opt-in for contextual spacing (#2475)
* fix(layout-engine): require bilateral opt-in for contextual spacing * fix(layout-engine): apply contextual spacing per paragraph
1 parent c79b1d1 commit 40e04c2

10 files changed

Lines changed: 450 additions & 248 deletions

File tree

devtools/visual-testing/pnpm-lock.yaml

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

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

Lines changed: 175 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4134,26 +4134,25 @@ describe('requirePageBoundary edge cases', () => {
41344134
expect(pageContainsBlock(layout.pages[1], 'body')).toBe(true);
41354135
});
41364136

4137-
it('suppresses inter-paragraph spacing when current paragraph has contextualSpacing', () => {
4138-
// Test that current paragraph's spacingAfter is suppressed when it has contextualSpacing
4137+
it('suppresses inter-paragraph spacing when both paragraphs have contextualSpacing', () => {
41394138
const current: FlowBlock = {
41404139
kind: 'paragraph',
41414140
id: 'current',
41424141
runs: [{ text: 'Current', fontFamily: 'Arial', fontSize: 12 }],
41434142
attrs: {
41444143
keepNext: true,
41454144
styleId: 'TestStyle',
4146-
contextualSpacing: true, // Current has it
4147-
spacing: { after: 50 }, // Large spacing after
4145+
contextualSpacing: true,
4146+
spacing: { after: 50 },
41484147
},
41494148
};
41504149
const next: FlowBlock = {
41514150
kind: 'paragraph',
41524151
id: 'next',
41534152
runs: [{ text: 'Next', fontFamily: 'Arial', fontSize: 12 }],
41544153
attrs: {
4155-
styleId: 'TestStyle', // Same style
4156-
// Note: next does NOT have contextualSpacing
4154+
styleId: 'TestStyle',
4155+
contextualSpacing: true,
41574156
spacing: { before: 10 },
41584157
},
41594158
};
@@ -4169,22 +4168,65 @@ describe('requirePageBoundary edge cases', () => {
41694168
totalHeight: 20,
41704169
};
41714170

4172-
// If contextual spacing works: gap = max(0, 10) = 10px (current's after suppressed)
4173-
// Total = 30 + 10 + 20 = 60px
4174-
// If broken: gap = max(50, 10) = 50px
4175-
// Total = 30 + 50 + 20 = 100px
4171+
// Both opt in → gap = max(0, 0) = 0px. Total = 30 + 0 + 20 = 50px
41764172
const options: LayoutOptions = {
41774173
pageSize: { w: 400, h: 130 },
41784174
margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 70px content
41794175
};
41804176

41814177
const layout = layoutDocument([current, next], [currentMeasure, nextMeasure], options);
41824178

4183-
// Should fit on one page (60px < 70px)
41844179
expect(layout.pages).toHaveLength(1);
41854180
expect(pageContainsBlock(layout.pages[0], 'current')).toBe(true);
41864181
expect(pageContainsBlock(layout.pages[0], 'next')).toBe(true);
41874182
});
4183+
4184+
it('suppresses current after-spacing even when next does not have contextualSpacing (per-paragraph)', () => {
4185+
const current: FlowBlock = {
4186+
kind: 'paragraph',
4187+
id: 'current',
4188+
runs: [{ text: 'Current', fontFamily: 'Arial', fontSize: 12 }],
4189+
attrs: {
4190+
keepNext: true,
4191+
styleId: 'TestStyle',
4192+
contextualSpacing: true,
4193+
spacing: { after: 50 },
4194+
},
4195+
};
4196+
const next: FlowBlock = {
4197+
kind: 'paragraph',
4198+
id: 'next',
4199+
runs: [{ text: 'Next', fontFamily: 'Arial', fontSize: 12 }],
4200+
attrs: {
4201+
styleId: 'TestStyle',
4202+
// next does NOT have contextualSpacing — per-paragraph rule: current still
4203+
// suppresses its own after-spacing independently
4204+
spacing: { before: 10 },
4205+
},
4206+
};
4207+
4208+
const currentMeasure: ParagraphMeasure = {
4209+
kind: 'paragraph',
4210+
lines: [makeLine(30)],
4211+
totalHeight: 30,
4212+
};
4213+
const nextMeasure: ParagraphMeasure = {
4214+
kind: 'paragraph',
4215+
lines: [makeLine(20)],
4216+
totalHeight: 20,
4217+
};
4218+
4219+
// Current suppresses its own after → 0. Next does not suppress before → 10.
4220+
// gap = max(0, 10) = 10px. Total = 30 + 10 + 20 = 60px < 70px → one page
4221+
const options: LayoutOptions = {
4222+
pageSize: { w: 400, h: 130 },
4223+
margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 70px content
4224+
};
4225+
4226+
const layout = layoutDocument([current, next], [currentMeasure, nextMeasure], options);
4227+
4228+
expect(layout.pages).toHaveLength(1);
4229+
});
41884230
});
41894231

41904232
/**
@@ -4578,14 +4620,14 @@ describe('requirePageBoundary edge cases', () => {
45784620
expect(pageContainsBlock(layout.pages[0], 'body')).toBe(true);
45794621
});
45804622

4581-
it('reclaims trailing spacing when chain starter has contextualSpacing', () => {
4582-
// Previous paragraph has spacingAfter, chain starter has contextualSpacing + same style.
4623+
it('reclaims trailing spacing when both filler and chain starter have contextualSpacing', () => {
4624+
// Both filler and chain starter have contextualSpacing + same style.
45834625
// The trailing spacing should be reclaimed, making room for the chain.
45844626
const filler: FlowBlock = {
45854627
kind: 'paragraph',
45864628
id: 'filler',
45874629
runs: [{ text: 'Filler content', fontFamily: 'Arial', fontSize: 12 }],
4588-
attrs: { styleId: 'Normal', spacingAfter: 10 },
4630+
attrs: { styleId: 'Normal', contextualSpacing: true, spacing: { after: 10 } },
45894631
};
45904632
const chainStarter: FlowBlock = {
45914633
kind: 'paragraph',
@@ -4600,23 +4642,23 @@ describe('requirePageBoundary edge cases', () => {
46004642
attrs: {},
46014643
};
46024644

4603-
// Filler is 40px, chain starter and anchor are each 25px
4645+
// Filler is 40px, chain starter and anchor are each 26px
46044646
const fillerMeasure: ParagraphMeasure = {
46054647
kind: 'paragraph',
46064648
lines: [makeLine(40)],
46074649
totalHeight: 40,
46084650
};
46094651
const chainMeasure: ParagraphMeasure = {
46104652
kind: 'paragraph',
4611-
lines: [makeLine(25)],
4612-
totalHeight: 25,
4653+
lines: [makeLine(26)],
4654+
totalHeight: 26,
46134655
};
46144656

46154657
// Page has 100px content area
4616-
// After filler (40px) + spacingAfter (10px), cursor is at 50px from top
4658+
// After filler (40px) + spacingAfter (10px), cursor is at 80px (top=30 + 40 + 10)
46174659
// Available without reclaim: 100 - 50 = 50px
4618-
// Chain needs: 25 + 25 = 50px (exactly fits with reclaim, doesn't fit without)
4619-
// With contextualSpacing, the 10px spacingAfter is reclaimed → 60px available
4660+
// Chain needs: 26 + 26 = 52px > 50px (does NOT fit without reclaim)
4661+
// With reclaim the 10px spacingAfter is recovered → 60px available, 52px fits.
46204662
const options: LayoutOptions = {
46214663
pageSize: { w: 400, h: 160 },
46224664
margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 100px content
@@ -4635,6 +4677,58 @@ describe('requirePageBoundary edge cases', () => {
46354677
expect(pageContainsBlock(layout.pages[0], 'anchor')).toBe(true);
46364678
});
46374679

4680+
it('does not reclaim trailing spacing when only chain starter has contextualSpacing', () => {
4681+
// Filler does NOT have contextualSpacing — per-paragraph rule: filler does not suppress its own after.
4682+
// Same dimensions as the positive case: chain = 52px, available without reclaim = 50px.
4683+
// Without reclaim 52 > 50, so the chain moves to page 2.
4684+
const filler: FlowBlock = {
4685+
kind: 'paragraph',
4686+
id: 'filler',
4687+
runs: [{ text: 'Filler content', fontFamily: 'Arial', fontSize: 12 }],
4688+
attrs: { styleId: 'Normal', spacing: { after: 10 } },
4689+
};
4690+
const chainStarter: FlowBlock = {
4691+
kind: 'paragraph',
4692+
id: 'chainStarter',
4693+
runs: [{ text: 'Chain starter', fontFamily: 'Arial', fontSize: 12 }],
4694+
attrs: { keepNext: true, contextualSpacing: true, styleId: 'Normal' },
4695+
};
4696+
const anchor: FlowBlock = {
4697+
kind: 'paragraph',
4698+
id: 'anchor',
4699+
runs: [{ text: 'Anchor', fontFamily: 'Arial', fontSize: 12 }],
4700+
attrs: {},
4701+
};
4702+
4703+
const fillerMeasure: ParagraphMeasure = {
4704+
kind: 'paragraph',
4705+
lines: [makeLine(40)],
4706+
totalHeight: 40,
4707+
};
4708+
const chainMeasure: ParagraphMeasure = {
4709+
kind: 'paragraph',
4710+
lines: [makeLine(26)],
4711+
totalHeight: 26,
4712+
};
4713+
4714+
const options: LayoutOptions = {
4715+
pageSize: { w: 400, h: 160 },
4716+
margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 100px content
4717+
};
4718+
4719+
const layout = layoutDocument(
4720+
[filler, chainStarter, anchor],
4721+
[fillerMeasure, chainMeasure, chainMeasure],
4722+
options,
4723+
);
4724+
4725+
// No reclaim → 50px available, 52px chain → page 2
4726+
expect(layout.pages).toHaveLength(2);
4727+
expect(pageContainsBlock(layout.pages[0], 'filler')).toBe(true);
4728+
expect(pageContainsBlock(layout.pages[1], 'chainStarter')).toBe(true);
4729+
expect(pageContainsBlock(layout.pages[1], 'anchor')).toBe(true);
4730+
});
4731+
46384732
it('does not reclaim trailing spacing when styles differ', () => {
46394733
// Previous paragraph has spacingAfter, chain starter has contextualSpacing but DIFFERENT style.
46404734
// The trailing spacing should NOT be reclaimed.
@@ -4690,5 +4784,66 @@ describe('requirePageBoundary edge cases', () => {
46904784
expect(pageContainsBlock(layout.pages[1], 'chainStarter')).toBe(true);
46914785
expect(pageContainsBlock(layout.pages[1], 'anchor')).toBe(true);
46924786
});
4787+
4788+
it('does not suppress chain-internal spacing for mixed contextualSpacing', () => {
4789+
// Three same-style paragraphs in a keepNext chain: true / false / true.
4790+
// The middle one opts out, so spacing around it should NOT be suppressed.
4791+
const filler: FlowBlock = {
4792+
kind: 'paragraph',
4793+
id: 'filler',
4794+
runs: [{ text: 'Filler', fontFamily: 'Arial', fontSize: 12 }],
4795+
attrs: { styleId: 'Other' },
4796+
};
4797+
const para1: FlowBlock = {
4798+
kind: 'paragraph',
4799+
id: 'para1',
4800+
runs: [{ text: 'Para 1', fontFamily: 'Arial', fontSize: 12 }],
4801+
attrs: { keepNext: true, styleId: 'Normal', contextualSpacing: true, spacing: { after: 20 } },
4802+
};
4803+
const para2: FlowBlock = {
4804+
kind: 'paragraph',
4805+
id: 'para2',
4806+
runs: [{ text: 'Para 2', fontFamily: 'Arial', fontSize: 12 }],
4807+
attrs: { keepNext: true, styleId: 'Normal', contextualSpacing: false, spacing: { before: 20, after: 20 } },
4808+
};
4809+
const para3: FlowBlock = {
4810+
kind: 'paragraph',
4811+
id: 'para3',
4812+
runs: [{ text: 'Para 3', fontFamily: 'Arial', fontSize: 12 }],
4813+
attrs: { styleId: 'Normal', contextualSpacing: true, spacing: { before: 20 } },
4814+
};
4815+
4816+
const fillerMeasure: ParagraphMeasure = {
4817+
kind: 'paragraph',
4818+
lines: [makeLine(10)],
4819+
totalHeight: 10,
4820+
};
4821+
const measure: ParagraphMeasure = {
4822+
kind: 'paragraph',
4823+
lines: [makeLine(20)],
4824+
totalHeight: 20,
4825+
};
4826+
4827+
// Chain (para1+para2+para3) with per-paragraph rule:
4828+
// para1→para2: para1 suppresses after (cs=true) → 0, para2 keeps before (cs=false) → 20. gap = max(0,20) = 20
4829+
// para2→para3: para2 keeps after (cs=false) → 20, para3 suppresses before (cs=true) → 0. gap = max(20,0) = 20
4830+
// Total: 20 + 20 + 20 + 20 + 20 = 100px
4831+
//
4832+
// Filler takes 10px. Content area = 105px.
4833+
// After filler, 95px remain — 100px chain doesn't fit current page but fits blank page → page 2.
4834+
const options: LayoutOptions = {
4835+
pageSize: { w: 400, h: 165 },
4836+
margins: { top: 30, right: 30, bottom: 30, left: 30 }, // 105px content
4837+
};
4838+
4839+
const layout = layoutDocument([filler, para1, para2, para3], [fillerMeasure, measure, measure, measure], options);
4840+
4841+
// Chain must move to page 2 because it's 100px and only 95px remain after filler.
4842+
expect(layout.pages).toHaveLength(2);
4843+
expect(pageContainsBlock(layout.pages[0], 'filler')).toBe(true);
4844+
expect(pageContainsBlock(layout.pages[1], 'para1')).toBe(true);
4845+
expect(pageContainsBlock(layout.pages[1], 'para2')).toBe(true);
4846+
expect(pageContainsBlock(layout.pages[1], 'para3')).toBe(true);
4847+
});
46934848
});
46944849
});

0 commit comments

Comments
 (0)