codesummary 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ import fs from 'fs-extra';
2
+
3
+ /**
4
+ * Renderer for structured repository context artifact (.llmsummary.json).
5
+ */
6
+ export default class LlmSummaryRenderer {
7
+ async render(outputPath, summaryData) {
8
+ await fs.writeJson(outputPath, summaryData, { spaces: 2 });
9
+ return {
10
+ outputPath
11
+ };
12
+ }
13
+ }
14
+
@@ -0,0 +1,685 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ /**
9
+ * PDFThemeRenderer
10
+ * Shared high-end visual primitives for PDFKit reports.
11
+ */
12
+ export default class PDFThemeRenderer {
13
+ constructor(options = {}) {
14
+ const defaultFontsDir = path.resolve(__dirname, '../../fonts');
15
+ this.fontsDir = options.fontsDir || defaultFontsDir;
16
+
17
+ // ── Design tokens ─────────────────────────────────────────────────────── //
18
+ this.tokens = {
19
+ // Text hierarchy
20
+ text: '#1A202C',
21
+ textSecond: '#4A5568',
22
+ muted: '#718096',
23
+ faint: '#A0AEC0',
24
+ inverse: '#FFFFFF',
25
+
26
+ // Brand palette — vibrant indigo + cyan
27
+ primary: '#5B50F0',
28
+ primaryDark: '#4338CA',
29
+ primaryLight: '#EEF2FF',
30
+ primaryMid: '#818CF8',
31
+ cyan: '#06B6D4',
32
+ cyanLight: '#ECFEFF',
33
+ green: '#10B981',
34
+ greenLight: '#D1FAE5',
35
+ orange: '#F59E0B',
36
+ orangeLight: '#FEF3C7',
37
+ red: '#EF4444',
38
+ redLight: '#FEE2E2',
39
+ purple: '#8B5CF6',
40
+ purpleLight: '#EDE9FE',
41
+
42
+ // Cover page
43
+ coverBg: '#0D0F1A',
44
+ coverBg2: '#1A0D3D',
45
+ coverTitle: '#FFFFFF',
46
+ coverSub: '#A5B4FC',
47
+ coverMuted: '#4A5568',
48
+
49
+ // Surfaces
50
+ pageBackground: '#F7F8FC',
51
+ headerStrip: '#0D0F1A',
52
+ headerStripText: '#94A3B8',
53
+ cardBackground: '#FFFFFF',
54
+ divider: '#E2E8F0',
55
+
56
+ // Code editor (Catppuccin Mocha)
57
+ codeBg: '#1E1E2E',
58
+ codeFg: '#CDD6F4',
59
+ codeGutter: '#313244',
60
+ codeGutterText: '#6C7086',
61
+ codeTabBg: '#181825',
62
+ codeTabText: '#89B4FA',
63
+ codeBorder: '#45475A',
64
+
65
+ // Tables
66
+ tableHeaderBg: '#1E293B',
67
+ tableHeaderText: '#F1F5F9',
68
+ tableStripe: '#F8FAFC',
69
+ tableRowBorder: '#E2E8F0',
70
+
71
+ // Callouts
72
+ calloutInfoBg: '#EEF2FF',
73
+ calloutInfoBorder: '#818CF8',
74
+ calloutRiskBg: '#FEF2F2',
75
+ calloutRiskBorder: '#FCA5A5',
76
+ calloutRiskText: '#B91C1C',
77
+ calloutSuccessBg: '#F0FDF4',
78
+ calloutSuccessBorder: '#6EE7B7',
79
+ calloutWarningBg: '#FFFBEB',
80
+ calloutWarningBorder: '#FCD34D'
81
+ };
82
+
83
+ this.typeScale = {
84
+ display: 30,
85
+ h1: 22,
86
+ h2: 14,
87
+ h3: 11.5,
88
+ body: 10,
89
+ small: 8.5,
90
+ micro: 7.5
91
+ };
92
+
93
+ this.fontNames = {
94
+ regular: 'Inter-Regular',
95
+ bold: 'Inter-Bold',
96
+ mono: 'FiraCode'
97
+ };
98
+ }
99
+
100
+ // ── Font registration ─────────────────────────────────────────────────── //
101
+
102
+ applyBaseTheme(doc) {
103
+ const regular = path.join(this.fontsDir, 'Inter-Regular.ttf');
104
+ const bold = path.join(this.fontsDir, 'Inter-Bold.ttf');
105
+ const mono = path.join(this.fontsDir, 'FiraCode.ttf');
106
+
107
+ const hasRegular = fs.existsSync(regular);
108
+ const hasBold = fs.existsSync(bold);
109
+ const hasMono = fs.existsSync(mono);
110
+
111
+ if (hasRegular) doc.registerFont(this.fontNames.regular, regular);
112
+ if (hasBold) doc.registerFont(this.fontNames.bold, bold);
113
+ if (hasMono) doc.registerFont(this.fontNames.mono, mono);
114
+
115
+ if (!hasRegular) this.fontNames.regular = 'Helvetica';
116
+ if (!hasBold) this.fontNames.bold = 'Helvetica-Bold';
117
+ if (!hasMono) this.fontNames.mono = 'Courier';
118
+
119
+ if (!doc.page?.margins) return;
120
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
121
+ }
122
+
123
+ // ── Low-level primitives ──────────────────────────────────────────────── //
124
+
125
+ /** Subtle horizontal rule. */
126
+ drawDivider(doc, y = doc.y, color = null) {
127
+ const left = doc.page.margins.left;
128
+ const right = doc.page.width - doc.page.margins.right;
129
+ doc.save()
130
+ .moveTo(left, y).lineTo(right, y)
131
+ .lineWidth(0.4)
132
+ .strokeColor(color || this.tokens.divider)
133
+ .stroke()
134
+ .restore();
135
+ }
136
+
137
+ /** Filled rounded pill. Returns pill width. */
138
+ drawPill(doc, x, y, label, bgColor, textColor, fontSize = 7) {
139
+ const padX = 7, padY = 3;
140
+ doc.font(this.fontNames.bold).fontSize(fontSize);
141
+ const textW = doc.widthOfString(label);
142
+ const pillW = textW + padX * 2;
143
+ const pillH = fontSize + padY * 2;
144
+ doc.save();
145
+ doc.roundedRect(x, y, pillW, pillH, pillH / 2);
146
+ doc.fillColor(bgColor);
147
+ doc.fill();
148
+ doc.restore();
149
+ doc.font(this.fontNames.bold).fontSize(fontSize).fillColor(textColor)
150
+ .text(label, x + padX, y + padY + 0.5, { lineBreak: false });
151
+ return pillW;
152
+ }
153
+
154
+ /** Decorative overlapping circles (for cover). */
155
+ drawDecorCircles(doc, cx, cy) {
156
+ const circles = [
157
+ { r: 190, color: '#5B50F0', opacity: 0.07 },
158
+ { r: 128, color: '#06B6D4', opacity: 0.11 },
159
+ { r: 72, color: '#10B981', opacity: 0.20 },
160
+ { r: 32, color: '#8B5CF6', opacity: 0.42 }
161
+ ];
162
+ for (const { r, color, opacity } of circles) {
163
+ doc.save();
164
+ doc.circle(cx, cy, r);
165
+ doc.fillColor(color, opacity);
166
+ doc.fill();
167
+ doc.restore();
168
+ }
169
+ }
170
+
171
+ /** Draw a horizontal gradient approximation using N thin rect slices. */
172
+ drawGradientRect(doc, x, y, w, h, colorA, colorB, steps = 24) {
173
+ const rA = parseInt(colorA.slice(1, 3), 16);
174
+ const gA = parseInt(colorA.slice(3, 5), 16);
175
+ const bA = parseInt(colorA.slice(5, 7), 16);
176
+ const rB = parseInt(colorB.slice(1, 3), 16);
177
+ const gB = parseInt(colorB.slice(3, 5), 16);
178
+ const bB = parseInt(colorB.slice(5, 7), 16);
179
+ const sliceW = w / steps;
180
+ for (let i = 0; i < steps; i++) {
181
+ const t = i / (steps - 1);
182
+ const r = Math.round(rA + (rB - rA) * t);
183
+ const g = Math.round(gA + (gB - gA) * t);
184
+ const b = Math.round(bA + (bB - bA) * t);
185
+ const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
186
+ doc.save().rect(x + i * sliceW, y, sliceW + 0.5, h).fillColor(hex).fill().restore();
187
+ }
188
+ }
189
+
190
+ // ── Section header ────────────────────────────────────────────────────── //
191
+
192
+ /**
193
+ * Vibrant section header with left accent pill and bold label.
194
+ */
195
+ renderSectionHeader(doc, title) {
196
+ const left = doc.page.margins.left;
197
+ const available = doc.page.width - doc.page.margins.left - doc.page.margins.right;
198
+ const y = Math.max(doc.y, doc.page.margins.top);
199
+
200
+ const barW = 4;
201
+ const lineH = 20;
202
+
203
+ // Left accent bar (slightly wider for more visual weight)
204
+ doc.save();
205
+ doc.roundedRect(left, y, barW, lineH, 2);
206
+ doc.fillColor(this.tokens.primary);
207
+ doc.fill();
208
+ doc.restore();
209
+
210
+ doc.font(this.fontNames.bold)
211
+ .fontSize(this.typeScale.h2)
212
+ .fillColor(this.tokens.text)
213
+ .text(title, left + barW + 10, y + 2, { width: available - barW - 12 });
214
+
215
+ const divY = doc.y + 4;
216
+ this.drawDivider(doc, divY);
217
+ doc.y = divY + 6;
218
+ // Reset cursor to left margin so downstream elements (tables, text) start at the correct x
219
+ doc.x = left;
220
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
221
+ }
222
+
223
+ // ── Page banner (AI section title) ───────────────────────────────────── //
224
+
225
+ /**
226
+ * Full-width dark banner (used as a page-level section hero).
227
+ */
228
+ renderPageBanner(doc, title, subtitle = '') {
229
+ const left = doc.page.margins.left;
230
+ const top = Math.max(doc.y, doc.page.margins.top);
231
+ const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
232
+ const h = subtitle ? 46 : 34;
233
+
234
+ // Banner background
235
+ doc.save()
236
+ .roundedRect(left, top, width, h, 6)
237
+ .fillColor(this.tokens.coverBg)
238
+ .fill()
239
+ .restore();
240
+
241
+ // Accent gradient strip on left edge
242
+ doc.save()
243
+ .roundedRect(left, top, 4, h, 3)
244
+ .fillColor(this.tokens.primary)
245
+ .fill()
246
+ .restore();
247
+
248
+ // AI badge on right
249
+ const badgeLabel = 'AI-POWERED';
250
+ const bPad = 6, bFont = 7;
251
+ doc.save().font(this.fontNames.bold).fontSize(bFont);
252
+ const bW = doc.widthOfString(badgeLabel) + bPad * 2;
253
+ const bH = bFont + bPad - 1;
254
+ const bX = left + width - bW - 10;
255
+ const bY = top + (h - bH) / 2;
256
+ doc.restore();
257
+ doc.save()
258
+ .roundedRect(bX, bY, bW, bH, bH / 2)
259
+ .fillColor(this.tokens.cyan)
260
+ .fill()
261
+ .restore();
262
+ doc.font(this.fontNames.bold).fontSize(bFont).fillColor(this.tokens.coverBg)
263
+ .text(badgeLabel, bX + bPad, bY + (bH - bFont) / 2 + 0.5, { lineBreak: false });
264
+
265
+ // Title text
266
+ doc.font(this.fontNames.bold).fontSize(this.typeScale.h3 + 1).fillColor(this.tokens.inverse)
267
+ .text(title, left + 16, top + (subtitle ? 8 : (h - this.typeScale.h3 - 1) / 2), {
268
+ width: width - bW - 32,
269
+ lineBreak: false
270
+ });
271
+
272
+ if (subtitle) {
273
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.small).fillColor(this.tokens.faint)
274
+ .text(subtitle, left + 16, top + 26, { width: width - bW - 32, lineBreak: false });
275
+ }
276
+
277
+ doc.y = top + h + 18;
278
+ doc.x = left;
279
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
280
+ }
281
+
282
+ // ── Callout box ───────────────────────────────────────────────────────── //
283
+
284
+ renderCalloutBox(doc, text, type = 'info', width = null) {
285
+ const left = doc.page.margins.left;
286
+ const boxWidth = width || (doc.page.width - doc.page.margins.left - doc.page.margins.right);
287
+ const paddingX = 14, paddingY = 10;
288
+ const y = doc.y;
289
+
290
+ const palette = {
291
+ info: { bg: this.tokens.calloutInfoBg, border: this.tokens.calloutInfoBorder, bar: this.tokens.primary, text: this.tokens.text },
292
+ risk: { bg: this.tokens.calloutRiskBg, border: this.tokens.calloutRiskBorder, bar: this.tokens.red, text: this.tokens.calloutRiskText },
293
+ success: { bg: this.tokens.calloutSuccessBg, border: this.tokens.calloutSuccessBorder, bar: this.tokens.green, text: '#065F46' },
294
+ warning: { bg: this.tokens.calloutWarningBg, border: this.tokens.calloutWarningBorder, bar: this.tokens.orange, text: '#92400E' }
295
+ };
296
+ const { bg, bar, text: textColor } = palette[type] || palette.info;
297
+
298
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body);
299
+ const textH = doc.heightOfString(text, { width: boxWidth - paddingX * 2 - 4, lineGap: 1.5 });
300
+ const boxH = textH + paddingY * 2;
301
+
302
+ doc.save().roundedRect(left, y, boxWidth, boxH, 6).fillColor(bg).fill().restore();
303
+ doc.save().roundedRect(left, y, 4, boxH, 3).fillColor(bar).fill().restore();
304
+
305
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(textColor)
306
+ .text(text, left + paddingX + 2, y + paddingY, { width: boxWidth - paddingX * 2 - 4, lineGap: 1.5 });
307
+
308
+ doc.y = y + boxH + 8;
309
+ doc.x = left;
310
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
311
+ }
312
+
313
+ // ── Data flow arrow row ───────────────────────────────────────────────── //
314
+
315
+ /**
316
+ * Renders a visual data-flow strip: A -> B -> C with colored pills.
317
+ */
318
+ renderFlowStrip(doc, flowText) {
319
+ if (!flowText) return;
320
+ const parts = flowText.split(/\s*->\s*/).map(p => p.trim()).filter(Boolean);
321
+ if (parts.length < 2) {
322
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body)
323
+ .fillColor(this.tokens.primary).text(flowText, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right });
324
+ doc.moveDown(0.5);
325
+ return;
326
+ }
327
+
328
+ const left = doc.page.margins.left;
329
+ const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
330
+ const pillH = 26;
331
+ const arrowW = 20;
332
+ const colors = [this.tokens.primary, this.tokens.cyan, this.tokens.green, this.tokens.orange, this.tokens.purple];
333
+ const fontSize = 8;
334
+ let hPad = 12;
335
+
336
+ doc.font(this.fontNames.bold).fontSize(fontSize);
337
+
338
+ // 1. Calculate items and their required widths
339
+ let items = parts.map(label => ({
340
+ label,
341
+ pW: doc.widthOfString(label) + hPad * 2
342
+ }));
343
+
344
+ // Strategy: If we only slightly overflow (up to 15%), reduce padding to fit
345
+ const totalRawW = items.reduce((s, it) => s + it.pW, 0) + (items.length - 1) * arrowW;
346
+ if (totalRawW > width && totalRawW < width * 1.15) {
347
+ hPad = 8;
348
+ items = parts.map(label => ({
349
+ label,
350
+ pW: doc.widthOfString(label) + hPad * 2
351
+ }));
352
+ }
353
+
354
+ // 2. Group into rows
355
+ const rows = [];
356
+ let currentRow = [];
357
+ let currentRowW = 0;
358
+
359
+ items.forEach((item, i) => {
360
+ const neededW = item.pW + (currentRow.length > 0 ? arrowW : 0);
361
+
362
+ if (currentRowW + neededW > width && currentRow.length > 0) {
363
+ rows.push(currentRow);
364
+ currentRow = [];
365
+ currentRowW = 0;
366
+ }
367
+
368
+ currentRow.push(item);
369
+ currentRowW += (currentRow.length === 1 ? item.pW : item.pW + arrowW);
370
+ });
371
+ if (currentRow.length > 0) rows.push(currentRow);
372
+
373
+ // 3. Render rows
374
+ let currentY = doc.y;
375
+ let globalIndex = 0;
376
+ const bottomReserve = 40;
377
+
378
+ rows.forEach((row, rowIndex) => {
379
+ // Page-break guard: if this row doesn't fit, start a new page
380
+ const bottomLimit = doc.page.height - doc.page.margins.bottom - bottomReserve;
381
+ if (currentY + pillH > bottomLimit) {
382
+ doc.addPage();
383
+ currentY = doc.page.margins.top + 10;
384
+ doc.x = left;
385
+ }
386
+
387
+ // Sync PDFKit cursor to our explicit Y before rendering this row.
388
+ // Without this, PDFKit's auto-page logic fires on the next .text() call
389
+ // and silently inserts a blank page mid-strip.
390
+ doc.y = currentY;
391
+
392
+ const rowPillsW = row.reduce((s, it) => s + it.pW, 0);
393
+ const rowArrowsW = (row.length - 1) * arrowW;
394
+ const rowTotalW = rowPillsW + rowArrowsW;
395
+
396
+ let x = left + (width - rowTotalW) / 2;
397
+
398
+ row.forEach((item, colIndex) => {
399
+ const color = colors[globalIndex % colors.length];
400
+
401
+ // Pill background
402
+ doc.save().roundedRect(x, currentY, item.pW, pillH, 6).fillColor(color).fill().restore();
403
+
404
+ // Vertical center calculation: (PillHeight - TextHeight) / 2
405
+ const textH = doc.heightOfString(item.label, { width: item.pW - hPad * 2 });
406
+ const textY = currentY + (pillH - textH) / 2 + 0.5;
407
+
408
+ doc.font(this.fontNames.bold).fontSize(fontSize).fillColor('#FFFFFF')
409
+ .text(item.label, x + hPad, textY, {
410
+ width: item.pW - hPad * 2,
411
+ align: 'center',
412
+ lineBreak: true
413
+ });
414
+
415
+ // Restore cursor after .text() to prevent drift / spurious auto-page
416
+ doc.y = currentY;
417
+ doc.x = left;
418
+
419
+ x += item.pW;
420
+ globalIndex++;
421
+
422
+ // Draw arrow if not last in this row
423
+ if (colIndex < row.length - 1) {
424
+ const arrowY = currentY + pillH / 2;
425
+ doc.save()
426
+ .moveTo(x + 4, arrowY).lineTo(x + arrowW - 4, arrowY)
427
+ .lineWidth(1.5).strokeColor(this.tokens.muted).stroke()
428
+ .restore();
429
+ doc.save()
430
+ .polygon([x + arrowW - 6, arrowY - 3], [x + arrowW - 2, arrowY], [x + arrowW - 6, arrowY + 3])
431
+ .fillColor(this.tokens.muted).fill()
432
+ .restore();
433
+ x += arrowW;
434
+ }
435
+ });
436
+
437
+ // If there's a next row, draw a vertical continuation line/arrow
438
+ if (rowIndex < rows.length - 1) {
439
+ const lastInRowX = x - row[row.length - 1].pW / 2;
440
+ const connY = currentY + pillH;
441
+ doc.save()
442
+ .moveTo(lastInRowX, connY + 1).lineTo(lastInRowX, connY + 5)
443
+ .lineWidth(1).strokeColor(this.tokens.faint).dash(2, { space: 2 }).stroke()
444
+ .restore();
445
+ }
446
+
447
+ currentY += pillH + 12; // Space between rows
448
+ });
449
+
450
+ doc.y = currentY;
451
+ doc.x = left;
452
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
453
+ }
454
+
455
+ // ── Table ─────────────────────────────────────────────────────────────── //
456
+
457
+ async renderZebraTable(doc, titleOrHeaders, headersOrRows, maybeRows) {
458
+ const hasTitle = typeof titleOrHeaders === 'string';
459
+ const title = hasTitle ? titleOrHeaders : '';
460
+ const headers = hasTitle ? headersOrRows : titleOrHeaders;
461
+ const rows = hasTitle ? maybeRows : headersOrRows;
462
+
463
+ if (!Array.isArray(headers) || headers.length === 0) return;
464
+ if (!Array.isArray(rows) || rows.length === 0) return;
465
+
466
+ const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
467
+ const rowValues = rows.map(row =>
468
+ Array.isArray(row) ? row.map(v => String(v ?? '')) : headers.map(k => String(row?.[k] ?? ''))
469
+ );
470
+
471
+ const left = doc.page.margins.left;
472
+ doc.x = left;
473
+
474
+ if (title) {
475
+ doc.font(this.fontNames.bold).fontSize(this.typeScale.small)
476
+ .fillColor(this.tokens.muted)
477
+ .text(title.toUpperCase(), { width: tableWidth })
478
+ .moveDown(1.5);
479
+ doc.x = left; // guard: reset cursor after text
480
+ }
481
+
482
+ // Build header column objects so pdfkit-table applies the background colour
483
+ const headerDefs = headers.map(h => ({
484
+ label: String(h ?? ''),
485
+ headerColor: this.tokens.tableHeaderBg,
486
+ headerOpacity: 1
487
+ }));
488
+
489
+ doc.x = left;
490
+ await doc.table({ headers: headerDefs, rows: rowValues }, {
491
+ x: left,
492
+ width: tableWidth,
493
+ columnSpacing: 8,
494
+ padding: [7, 10, 7, 10],
495
+ prepareHeader: () => {
496
+ doc.font(this.fontNames.bold).fontSize(this.typeScale.body).fillColor(this.tokens.tableHeaderText);
497
+ },
498
+ prepareRow: (row, indexColumn, indexRow, rectRow) => {
499
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
500
+ if (indexColumn === 0 && indexRow % 2 !== 0 && rectRow) {
501
+ doc.addBackground(rectRow, this.tokens.tableStripe, 1);
502
+ }
503
+ },
504
+ divider: {
505
+ header: { disabled: false, width: 0.5, opacity: 1, color: this.tokens.tableHeaderBg },
506
+ horizontal: { disabled: false, width: 0.4, opacity: 0.6, color: this.tokens.divider },
507
+ vertical: { disabled: true }
508
+ }
509
+ });
510
+ }
511
+
512
+ // ── Risk / hotspot cards ──────────────────────────────────────────────── //
513
+
514
+ /**
515
+ * Binary-search the last word boundary where text still fits within availH.
516
+ * Returns [firstPart, remainingPart].
517
+ */
518
+ _splitTextToFit(doc, text, textWidth, availH, fontName, fontSize, lineGap = 0) {
519
+ doc.font(fontName).fontSize(fontSize);
520
+ if (availH <= 0) return ['', text];
521
+ if (doc.heightOfString(text, { width: textWidth, lineGap }) <= availH) return [text, ''];
522
+ const words = text.split(' ');
523
+ if (words.length <= 1) return ['', text];
524
+ let lo = 1, hi = words.length - 1;
525
+ while (lo < hi) {
526
+ const mid = Math.floor((lo + hi + 1) / 2);
527
+ const h = doc.heightOfString(words.slice(0, mid).join(' '), { width: textWidth, lineGap });
528
+ if (h <= availH) lo = mid; else hi = mid - 1;
529
+ }
530
+ return [words.slice(0, lo).join(' '), words.slice(lo).join(' ')];
531
+ }
532
+
533
+ renderRiskCards(doc, hotspots = []) {
534
+ if (!hotspots?.length) return;
535
+
536
+ const left = doc.page.margins.left;
537
+ const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
538
+ const FOOTER_RESERVE = 16;
539
+ // Offset from card top (after pY) where the reason text starts on first segment
540
+ const HEADER_H = 12;
541
+
542
+ for (const risk of hotspots) {
543
+ const severity = String(risk?.severity || 'HIGH').toUpperCase();
544
+ const isHigh = severity === 'HIGH' || severity === 'CRITICAL';
545
+ const isMed = severity === 'MEDIUM' || severity === 'MED';
546
+
547
+ let bgColor, barColor, titleColor, badgeBg;
548
+ if (isHigh) { bgColor = '#FEF2F2'; barColor = '#EF4444'; titleColor = '#B91C1C'; badgeBg = '#EF4444'; }
549
+ else if (isMed) { bgColor = '#FFFBEB'; barColor = '#F59E0B'; titleColor = '#92400E'; badgeBg = '#F59E0B'; }
550
+ else { bgColor = '#F0FDF4'; barColor = '#10B981'; titleColor = '#065F46'; badgeBg = '#10B981'; }
551
+
552
+ const file = risk?.file || 'Unknown file';
553
+ const pX = 14, pY = 6, barW = 4;
554
+ const textW = width - pX * 2 - barW - 2;
555
+
556
+ // Pre-measure badge width
557
+ doc.save().font(this.fontNames.bold).fontSize(7);
558
+ const bW = doc.widthOfString(severity) + 12;
559
+ doc.restore();
560
+
561
+ let remainingReason = risk?.reason || 'No reason provided.';
562
+ let isFirst = true;
563
+
564
+ while (true) {
565
+ const bottomLimit = doc.page.height - doc.page.margins.bottom - FOOTER_RESERVE;
566
+ const availH = bottomLimit - doc.y;
567
+
568
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body);
569
+ const oneLineH = doc.currentLineHeight(true);
570
+ // Minimum height to start a card segment: padding + optional header + one line + padding
571
+ const minH = pY + (isFirst ? HEADER_H : 0) + oneLineH + pY;
572
+
573
+ if (availH < minH) { doc.addPage(); continue; }
574
+
575
+ const fullReasonH = remainingReason
576
+ ? doc.heightOfString(remainingReason, { width: textW, lineGap: 1.4 })
577
+ : 0;
578
+ const totalH = pY + (isFirst ? HEADER_H : 0) + fullReasonH + pY;
579
+ const y = doc.y;
580
+
581
+ let renderReason = remainingReason;
582
+ let nextReason = '';
583
+
584
+ if (totalH > availH) {
585
+ const textAvailH = availH - pY - (isFirst ? HEADER_H : 0) - pY;
586
+ [renderReason, nextReason] = this._splitTextToFit(
587
+ doc, remainingReason, textW, textAvailH,
588
+ this.fontNames.regular, this.typeScale.body, 1.4
589
+ );
590
+ // If the split didn't yield any text (e.g. first word is too long), jump to next page
591
+ if (!renderReason.trim()) { doc.addPage(); continue; }
592
+ }
593
+
594
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body);
595
+ const segReasonH = renderReason
596
+ ? doc.heightOfString(renderReason, { width: textW, lineGap: 1.4 })
597
+ : 0;
598
+ const segH = pY + (isFirst ? HEADER_H : 0) + segReasonH + pY;
599
+
600
+ // Draw card background + severity bar
601
+ doc.save().roundedRect(left, y, width, segH, 5).fillColor(bgColor).fill().restore();
602
+ doc.save().roundedRect(left, y, barW, segH, 3).fillColor(barColor).fill().restore();
603
+
604
+ if (isFirst) {
605
+ // Severity badge
606
+ const bH = 14, bX = left + width - bW - 8, bY2 = y + pY - 1;
607
+ doc.save().roundedRect(bX, bY2, bW, bH, 4).fillColor(badgeBg).fill().restore();
608
+ doc.font(this.fontNames.bold).fontSize(7).fillColor('#FFFFFF')
609
+ .text(severity, bX, bY2 + 3.5, { width: bW, align: 'center', lineBreak: false });
610
+
611
+ // File path
612
+ doc.font(this.fontNames.bold).fontSize(this.typeScale.body).fillColor(titleColor)
613
+ .text(file, left + barW + pX, y + pY, { width: width - pX * 2 - barW - bW - 14, lineBreak: false });
614
+ }
615
+
616
+ // Reason text
617
+ if (renderReason) {
618
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text)
619
+ .text(renderReason, left + barW + pX, y + pY + (isFirst ? HEADER_H : 0), { width: textW, lineGap: 1.4 });
620
+ }
621
+
622
+ doc.y = y + segH + 4;
623
+ doc.x = left;
624
+
625
+ if (!nextReason.trim()) break;
626
+
627
+ remainingReason = nextReason;
628
+ isFirst = false;
629
+ doc.addPage();
630
+ }
631
+ }
632
+ doc.moveDown(1.5);
633
+ doc.font(this.fontNames.regular).fontSize(this.typeScale.body).fillColor(this.tokens.text);
634
+ }
635
+
636
+ // ── Running page header ───────────────────────────────────────────────── //
637
+
638
+ /**
639
+ * Thin dark strip at the very top of a content page (not cover).
640
+ */
641
+ renderPageHeader(doc, projectName, pageNum) {
642
+ const w = doc.page.width;
643
+ const h = 28;
644
+
645
+ // Full-width strip
646
+ doc.save().rect(0, 0, w, h).fillColor(this.tokens.headerStrip).fill().restore();
647
+
648
+ // Accent gradient line at bottom edge of strip
649
+ const rA = parseInt(this.tokens.primary.slice(1, 3), 16);
650
+ const gA = parseInt(this.tokens.primary.slice(3, 5), 16);
651
+ const bA = parseInt(this.tokens.primary.slice(5, 7), 16);
652
+ const rB = parseInt(this.tokens.cyan.slice(1, 3), 16);
653
+ const gB = parseInt(this.tokens.cyan.slice(3, 5), 16);
654
+ const bB = parseInt(this.tokens.cyan.slice(5, 7), 16);
655
+ const steps = 32;
656
+ const sliceW = w / steps;
657
+ for (let i = 0; i < steps; i++) {
658
+ const t2 = i / (steps - 1);
659
+ const r = Math.round(rA + (rB - rA) * t2);
660
+ const g = Math.round(gA + (gB - gA) * t2);
661
+ const b = Math.round(bA + (bB - bA) * t2);
662
+ const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
663
+ doc.save().rect(i * sliceW, h - 1.5, sliceW + 0.5, 1.5).fillColor(hex).fill().restore();
664
+ }
665
+
666
+ // Project name — vertically centered in strip
667
+ const leftFontSize = 7.5;
668
+ const leftY = (h - leftFontSize) / 2 - 0.5;
669
+ doc.font(this.fontNames.bold).fontSize(leftFontSize).fillColor(this.tokens.headerStripText)
670
+ .text(projectName.toUpperCase(), doc.page.margins.left, leftY, {
671
+ width: 200, lineBreak: false
672
+ });
673
+
674
+ // Breadcrumb on right — vertically centered
675
+ const rightFontSize = 7;
676
+ const rightY = (h - rightFontSize) / 2 - 0.5;
677
+ doc.font(this.fontNames.regular).fontSize(rightFontSize).fillColor(this.tokens.primaryMid)
678
+ .text('Code Documentation', w - doc.page.margins.right - 100, rightY, {
679
+ width: 100, align: 'right', lineBreak: false
680
+ });
681
+ const left = doc.page.margins.left;
682
+ doc.x = left;
683
+ doc.y = Math.max(doc.page.margins.top, h + 12);
684
+ }
685
+ }