@@ -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