Skip to content

Commit f76a50c

Browse files
authored
Merge pull request #3360 from trycompai/main
[comp] Production Deploy
2 parents e30faf8 + 5bac5c9 commit f76a50c

5 files changed

Lines changed: 722 additions & 71 deletions

File tree

‎apps/api/src/trust-portal/policy-pdf-renderer.service.spec.ts‎

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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*Tj/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*Tj/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

Comments
 (0)