@@ -937,6 +937,293 @@ describe('PolicyPdfRendererService', () => {
937937 expect ( result . length ) . toBeGreaterThan ( 0 ) ;
938938 } ) ;
939939
940+ // --- CS-704: heading keep-together (no orphaned headings) ---------------
941+
942+ // Split an (uncompressed) jsPDF buffer into per-page visible text. jsPDF
943+ // writes one content stream per page, so the concatenated (text)Tj tokens
944+ // between two `endstream` markers are exactly one page's text. This lets
945+ // us assert which page a given string lands on.
946+ const pageTextsFrom = ( buf : Buffer ) : string [ ] =>
947+ buf
948+ . toString ( 'latin1' )
949+ . split ( 'endstream' )
950+ . map ( ( segment ) => {
951+ const start = segment . lastIndexOf ( 'stream' ) ;
952+ if ( start === - 1 ) return '' ;
953+ const body = segment . slice ( start + 'stream' . length ) ;
954+ const re = / \( ( .* ?) \) \s * T j / g;
955+ let text = '' ;
956+ let m : RegExpExecArray | null ;
957+ while ( ( m = re . exec ( body ) ) !== null ) text += m [ 1 ] ;
958+ return text ;
959+ } )
960+ . filter ( ( t ) => t . length > 0 ) ;
961+
962+ const pageIndexContaining = ( pages : string [ ] , needle : string ) : number =>
963+ pages . findIndex ( ( t ) => t . includes ( needle ) ) ;
964+
965+ // Like pageTextsFrom but keeps blank pages (in order), so a blank leading
966+ // page shifts the index of later content. Drops the trailing xref/trailer
967+ // segment that follows the final content stream.
968+ const orderedPageTexts = ( buf : Buffer ) : string [ ] => {
969+ const segments = buf . toString ( 'latin1' ) . split ( 'endstream' ) ;
970+ return segments . slice ( 0 , - 1 ) . map ( ( segment ) => {
971+ const start = segment . lastIndexOf ( 'stream' ) ;
972+ if ( start === - 1 ) return '' ;
973+ const body = segment . slice ( start + 'stream' . length ) ;
974+ const re = / \( ( .* ?) \) \s * T j / g;
975+ let text = '' ;
976+ let m : RegExpExecArray | null ;
977+ while ( ( m = re . exec ( body ) ) !== null ) text += m [ 1 ] ;
978+ return text ;
979+ } ) ;
980+ } ;
981+
982+ it ( 'does not orphan a heading at the bottom of a page (CS-704)' , ( ) => {
983+ // Sweep headings across many vertical offsets by growing the amount of
984+ // filler before each one. Without keep-together logic at least one
985+ // heading lands at a page bottom with its body flowing to the next page.
986+ // With the fix, every heading must share a page with the first line of
987+ // its section.
988+ const SECTIONS = 30 ;
989+ const nodes : Array < Record < string , unknown > > = [ ] ;
990+ for ( let i = 0 ; i < SECTIONS ; i ++ ) {
991+ for ( let f = 0 ; f < i ; f ++ ) {
992+ nodes . push ( {
993+ type : 'paragraph' ,
994+ content : [
995+ {
996+ type : 'text' ,
997+ text : `filler ${ i } -${ f } advancing the cursor down the page` ,
998+ } ,
999+ ] ,
1000+ } ) ;
1001+ }
1002+ nodes . push ( {
1003+ type : 'heading' ,
1004+ attrs : { level : 2 } ,
1005+ content : [ { type : 'text' , text : `HEADINGMARKER${ i } Section ${ i } ` } ] ,
1006+ } ) ;
1007+ nodes . push ( {
1008+ type : 'paragraph' ,
1009+ content : [
1010+ {
1011+ type : 'text' ,
1012+ text : `BODYMARKER${ i } first line of the section body content` ,
1013+ } ,
1014+ ] ,
1015+ } ) ;
1016+ }
1017+
1018+ const result = service . renderPoliciesPdfBuffer ( [
1019+ { name : 'Keep Together Policy' , content : { type : 'doc' , content : nodes } } ,
1020+ ] ) ;
1021+
1022+ const pages = pageTextsFrom ( result ) ;
1023+ expect ( pages . length ) . toBeGreaterThan ( 1 ) ; // multi-page, or the test is moot
1024+
1025+ const orphaned : number [ ] = [ ] ;
1026+ for ( let i = 0 ; i < SECTIONS ; i ++ ) {
1027+ const headingPage = pageIndexContaining ( pages , `HEADINGMARKER${ i } ` ) ;
1028+ const bodyPage = pageIndexContaining ( pages , `BODYMARKER${ i } ` ) ;
1029+ expect ( headingPage ) . toBeGreaterThanOrEqual ( 0 ) ;
1030+ expect ( bodyPage ) . toBeGreaterThanOrEqual ( 0 ) ;
1031+ if ( headingPage !== bodyPage ) orphaned . push ( i ) ;
1032+ }
1033+
1034+ expect ( orphaned ) . toEqual ( [ ] ) ;
1035+ } ) ;
1036+
1037+ it ( 'does not push a trailing heading (no following content) to its own page' , ( ) => {
1038+ // The keep-together reserve must only apply when a section actually
1039+ // follows the heading — a heading that is the last node stays on the
1040+ // current page rather than being bumped to a fresh one.
1041+ const result = service . renderPoliciesPdfBuffer ( [
1042+ {
1043+ name : 'Trailing Heading Policy' ,
1044+ content : {
1045+ type : 'doc' ,
1046+ content : [
1047+ {
1048+ type : 'paragraph' ,
1049+ content : [ { type : 'text' , text : 'INTROBODY opening paragraph' } ] ,
1050+ } ,
1051+ {
1052+ type : 'heading' ,
1053+ attrs : { level : 2 } ,
1054+ content : [ { type : 'text' , text : 'TRAILINGHEADING appendix' } ] ,
1055+ } ,
1056+ ] ,
1057+ } ,
1058+ } ,
1059+ ] ) ;
1060+
1061+ const pages = pageTextsFrom ( result ) ;
1062+ const introPage = pageIndexContaining ( pages , 'INTROBODY' ) ;
1063+ const headingPage = pageIndexContaining ( pages , 'TRAILINGHEADING' ) ;
1064+ expect ( introPage ) . toBe ( 0 ) ;
1065+ expect ( headingPage ) . toBe ( 0 ) ;
1066+ } ) ;
1067+
1068+ it ( 'does not bump a heading when only empty nodes follow it' , ( ) => {
1069+ // A heading near the page bottom followed ONLY by empty paragraphs / hard
1070+ // breaks (common trailing nodes in TipTap docs) must be treated as a
1071+ // trailing heading — reserving keep-together space for a non-existent
1072+ // section would bump it to a lonely page.
1073+ const nodes : Array < Record < string , unknown > > = [ ] ;
1074+ for ( let f = 0 ; f < 23 ; f ++ ) {
1075+ nodes . push ( {
1076+ type : 'paragraph' ,
1077+ content : [
1078+ { type : 'text' , text : `filler line ${ f } to consume vertical space` } ,
1079+ ] ,
1080+ } ) ;
1081+ }
1082+ nodes . push ( {
1083+ type : 'heading' ,
1084+ attrs : { level : 2 } ,
1085+ content : [ { type : 'text' , text : 'EMPTYTRAILHEADING near the bottom' } ] ,
1086+ } ) ;
1087+ // Empty trailing content that renders nothing visible.
1088+ nodes . push ( { type : 'paragraph' } ) ;
1089+ nodes . push ( { type : 'paragraph' , content : [ { type : 'hardBreak' } ] } ) ;
1090+
1091+ const result = service . renderPoliciesPdfBuffer ( [
1092+ { name : 'Empty Trailing Policy' , content : { type : 'doc' , content : nodes } } ,
1093+ ] ) ;
1094+
1095+ const pages = pageTextsFrom ( result ) ;
1096+ // The heading shares the page with the filler above it, rather than being
1097+ // pushed onto a page of its own.
1098+ expect ( pageIndexContaining ( pages , 'filler line 22' ) ) . toBe ( 0 ) ;
1099+ expect ( pageIndexContaining ( pages , 'EMPTYTRAILHEADING' ) ) . toBe ( 0 ) ;
1100+ } ) ;
1101+
1102+ it ( 'keeps a heading with a following table that has a tall first row' , ( ) => {
1103+ // A heading immediately followed by a table with a tall first row must
1104+ // not be orphaned: the reserve has to account for the table's first-row
1105+ // height, not just a few plain text lines. Sweep offsets so at least one
1106+ // heading lands where the fixed text-only reserve would have orphaned it.
1107+ const tallCell = ( label : string ) => ( {
1108+ type : 'tableCell' ,
1109+ content : [
1110+ {
1111+ type : 'paragraph' ,
1112+ content : [
1113+ {
1114+ type : 'text' ,
1115+ text : `${ label } line one line two line three line four line five line six` ,
1116+ } ,
1117+ ] ,
1118+ } ,
1119+ ] ,
1120+ } ) ;
1121+
1122+ const SECTIONS = 24 ;
1123+ const nodes : Array < Record < string , unknown > > = [ ] ;
1124+ for ( let i = 0 ; i < SECTIONS ; i ++ ) {
1125+ for ( let f = 0 ; f < i ; f ++ ) {
1126+ nodes . push ( {
1127+ type : 'paragraph' ,
1128+ content : [
1129+ { type : 'text' , text : `filler ${ i } -${ f } advancing the cursor` } ,
1130+ ] ,
1131+ } ) ;
1132+ }
1133+ nodes . push ( {
1134+ type : 'heading' ,
1135+ attrs : { level : 2 } ,
1136+ content : [ { type : 'text' , text : `TABLEHEADING${ i } Section ${ i } ` } ] ,
1137+ } ) ;
1138+ nodes . push ( {
1139+ type : 'table' ,
1140+ content : [
1141+ {
1142+ type : 'tableRow' ,
1143+ content : [ tallCell ( `TABLECELL${ i } ` ) , tallCell ( `meta${ i } ` ) ] ,
1144+ } ,
1145+ ] ,
1146+ } ) ;
1147+ }
1148+
1149+ const result = service . renderPoliciesPdfBuffer ( [
1150+ { name : 'Heading Table Policy' , content : { type : 'doc' , content : nodes } } ,
1151+ ] ) ;
1152+
1153+ const pages = pageTextsFrom ( result ) ;
1154+ const orphaned : number [ ] = [ ] ;
1155+ for ( let i = 0 ; i < SECTIONS ; i ++ ) {
1156+ const headingPage = pageIndexContaining ( pages , `TABLEHEADING${ i } ` ) ;
1157+ const tablePage = pageIndexContaining ( pages , `TABLECELL${ i } ` ) ;
1158+ expect ( headingPage ) . toBeGreaterThanOrEqual ( 0 ) ;
1159+ expect ( tablePage ) . toBeGreaterThanOrEqual ( 0 ) ;
1160+ if ( headingPage !== tablePage ) orphaned . push ( i ) ;
1161+ }
1162+ expect ( orphaned ) . toEqual ( [ ] ) ;
1163+ } ) ;
1164+
1165+ it ( 'does not emit a blank leading page for an oversized heading' , ( ) => {
1166+ // A nameless policy whose first (and only) node is a heading taller than
1167+ // the page must start rendering on page 1, not after an empty page. The
1168+ // reserve is capped at the usable page height so the up-front check can't
1169+ // add a page while the current one is still empty.
1170+ const longHeadingText = Array . from (
1171+ { length : 200 } ,
1172+ ( ) => 'BLANKGUARDWORD' ,
1173+ ) . join ( ' ' ) ;
1174+
1175+ const result = service . renderPoliciesPdfBuffer ( [
1176+ {
1177+ name : '' ,
1178+ content : {
1179+ type : 'doc' ,
1180+ content : [
1181+ {
1182+ type : 'heading' ,
1183+ attrs : { level : 1 } ,
1184+ content : [ { type : 'text' , text : longHeadingText } ] ,
1185+ } ,
1186+ ] ,
1187+ } ,
1188+ } ,
1189+ ] ) ;
1190+
1191+ const orderedPages = orderedPageTexts ( result ) ;
1192+ // The very first page carries heading text — no blank page precedes it.
1193+ expect ( orderedPages [ 0 ] ) . toContain ( 'BLANKGUARDWORD' ) ;
1194+ } ) ;
1195+
1196+ it ( 'paginates a heading longer than a page instead of overflowing' , ( ) => {
1197+ // Pathological guard: a heading that wraps to more lines than fit on one
1198+ // page must span multiple pages rather than run off the bottom margin.
1199+ const longHeadingText = Array . from (
1200+ { length : 200 } ,
1201+ ( ) => 'OVERFLOWWORD' ,
1202+ ) . join ( ' ' ) ;
1203+
1204+ const result = service . renderPoliciesPdfBuffer ( [
1205+ {
1206+ name : 'Giant Heading Policy' ,
1207+ content : {
1208+ type : 'doc' ,
1209+ content : [
1210+ {
1211+ type : 'heading' ,
1212+ attrs : { level : 1 } ,
1213+ content : [ { type : 'text' , text : longHeadingText } ] ,
1214+ } ,
1215+ ] ,
1216+ } ,
1217+ } ,
1218+ ] ) ;
1219+
1220+ const pages = pageTextsFrom ( result ) ;
1221+ const pagesWithHeading = pages . filter ( ( t ) =>
1222+ t . includes ( 'OVERFLOWWORD' ) ,
1223+ ) . length ;
1224+ expect ( pagesWithHeading ) . toBeGreaterThan ( 1 ) ;
1225+ } ) ;
1226+
9401227 it ( 'applies custom primary color' , ( ) => {
9411228 const result = service . renderPoliciesPdfBuffer (
9421229 [
0 commit comments