codesummary 1.2.1 → 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.
@@ -1,8 +1,9 @@
1
- import PDFDocument from 'pdfkit';
1
+ import PDFDocument from 'pdfkit-table';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import chalk from 'chalk';
5
5
  import ErrorHandler from './errorHandler.js';
6
+ import PDFThemeRenderer from './renderers/pdfThemeRenderer.js';
6
7
  import { getExtensionDescription, resolveVersionedPath } from './utils.js';
7
8
 
8
9
  /**
@@ -11,62 +12,63 @@ import { getExtensionDescription, resolveVersionedPath } from './utils.js';
11
12
  */
12
13
  export class PDFGenerator {
13
14
  constructor(config) {
14
- this.config = config;
15
- this.doc = null;
16
- this.pageHeight = 842; // A4 height (595 x 842 points)
17
- this.pageWidth = 595; // A4 width
15
+ this.config = config;
16
+ this.doc = null;
17
+ this.themeRenderer = new PDFThemeRenderer();
18
+ this.pageHeight = 842;
19
+ this.pageWidth = 595;
20
+ this._isCoverPage = true;
21
+ this._projectName = '';
22
+ this._pageCount = 0;
23
+ this._sectionCounter = 0;
24
+
25
+ // ── Colour tokens for direct use in this file ─────────────────────── //
26
+ this.t = null; // set after themeRenderer is ready (tokens reference)
18
27
  }
19
28
 
20
- /**
21
- * Calculate available content width
22
- * @returns {number} Available width for content
23
- */
24
29
  getContentWidth() {
30
+ if (this.doc?.page?.margins) {
31
+ return this.doc.page.width - (this.doc.page.margins.left + this.doc.page.margins.right);
32
+ }
25
33
  return this.pageWidth - (this.config.styles.layout.marginLeft + this.config.styles.layout.marginRight);
26
34
  }
27
35
 
28
- /**
29
- * Generate PDF from scanned files
30
- * @param {object} filesByExtension - Files grouped by extension
31
- * @param {Array} selectedExtensions - Extensions selected by user
32
- * @param {string} outputPath - Output file path
33
- * @param {string} projectName - Name of the project
34
- * @returns {Promise<object>} Object with outputPath and pageCount
35
- */
36
- async generatePDF(filesByExtension, selectedExtensions, outputPath, projectName) {
36
+ // ═══════════════════════════════════════════════════════════════════════ //
37
+ // Public API
38
+ // ═══════════════════════════════════════════════════════════════════════ //
39
+
40
+ async generatePDF(filesByExtension, selectedExtensions, outputPath, projectName, pdfOptions = {}) {
37
41
  console.log(chalk.gray('Generating PDF...'));
42
+ this._projectName = projectName;
38
43
 
39
- // Initialize PDF document with A4 size and optimized margins
40
44
  this.doc = new PDFDocument({
45
+ bufferPages: true,
41
46
  size: 'A4',
42
47
  margins: {
43
- top: this.config.styles.layout.marginTop,
44
- bottom: this.config.styles.layout.marginTop,
45
- left: this.config.styles.layout.marginLeft,
46
- right: this.config.styles.layout.marginRight
48
+ top: Math.max(this.config.styles.layout.marginTop, 50),
49
+ bottom: Math.max(this.config.styles.layout.marginTop, 50),
50
+ left: Math.max(this.config.styles.layout.marginLeft, 50),
51
+ right: Math.max(this.config.styles.layout.marginRight, 50)
47
52
  }
48
53
  });
49
54
 
50
- // Register cleanup for PDF document
55
+ this.doc.info.Title = `${projectName} - Code Summary`;
56
+ this.doc.info.Author = 'CodeSummary';
57
+ this.doc.info.Subject = 'Code and architecture documentation';
58
+ this.doc.info.Keywords = 'code summary, architecture, documentation';
59
+
51
60
  ErrorHandler.registerCleanup(() => {
52
- if (this.doc && !this.doc.closed) {
53
- this.doc.end();
54
- }
61
+ if (this.doc && !this.doc.closed) this.doc.end();
55
62
  }, 'PDF document cleanup');
56
63
 
57
-
58
- // Setup output stream with error handling for files in use
59
64
  let finalOutputPath = outputPath;
60
65
  let outputStream;
61
-
66
+
62
67
  try {
63
68
  outputStream = fs.createWriteStream(outputPath);
64
69
  this.doc.pipe(outputStream);
65
-
66
70
  ErrorHandler.registerCleanup(() => {
67
- if (outputStream && !outputStream.destroyed) {
68
- outputStream.destroy();
69
- }
71
+ if (outputStream && !outputStream.destroyed) outputStream.destroy();
70
72
  }, 'PDF output stream cleanup');
71
73
  } catch (error) {
72
74
  if (error.code === 'EBUSY' || error.code === 'EACCES') {
@@ -79,331 +81,1243 @@ export class PDFGenerator {
79
81
  }
80
82
  }
81
83
 
82
- // PDFKit handles positioning automatically
83
-
84
84
  try {
85
- // Generate the three main sections
86
- await this.generateTitleSection(selectedExtensions, projectName);
87
- await this.generateFileStructureSection(filesByExtension, selectedExtensions);
88
- await this.generateFileContentSection(filesByExtension, selectedExtensions);
85
+ // Register fonts and set base styles
86
+ this.themeRenderer.applyBaseTheme(this.doc);
87
+ this.t = this.themeRenderer.tokens;
88
+
89
+ // Page decoration — runs on every new page
90
+ this._isCoverPage = true;
91
+ this.doc.on('pageAdded', () => {
92
+ this._pageCount++;
93
+ this._isCoverPage = false;
94
+ this.applyPageDecor();
95
+ });
96
+ // Decorate the first page (cover)
97
+ this.applyPageDecor();
98
+
99
+ const includeFileTree = pdfOptions?.includeFileTree !== false;
100
+ const includeSourceCode = pdfOptions?.includeSourceCode !== false;
89
101
 
90
- // No page numbers - content only
102
+ this._totalFiles = selectedExtensions.reduce(
103
+ (s, e) => s + (filesByExtension[e]?.length || 0), 0
104
+ );
105
+
106
+ await this.generateTitleSection(selectedExtensions, projectName, pdfOptions);
107
+ await this.generateAiInsightsSection(pdfOptions);
108
+
109
+ if (includeFileTree) {
110
+ await this.generateFileStructureSection(filesByExtension, selectedExtensions);
111
+ }
112
+ if (includeSourceCode) {
113
+ await this.generateFileContentSection(filesByExtension, selectedExtensions);
114
+ }
91
115
 
92
- // Finalize document
116
+ this.renderPageNumbers();
117
+ const pageRange = this.doc.bufferedPageRange();
93
118
  this.doc.end();
94
119
 
95
- // Wait for file write to complete
96
120
  await new Promise((resolve, reject) => {
97
121
  outputStream.on('finish', resolve);
98
122
  outputStream.on('error', reject);
99
123
  });
100
124
 
101
125
  console.log(chalk.green('SUCCESS: PDF generation completed'));
102
- return { outputPath: finalOutputPath, pageCount: 'N/A' };
126
+ return { outputPath: finalOutputPath, pageCount: pageRange.count };
103
127
 
104
128
  } catch (error) {
105
129
  ErrorHandler.handlePDFError(error, finalOutputPath || outputPath);
106
130
  }
107
131
  }
108
132
 
109
- /**
110
- * Generate the title/overview section
111
- * @param {Array} selectedExtensions - Selected file extensions
112
- * @param {string} projectName - Project name
113
- */
114
- async generateTitleSection(selectedExtensions, projectName) {
115
- // Title
116
- this.doc
117
- .fontSize(20)
118
- .fillColor(this.config.styles.colors.title)
119
- .font('Helvetica-Bold')
120
- .text(this.config.settings.documentTitle, {
121
- align: 'center',
122
- width: this.getContentWidth()
123
- })
124
- .moveDown(1);
125
-
126
- // Subtitle
127
- this.doc
128
- .fontSize(14)
129
- .fillColor(this.config.styles.colors.section)
130
- .font('Helvetica')
131
- .text(`Project: ${projectName}`, {
132
- align: 'center',
133
- width: this.getContentWidth()
134
- })
135
- .moveDown(1);
136
-
137
- // Generation timestamp
133
+ // ═══════════════════════════════════════════════════════════════════════ //
134
+ // Page decoration
135
+ // ═══════════════════════════════════════════════════════════════════════ //
136
+
137
+ applyPageDecor() {
138
+ if (this._isCoverPage) {
139
+ this._drawCoverBackground();
140
+ } else {
141
+ this._drawContentPageBackground();
142
+ }
143
+ }
144
+
145
+ _drawCoverBackground() {
146
+ const w = this.pageWidth, h = this.pageHeight;
147
+ const t = this.t || this.themeRenderer.tokens;
148
+
149
+ // Full dark background
150
+ this.doc.save();
151
+ this.doc.rect(0, 0, w, h);
152
+ this.doc.fillColor(t.coverBg);
153
+ this.doc.fill();
154
+ this.doc.restore();
155
+
156
+ // Ambient background glows
157
+ this.doc.save();
158
+ this.doc.circle(w * 0.72, h * 0.42, 270);
159
+ this.doc.fillColor(t.primary, 0.055);
160
+ this.doc.fill();
161
+ this.doc.restore();
162
+
163
+ this.doc.save();
164
+ this.doc.circle(w * 0.18, h * 0.80, 200);
165
+ this.doc.fillColor(t.cyan, 0.048);
166
+ this.doc.fill();
167
+ this.doc.restore();
168
+
169
+ // Decorative circles (top-right corner, partially off-page for drama)
170
+ this.themeRenderer.drawDecorCircles(this.doc, w - 45, 72);
171
+
172
+ // Left accent bar (full height, gradient indigo)
173
+ this.themeRenderer.drawGradientRect(this.doc, 0, 3, 3, h - 31, t.primary, t.primaryDark, 14);
174
+
175
+ // Bottom accent strip
176
+ this.doc.save();
177
+ this.doc.rect(0, h - 28, w, 28);
178
+ this.doc.fillColor('#0A0C14');
179
+ this.doc.fill();
180
+ this.doc.restore();
181
+
182
+ // Multi-color gradient top edge (3px)
183
+ this.themeRenderer.drawGradientRect(this.doc, 0, 0, w, 3, t.primary, t.cyan);
184
+ }
185
+
186
+ _drawContentPageBackground() {
187
+ const t = this.t || this.themeRenderer.tokens;
188
+ const margins = this.doc.page.margins;
189
+ const left = margins.left;
190
+ const top = margins.top;
191
+ const right = this.pageWidth - margins.right;
192
+ const bottom = this.pageHeight - margins.bottom;
193
+
194
+ // Off-white page background
195
+ this.doc.save()
196
+ .roundedRect(left - 10, top - 16, (right - left) + 20, (bottom - top) + 20, 8)
197
+ .fillColor(t.pageBackground)
198
+ .fill()
199
+ .restore();
200
+
201
+ // Running page header strip
202
+ this.themeRenderer.renderPageHeader(this.doc, this._projectName, this._pageCount);
203
+ }
204
+
205
+ // ═══════════════════════════════════════════════════════════════════════ //
206
+ // Cover / title page
207
+ // ═══════════════════════════════════════════════════════════════════════ //
208
+
209
+ async generateTitleSection(selectedExtensions, projectName, pdfOptions = {}) {
210
+ const t = this.t || this.themeRenderer.tokens;
211
+ const left = this.doc.page.margins.left;
212
+ const w = this.getContentWidth();
213
+
214
+ // ── "Code Documentation" category pill ──
215
+ this.doc.y = 52;
216
+ const catLabel = 'CODE DOCUMENTATION';
217
+ const catFont = 7.5;
218
+ const catPadX = 9, catPadY = 3.5;
219
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(catFont);
220
+ const catW = this.doc.widthOfString(catLabel) + catPadX * 2;
221
+ const catH = catFont + catPadY * 2;
222
+ const catY = this.doc.y;
223
+
224
+ this.doc.save();
225
+ this.doc.roundedRect(left, catY, catW, catH, catH / 2);
226
+ this.doc.fillColor(t.primary, 0.22);
227
+ this.doc.fill();
228
+ this.doc.restore();
229
+
230
+ this.doc.save();
231
+ this.doc.roundedRect(left, catY, catW, catH, catH / 2);
232
+ this.doc.lineWidth(0.8);
233
+ this.doc.strokeColor(t.primaryMid);
234
+ this.doc.stroke();
235
+ this.doc.restore();
236
+
237
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(catFont)
238
+ .fillColor(t.primaryMid)
239
+ .text(catLabel, left + catPadX, catY + catPadY, { lineBreak: false });
240
+
241
+ this.doc.y = catY + catH + 20;
242
+
243
+ // ── Project name (large, white) ──
244
+ const title = this.config.settings.documentTitle || `${projectName} — Code Summary`;
245
+ this.doc.font(this.themeRenderer.fontNames.bold)
246
+ .fontSize(34)
247
+ .fillColor(t.coverTitle)
248
+ .text(title, left, this.doc.y, { width: w * 0.78 });
249
+
250
+ // ── Subtitle ──
251
+ this.doc.font(this.themeRenderer.fontNames.bold)
252
+ .fontSize(13)
253
+ .fillColor(t.coverSub)
254
+ .text(`Project: ${projectName}`, left, this.doc.y + 3, { width: w });
255
+ this.doc.y += 5;
256
+
257
+ // ── Timestamp ──
138
258
  const timestamp = new Date().toLocaleString();
139
- this.doc
140
- .fontSize(10)
141
- .fillColor(this.config.styles.colors.footer)
142
- .font('Helvetica')
143
- .text(`Generated on: ${timestamp}`, {
144
- width: this.getContentWidth()
145
- })
146
- .moveDown(2);
147
-
148
- // Included file types section
149
- this.doc
150
- .fontSize(16)
151
- .fillColor(this.config.styles.colors.section)
152
- .font('Helvetica-Bold')
153
- .text('Included File Types', {
154
- width: this.getContentWidth()
155
- })
156
- .moveDown(0.5);
259
+ this.doc.font(this.themeRenderer.fontNames.regular)
260
+ .fontSize(9)
261
+ .fillColor(t.coverMuted)
262
+ .text(`Generated: ${timestamp}`, left, this.doc.y + 2, { width: w });
263
+ this.doc.y += 20;
157
264
 
158
- selectedExtensions.forEach(ext => {
159
- const description = getExtensionDescription(ext);
160
- this.doc
161
- .fontSize(11)
162
- .fillColor(this.config.styles.colors.text)
163
- .font('Helvetica')
164
- .text(`- ${ext} -> ${description}`, {
165
- width: this.getContentWidth()
265
+ // ── Gradient divider ──
266
+ const divY = this.doc.y;
267
+ this.themeRenderer.drawGradientRect(this.doc, left, divY, w, 1.5, t.primary, '#1A2040');
268
+ this.doc.y = divY + 20;
269
+
270
+ // ── Metric cards (replaces tiny pills) ──
271
+ this._drawCoverMetricCards(selectedExtensions, pdfOptions);
272
+
273
+ // ── File type table (2-column, staircase fixed) ──
274
+ const extChunks = [];
275
+ for (let i = 0; i < selectedExtensions.length; i += 2) {
276
+ extChunks.push([selectedExtensions[i], selectedExtensions[i + 1]]);
277
+ }
278
+
279
+ this.doc.font(this.themeRenderer.fontNames.bold)
280
+ .fontSize(8)
281
+ .fillColor(t.coverSub)
282
+ .text('INCLUDED FILE TYPES', left, this.doc.y + 4, { lineBreak: false });
283
+ this.doc.y += 20;
284
+
285
+ const tagW = 40, colGap = 14;
286
+ const colW = w / 2 - colGap / 2;
287
+
288
+ for (const row of extChunks) {
289
+ const rowY = this.doc.y; // FIX: capture before column loop
290
+ for (let col = 0; col < 2; col++) {
291
+ const ext = row[col];
292
+ if (!ext) continue;
293
+ const desc = getExtensionDescription(ext);
294
+ const colX = left + col * (w / 2 + colGap / 2);
295
+
296
+ this.doc.save()
297
+ .roundedRect(colX, rowY, tagW, 16, 3)
298
+ .fillColor('#141830')
299
+ .fill()
300
+ .restore();
301
+ this.doc.font(this.themeRenderer.fontNames.bold)
302
+ .fontSize(7.5)
303
+ .fillColor(t.primaryMid)
304
+ .text(ext, colX + 4, rowY + 4, { width: tagW - 8, lineBreak: false });
305
+ this.doc.font(this.themeRenderer.fontNames.regular)
306
+ .fontSize(8)
307
+ .fillColor(t.coverMuted)
308
+ .text(desc || 'Source file', colX + tagW + 6, rowY + 4, {
309
+ width: colW - tagW - 8, lineBreak: false
310
+ });
311
+ }
312
+ this.doc.y = rowY + 22; // FIX: fixed advance after both columns
313
+ }
314
+
315
+ this.doc.y += 24;
316
+
317
+ // ── Feature highlights (fills lower portion of cover) ──
318
+ this._drawCoverFeatureRow();
319
+
320
+ // ── Decorative dot grid filling remaining space ──
321
+ this._drawCoverDotGrid(left, w);
322
+
323
+ // ── Bottom credit ──
324
+ const botY = this.pageHeight - 22;
325
+ this.doc.font(this.themeRenderer.fontNames.bold)
326
+ .fontSize(7.5)
327
+ .fillColor('#3A4558')
328
+ .text('CodeSummary • Code Documentation Tool', left, botY, {
329
+ width: w, align: 'center', lineBreak: false
330
+ });
331
+
332
+ this.addPageIfContentExists();
333
+ }
334
+
335
+ /** Three large metric cards on cover (file types, files, AI status). */
336
+ _drawCoverMetricCards(selectedExtensions, pdfOptions) {
337
+ const t = this.t || this.themeRenderer.tokens;
338
+ const left = this.doc.page.margins.left;
339
+ const w = this.getContentWidth();
340
+ const cardH = 64;
341
+ const gap = 10;
342
+ const count = 3;
343
+ const cardW = (w - gap * (count - 1)) / count;
344
+ const y = this.doc.y;
345
+
346
+ const aiOn = Boolean(pdfOptions?.aiProjectContext);
347
+ const metrics = [
348
+ { value: String(selectedExtensions.length), label: 'FILE TYPES', color: t.primary },
349
+ { value: String(this._totalFiles || '—'), label: 'FILES SCANNED', color: t.cyan },
350
+ { value: aiOn ? 'ON' : 'OFF', label: 'AI ENRICHED', color: aiOn ? t.green : '#3D4A62' }
351
+ ];
352
+
353
+ for (let i = 0; i < metrics.length; i++) {
354
+ const { value, label, color } = metrics[i];
355
+ const x = left + i * (cardW + gap);
356
+
357
+ // Card background
358
+ this.doc.save();
359
+ this.doc.roundedRect(x, y, cardW, cardH, 6);
360
+ this.doc.fillColor('#141728');
361
+ this.doc.fill();
362
+ this.doc.restore();
363
+
364
+ // Top accent bar
365
+ this.doc.save();
366
+ this.doc.roundedRect(x, y, cardW, 3, 2);
367
+ this.doc.fillColor(color);
368
+ this.doc.fill();
369
+ this.doc.restore();
370
+
371
+ // Big value
372
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(26)
373
+ .fillColor('#FFFFFF')
374
+ .text(value, x, y + 14, { width: cardW, align: 'center', lineBreak: false });
375
+
376
+ // Label
377
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(7.5)
378
+ .fillColor(color)
379
+ .text(label, x, y + 44, { width: cardW, align: 'center', lineBreak: false });
380
+ }
381
+
382
+ this.doc.y = y + cardH + 20;
383
+ }
384
+
385
+ /** Three feature highlight cards on the cover's lower section. */
386
+ _drawCoverFeatureRow() {
387
+ const t = this.t || this.themeRenderer.tokens;
388
+ const left = this.doc.page.margins.left;
389
+ const w = this.getContentWidth();
390
+
391
+ // Section label + divider line
392
+ const labelY = this.doc.y;
393
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(7.5)
394
+ .fillColor('#2D3748')
395
+ .text('CAPABILITIES', left, labelY, { lineBreak: false });
396
+ this.doc.save()
397
+ .moveTo(left + 70, labelY + 4)
398
+ .lineTo(left + w, labelY + 4)
399
+ .lineWidth(0.4)
400
+ .strokeColor('#1E2A3A')
401
+ .stroke()
402
+ .restore();
403
+ this.doc.y = labelY + 18;
404
+
405
+ const features = [
406
+ {
407
+ title: 'Professional PDF',
408
+ desc: 'A4 cover with AI executive brief, syntax-highlighted source & file tree',
409
+ points: ['Project stats & metrics', 'Architecture overview', 'Risk hotspot analysis'],
410
+ color: t.primary
411
+ },
412
+ {
413
+ title: 'LLM-Optimized RAG',
414
+ desc: 'Semantic chunks with byte-accurate offsets and per-chunk token estimates',
415
+ points: ['Byte-accurate offsets', 'Token count per chunk', 'Semantic boundaries'],
416
+ color: t.cyan
417
+ },
418
+ {
419
+ title: 'Graph Analysis',
420
+ desc: 'Dependency graphs, centrality scoring, hotspot detection & semantic clusters',
421
+ points: ['Dependency mapping', 'Hotspot detection', 'Semantic clusters'],
422
+ color: t.green
423
+ }
424
+ ];
425
+
426
+ const gap = 8;
427
+ const cardW = (w - gap * 2) / 3;
428
+ const cardH = 112;
429
+ const y = this.doc.y;
430
+
431
+ for (let i = 0; i < features.length; i++) {
432
+ const { title, desc, points, color } = features[i];
433
+ const x = left + i * (cardW + gap);
434
+
435
+ // Card
436
+ this.doc.save();
437
+ this.doc.roundedRect(x, y, cardW, cardH, 6);
438
+ this.doc.fillColor('#0F1322');
439
+ this.doc.fill();
440
+ this.doc.restore();
441
+
442
+ // Left accent bar
443
+ this.doc.save();
444
+ this.doc.roundedRect(x, y, 3, cardH, 2);
445
+ this.doc.fillColor(color);
446
+ this.doc.fill();
447
+ this.doc.restore();
448
+
449
+ // Color indicator dot
450
+ this.doc.save();
451
+ this.doc.circle(x + 20, y + 20, 11);
452
+ this.doc.fillColor(color, 0.18);
453
+ this.doc.fill();
454
+ this.doc.restore();
455
+ this.doc.save();
456
+ this.doc.circle(x + 20, y + 20, 5);
457
+ this.doc.fillColor(color);
458
+ this.doc.fill();
459
+ this.doc.restore();
460
+
461
+ // Title
462
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(9.5)
463
+ .fillColor('#D0D8E8')
464
+ .text(title, x + 13, y + 38, { width: cardW - 18, lineBreak: false });
465
+
466
+ // Description
467
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(7.5)
468
+ .fillColor('#5A7090')
469
+ .text(desc, x + 13, y + 52, { width: cardW - 18, lineGap: 1.5 });
470
+
471
+ // Bullet points
472
+ for (let j = 0; j < points.length; j++) {
473
+ const bulletY = y + 72 + j * 13;
474
+ this.doc.save().circle(x + 16, bulletY + 3.5, 2).fillColor(color, 0.7).fill().restore();
475
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(7)
476
+ .fillColor('#3F5470')
477
+ .text(points[j], x + 22, bulletY, { width: cardW - 28, lineBreak: false });
478
+ }
479
+ }
480
+
481
+ this.doc.y = y + cardH + 28;
482
+
483
+ // Centered divider + tagline
484
+ const tagY = this.doc.y;
485
+ this.themeRenderer.drawGradientRect(this.doc, left + w * 0.25, tagY, w * 0.5, 1.5, t.primary, t.cyan);
486
+ this.doc.y = tagY + 14;
487
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(10)
488
+ .fillColor('#304560')
489
+ .text('From code to documentation. Automatically.', left, this.doc.y, {
490
+ width: w, align: 'center', lineBreak: false
491
+ });
492
+ this.doc.y += 14;
493
+ }
494
+
495
+ /** Fills remaining cover space with a subtle dot-grid pattern. */
496
+ _drawCoverDotGrid(left, w) {
497
+ const gridTop = this.doc.y + 8;
498
+ const gridBot = this.pageHeight - 36; // above bottom strip
499
+ if (gridBot - gridTop < 20) return;
500
+
501
+ const spacingX = 16, spacingY = 13;
502
+ const cols = Math.floor(w / spacingX);
503
+ const rows = Math.floor((gridBot - gridTop) / spacingY);
504
+ const t = this.t || this.themeRenderer.tokens;
505
+ const colors = [t.primary, t.cyan, t.green, t.purple];
506
+
507
+ for (let row = 0; row < rows; row++) {
508
+ for (let col = 0; col < cols; col++) {
509
+ const dotX = left + col * spacingX + spacingX * 0.5;
510
+ const dotY = gridTop + row * spacingY + spacingY * 0.5;
511
+ const sum = row + col;
512
+ // Large bright accent at prime-ish intervals
513
+ const bigAccent = sum % 13 === 0;
514
+ const midAccent = sum % 7 === 0 && !bigAccent;
515
+ const color = bigAccent ? colors[(row * cols + col) % colors.length]
516
+ : midAccent ? '#2D4A70'
517
+ : '#182130';
518
+ const opacity = bigAccent ? 0.80 : midAccent ? 0.55 : 0.40;
519
+ const radius = bigAccent ? 1.8 : midAccent ? 1.1 : 0.7;
520
+ this.doc.save().circle(dotX, dotY, radius).fillColor(color, opacity).fill().restore();
521
+ }
522
+ }
523
+ this.doc.y = gridBot + 4;
524
+ }
525
+
526
+ /** Small key-value list on cover (used for intro context). */
527
+ _renderCoverInfoBlock(heading, items) {
528
+ const t = this.t || this.themeRenderer.tokens;
529
+ const left = this.doc.page.margins.left;
530
+ const w = this.getContentWidth();
531
+
532
+ this.doc.font(this.themeRenderer.fontNames.bold)
533
+ .fontSize(9)
534
+ .fillColor(t.coverSub)
535
+ .text(heading.toUpperCase(), left, this.doc.y)
536
+ .moveDown(0.3);
537
+
538
+ for (const { label, value } of items) {
539
+ this.ensureSpace(18);
540
+ this.doc.font(this.themeRenderer.fontNames.bold)
541
+ .fontSize(9)
542
+ .fillColor('#637598')
543
+ .text(`${label}: `, left, this.doc.y, { continued: true, width: w });
544
+ this.doc.font(this.themeRenderer.fontNames.regular)
545
+ .fillColor(t.coverMuted)
546
+ .text(value, { width: w });
547
+ }
548
+ }
549
+
550
+ // ═══════════════════════════════════════════════════════════════════════ //
551
+ // AI Insights section
552
+ // ═══════════════════════════════════════════════════════════════════════ //
553
+
554
+ async generateAiInsightsSection(pdfOptions = {}) {
555
+ const aiContext = pdfOptions?.aiProjectContext;
556
+ if (!aiContext || typeof aiContext !== 'object') return;
557
+
558
+ if (aiContext.executiveBrief) {
559
+ await this.generateExecutiveBriefSection(aiContext.executiveBrief);
560
+ return;
561
+ }
562
+
563
+ const { architecture, operations, maintenanceAndSecurity, visualizations, applicability } = aiContext;
564
+ if (!architecture && !operations && !maintenanceAndSecurity && !visualizations && !applicability) return;
565
+
566
+ this.renderSectionHeader('AI-Assisted Architecture Documentation');
567
+
568
+ if (architecture) {
569
+ this.renderSectionHeader('Architecture & Design Patterns');
570
+ this.renderListSection('Structural Patterns', architecture.structuralPatterns);
571
+ this.renderListSection('Implementation Paradigm', architecture.implementationParadigm);
572
+ this.renderListSection('Coupling Points (Potential SPOFs)', architecture.couplingPoints);
573
+ this.doc.moveDown(0.4);
574
+ }
575
+
576
+ if (operations) {
577
+ const { dataAndStateLifecycle: lc = {}, configurationAndEnvironmentStrategy: cs = {}, languageSpecificBreakdown: lb = {} } = operations;
578
+ this.renderSectionHeader('Data & State Lifecycle');
579
+ this.renderListSection('Inbound / Outbound', lc.inboundOutbound);
580
+ this.renderListSection('Critical Transformations', lc.criticalTransformations);
581
+ this.renderListSection('State Management', lc.stateManagement);
582
+ this.renderSectionHeader('Configuration & Environment Strategy');
583
+ this.renderListSection('Configuration Hierarchy', cs.configurationHierarchy);
584
+ this.renderListSection('Infrastructure Dependencies', cs.infrastructureDependencies);
585
+ this.renderSectionHeader('Language-Specific Breakdown');
586
+ this.renderListSection('Responsibility Distribution', lb.responsibilityDistribution);
587
+ this.renderListSection('Interoperability', lb.interoperability);
588
+ }
589
+
590
+ if (maintenanceAndSecurity) {
591
+ const { onboardingAndMaintenanceGuide: og = {}, securityAndComplianceSurface: sc = {} } = maintenanceAndSecurity;
592
+ this.renderSectionHeader('Onboarding & Maintenance Guide');
593
+ this.renderListSection('Complexity Hotspots', og.complexityHotspots);
594
+ this.renderListSection('Modification Guide', og.modificationGuide);
595
+ this.renderListSection('Domain Glossary', og.domainGlossary);
596
+ this.renderSectionHeader('Security & Compliance Surface');
597
+ this.renderListSection('Exposure Surface', sc.exposureSurface);
598
+ this.renderListSection('Sensitive Data Handling', sc.sensitiveDataHandling);
599
+ }
600
+
601
+ if (visualizations) {
602
+ this.renderSectionHeader('Visualisations (Text-Based)');
603
+ await this.renderMonospaceList('Semantic Directory Tree', visualizations.semanticDirectoryTree);
604
+ await this.renderMonospaceList('Dependency Text Graph', visualizations.dependencyTextGraph);
605
+ }
606
+
607
+ if (applicability?.whyItHelpsAcrossProjects) {
608
+ const by = applicability.whyItHelpsAcrossProjects;
609
+ this.renderSectionHeader('Why This Is Beneficial Across Project Types');
610
+ this.renderListSection('Infrastructure as Code', by.infrastructureAsCode);
611
+ this.renderListSection('Data Platforms', by.dataPlatforms);
612
+ this.renderListSection('Frontend Applications', by.frontendApplications);
613
+ this.renderListSection('Backend Services', by.backendServices);
614
+ this.renderListSection('Generic Transferable Value', by.genericTransferableValue);
615
+ }
616
+ }
617
+
618
+ async generateExecutiveBriefSection(executiveBrief) {
619
+ const t = this.t || this.themeRenderer.tokens;
620
+ const left = this.doc.page.margins.left;
621
+ const w = this.getContentWidth();
622
+
623
+ const summary = executiveBrief.executiveSummary || {};
624
+ const dataFlow = executiveBrief.dataFlow || {};
625
+
626
+ // ── Page banner ──
627
+ this.ensureSpace(60);
628
+ this.themeRenderer.renderPageBanner(
629
+ this.doc,
630
+ 'AI Executive Brief',
631
+ 'Auto-generated architecture analysis powered by LLM'
632
+ );
633
+
634
+ // ── Executive Summary ──
635
+ this.renderSectionHeader('Executive Summary');
636
+ if (summary.purpose) {
637
+ this.doc.font(this.themeRenderer.fontNames.regular)
638
+ .fontSize(10.5)
639
+ .fillColor(t.text)
640
+ .text(summary.purpose, left, this.doc.y, { width: w, align: 'justify', lineGap: 3 })
641
+ .moveDown(1.5);
642
+ }
643
+ this._renderKillerFeatures(summary.killerFeatures || []);
644
+ this.doc.moveDown(0.5);
645
+
646
+ // ── Architecture Blueprint ──
647
+ this.renderSectionHeader('Architecture Blueprint');
648
+ const archRows = (executiveBrief.architectureBlueprint || []).map(item => [
649
+ item.pattern || '', item.primaryModule || '', item.value || ''
650
+ ]);
651
+ await this.themeRenderer.renderZebraTable(
652
+ this.doc, 'Top Design Patterns',
653
+ ['Pattern', 'Primary Module', 'Why It Matters'],
654
+ archRows
655
+ );
656
+ this.doc.moveDown(1.0);
657
+
658
+ // ── Data Flow ──
659
+ this.renderSectionHeader('Data Flow');
660
+ if (dataFlow.primaryFlow) {
661
+ this.themeRenderer.renderFlowStrip(this.doc, this.sanitizeFlowText(dataFlow.primaryFlow));
662
+ }
663
+ this.doc.moveDown(0.3);
664
+ this.renderListSection('Key Flows', dataFlow.keyFlows || []);
665
+ this.doc.moveDown(0.5);
666
+
667
+ // ── Onboarding Quick Guide ──
668
+ this.renderSectionHeader('Onboarding Quick Guide');
669
+ const ogRows = (executiveBrief.onboardingQuickGuide || []).map(item => [
670
+ item.goal || '', item.modify || ''
671
+ ]);
672
+ await this.themeRenderer.renderZebraTable(
673
+ this.doc, 'Action Map',
674
+ ['If You Want To', 'Modify'],
675
+ ogRows
676
+ );
677
+ this.doc.moveDown(1.0);
678
+
679
+ // ── Risk & Hotspots ──
680
+ this.renderSectionHeader('Risk & Hotspots');
681
+ this.themeRenderer.renderRiskCards(this.doc, executiveBrief.riskHotspots || []);
682
+ }
683
+
684
+ /** Render killer features as numbered, colored cards in a grid. */
685
+ _renderKillerFeatures(features) {
686
+ if (!features?.length) return;
687
+ const t = this.t || this.themeRenderer.tokens;
688
+ const left = this.doc.page.margins.left;
689
+ const w = this.getContentWidth();
690
+ const cols = 2;
691
+ const colW = (w - 10) / cols;
692
+ const pX = 12, pY = 9;
693
+ const colors = [t.primary, t.cyan, t.green, t.orange, t.purple, t.red];
694
+
695
+ // Extra breathing room above the label (not a section header, so manually added)
696
+ if (this.doc.y > this.doc.page.margins.top + 10) this.doc.y += 10;
697
+ this.doc.font(this.themeRenderer.fontNames.bold)
698
+ .fontSize(this.themeRenderer.typeScale.small)
699
+ .fillColor(t.muted)
700
+ .text('KILLER FEATURES', left)
701
+ .moveDown(0.4);
702
+
703
+ let rowStartY = this.doc.y;
704
+
705
+ for (let i = 0; i < features.length; i++) {
706
+ const col = i % cols;
707
+ const x = left + col * (colW + 10);
708
+ const text = features[i];
709
+ const color = colors[i % colors.length];
710
+
711
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(9.5);
712
+ const textH = this.doc.heightOfString(text, { width: colW - pX * 2 - 26, lineGap: 1.8 });
713
+ const cardH = Math.max(textH + pY * 2, 36);
714
+
715
+ if (col === 0) {
716
+ this.ensureSpace(cardH + 6);
717
+ rowStartY = this.doc.y; // FIX: capture row baseline when starting new row
718
+ }
719
+
720
+ const y = rowStartY; // FIX: both columns in the same row share the same y
721
+
722
+ // Card background
723
+ this.doc.save();
724
+ this.doc.roundedRect(x, y, colW, cardH, 5);
725
+ this.doc.fillColor(t.cardBackground);
726
+ this.doc.fill();
727
+ this.doc.restore();
728
+
729
+ // Left accent bar
730
+ this.doc.save();
731
+ this.doc.roundedRect(x, y, 3, cardH, 2);
732
+ this.doc.fillColor(color);
733
+ this.doc.fill();
734
+ this.doc.restore();
735
+
736
+ // Number badge — circle + centered number
737
+ const circCX = x + colW - 18;
738
+ const circCY = y + cardH / 2;
739
+ this.doc.save();
740
+ this.doc.circle(circCX, circCY, 11);
741
+ this.doc.fillColor(color, 0.12);
742
+ this.doc.fill();
743
+ this.doc.restore();
744
+ // Center text in circle: text area width 22, left edge = circCX - 11
745
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(9).fillColor(color)
746
+ .text(`${i + 1}`, circCX - 11, circCY - 4.5, {
747
+ width: 22, align: 'center', lineBreak: false
166
748
  });
167
- });
168
-
169
- this.doc.moveDown(2);
749
+
750
+ // Feature text
751
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(9.5).fillColor(t.text)
752
+ .text(text, x + pX + 3, y + pY, { width: colW - pX * 2 - 26, lineGap: 1.8 });
753
+
754
+ if (col === cols - 1 || i === features.length - 1) {
755
+ this.doc.y = rowStartY + cardH + 8; // FIX: advance from saved row baseline
756
+ }
757
+ }
758
+ this.doc.moveDown(0.7);
759
+ }
760
+
761
+ // ═══════════════════════════════════════════════════════════════════════ //
762
+ // File structure section
763
+ // ═══════════════════════════════════════════════════════════════════════ //
764
+
765
+ /** Build an ASCII tree from flat relative paths. */
766
+ _buildFileTree(relativePaths) {
767
+ // Build nested tree object
768
+ const tree = {};
769
+ for (const p of relativePaths) {
770
+ const parts = p.replace(/\\/g, '/').split('/').filter(Boolean);
771
+ let node = tree;
772
+ for (const part of parts) {
773
+ if (!node[part]) node[part] = {};
774
+ node = node[part];
775
+ }
776
+ }
777
+
778
+ // Render tree to lines
779
+ const lines = [];
780
+ const renderNode = (node, prefix) => {
781
+ const entries = Object.entries(node);
782
+ // Directories first, then files, alphabetical within each group
783
+ entries.sort(([aKey, aVal], [bKey, bVal]) => {
784
+ const aDir = Object.keys(aVal).length > 0;
785
+ const bDir = Object.keys(bVal).length > 0;
786
+ if (aDir && !bDir) return -1;
787
+ if (!aDir && bDir) return 1;
788
+ return aKey.localeCompare(bKey);
789
+ });
790
+ for (let i = 0; i < entries.length; i++) {
791
+ const [name, children] = entries[i];
792
+ const isLast = i === entries.length - 1;
793
+ const hasChildren = Object.keys(children).length > 0;
794
+ const connector = isLast ? '`-- ' : '+-- ';
795
+ const childPfx = prefix + (isLast ? ' ' : '| ');
796
+ lines.push(prefix + connector + name + (hasChildren ? '/' : ''));
797
+ if (hasChildren) renderNode(children, childPfx);
798
+ }
799
+ };
800
+ renderNode(tree, '');
801
+ return lines.join('\n');
170
802
  }
171
803
 
172
- /**
173
- * Generate the file structure section
174
- * @param {object} filesByExtension - Files grouped by extension
175
- * @param {Array} selectedExtensions - Selected extensions
176
- */
177
804
  async generateFileStructureSection(filesByExtension, selectedExtensions) {
178
- // Section header
179
- this.doc
180
- .fontSize(16)
181
- .fillColor(this.config.styles.colors.section)
182
- .font('Helvetica-Bold')
183
- .text('Project File Structure', {
184
- width: this.getContentWidth()
185
- })
186
- .moveDown(1);
187
-
188
- // Collect all files from selected extensions
189
805
  const allFiles = [];
190
806
  selectedExtensions.forEach(ext => {
191
- if (filesByExtension[ext]) {
192
- allFiles.push(...filesByExtension[ext]);
193
- }
807
+ if (filesByExtension[ext]) allFiles.push(...filesByExtension[ext]);
194
808
  });
809
+ if (allFiles.length === 0) return;
810
+
811
+ // Only force a new page if a substantial amount of content already sits on this page.
812
+ // A single trailing card (doc.y not far below margins.top) can share the page.
813
+ const minContentY = this.doc.page.margins.top + 120;
814
+ if (this.doc.y > minContentY) this.doc.addPage();
815
+ this.renderSectionHeader('Project File Structure');
195
816
 
196
- // Sort files by path
197
817
  allFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
818
+ const treeContent = this._buildFileTree(allFiles.map(f => f.relativePath));
819
+ await this.addCodeContent(treeContent, { compact: true, filename: 'repository root' });
820
+
821
+ // Footer summary
822
+ const t2 = this.t || this.themeRenderer.tokens;
823
+ const left = this.doc.page.margins.left;
824
+ const fw = this.getContentWidth();
825
+ this.doc.moveDown(0.6);
826
+ this.themeRenderer.drawDivider(this.doc, this.doc.y);
827
+ this.doc.y += 8;
828
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(9)
829
+ .fillColor(t2.muted)
830
+ .text(
831
+ `${allFiles.length} files · ${selectedExtensions.length} file types · ${selectedExtensions.join(', ')}`,
832
+ left, this.doc.y, { width: fw, align: 'center', lineBreak: false }
833
+ );
834
+ this.doc.moveDown(0.4);
835
+ }
836
+
837
+ // ═══════════════════════════════════════════════════════════════════════ //
838
+ // File content section
839
+ // ═══════════════════════════════════════════════════════════════════════ //
198
840
 
199
- // Add file list as continuous text
200
- const fileList = allFiles.map(file => file.relativePath).join('\n');
201
-
202
- this.doc
203
- .fontSize(10)
204
- .fillColor(this.config.styles.colors.text)
205
- .font('Courier')
206
- .text(fileList, {
207
- width: this.getContentWidth()
208
- })
209
- .moveDown(2);
210
- }
211
-
212
- /**
213
- * Generate the file content section
214
- * @param {object} filesByExtension - Files grouped by extension
215
- * @param {Array} selectedExtensions - Selected extensions
216
- */
217
841
  async generateFileContentSection(filesByExtension, selectedExtensions) {
218
- // Section header
219
- this.doc
220
- .fontSize(16)
221
- .fillColor(this.config.styles.colors.section)
222
- .font('Helvetica-Bold')
223
- .text('Project File Content', {
224
- width: this.getContentWidth()
225
- })
226
- .moveDown(2);
227
-
228
- for (const extension of selectedExtensions) {
229
- if (!filesByExtension[extension]) continue;
230
-
231
- const files = filesByExtension[extension];
232
-
233
- for (const file of files) {
234
- await this.addFileContent(file);
235
- }
842
+ const filesToRender = [];
843
+ for (const ext of selectedExtensions) {
844
+ if (!filesByExtension[ext]) continue;
845
+ filesToRender.push(...filesByExtension[ext]);
846
+ }
847
+ if (filesToRender.length === 0) return;
848
+
849
+ const minContentY2 = this.doc.page.margins.top + 120;
850
+ if (this.doc.y > minContentY2) this.doc.addPage();
851
+ this.renderSectionHeader('Project File Content');
852
+ this.doc.moveDown(0.5);
853
+
854
+ for (const file of filesToRender) {
855
+ await this.addFileContent(file);
236
856
  }
237
857
  }
238
858
 
239
- /**
240
- * Add content of a single file to the PDF
241
- * @param {object} file - File object with path and metadata
242
- */
243
859
  async addFileContent(file) {
244
- // Add file header
245
- this.addFileHeader(file.relativePath);
246
-
247
860
  try {
248
- // Read and validate file content
249
861
  const contentBuffer = await fs.readFile(file.absolutePath);
250
-
251
- // Validate content before processing
252
862
  if (!ErrorHandler.validateFileContent(file.relativePath, contentBuffer)) {
253
863
  this.addErrorContent('File appears to contain binary data');
254
864
  return;
255
865
  }
256
-
866
+
257
867
  const content = contentBuffer.toString('utf8');
258
-
259
- // Memory and size limits for large files
260
- const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
261
- const MAX_LINES = 10000; // Maximum lines per file
262
- const MAX_LINE_LENGTH = 2000; // Maximum characters per line
263
-
868
+ const MAX_FILE_SIZE = 50 * 1024 * 1024;
869
+ const MAX_LINES = 10000;
870
+ const MAX_LINE_LEN = 2000;
871
+
264
872
  if (contentBuffer.length > MAX_FILE_SIZE) {
265
873
  this.addErrorContent(`File too large (${Math.round(contentBuffer.length / 1024 / 1024)}MB). Limit: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
266
874
  return;
267
875
  }
268
-
876
+
269
877
  const lines = content.split('\n');
270
878
  let processedContent = content;
271
879
  let wasModified = false;
272
-
273
- // Limit number of lines
880
+
274
881
  if (lines.length > MAX_LINES) {
275
- const truncatedLines = lines.slice(0, MAX_LINES);
276
- truncatedLines.push(`\n... [TRUNCATED: ${lines.length - MAX_LINES} more lines] ...`);
277
- processedContent = truncatedLines.join('\n');
882
+ const truncated = lines.slice(0, MAX_LINES);
883
+ truncated.push(`\n... [TRUNCATED: ${lines.length - MAX_LINES} more lines] ...`);
884
+ processedContent = truncated.join('\n');
278
885
  wasModified = true;
279
886
  console.log(chalk.yellow(`WARNING: File truncated from ${lines.length} to ${MAX_LINES} lines: ${file.relativePath}`));
280
887
  }
281
-
282
- // Limit line length
888
+
283
889
  const limitedLines = processedContent.split('\n').map(line => {
284
- if (line.length > MAX_LINE_LENGTH) {
285
- wasModified = true;
286
- return line.substring(0, MAX_LINE_LENGTH) + '... [TRUNCATED]';
287
- }
890
+ if (line.length > MAX_LINE_LEN) { wasModified = true; return line.substring(0, MAX_LINE_LEN) + '... [TRUNCATED]'; }
288
891
  return line;
289
892
  });
290
-
291
- if (wasModified) {
292
- processedContent = limitedLines.join('\n');
293
- }
294
-
295
- // Log processing info for large files
893
+ if (wasModified) processedContent = limitedLines.join('\n');
894
+
296
895
  if (lines.length > 1000 || contentBuffer.length > 1024 * 1024) {
297
896
  console.log(chalk.gray(`Processing large file: ${file.relativePath} (${lines.length} lines, ${Math.round(contentBuffer.length / 1024)}KB)`));
298
897
  }
299
-
300
- await this.addCodeContent(processedContent);
301
-
898
+
899
+ // Ensure space for header + at least the first 15 lines of code (or the whole file if small)
900
+ // This prevents the header from being orphaned on the previous page
901
+ const minLinesToStart = Math.min(processedContent.split('\n').length, 15);
902
+ const firstChunkLines = processedContent.split('\n').slice(0, minLinesToStart);
903
+ this.doc.font(this.themeRenderer.fontNames.mono).fontSize(9);
904
+ const h1 = this.doc.heightOfString('X', {});
905
+ const h2 = this.doc.heightOfString('X\nX', { lineGap: 1.8 });
906
+ const firstChunkH = (firstChunkLines.length - 1) * (h2 - h1) + h1 + 16; // text + padding
907
+ this.ensureSpace(42 + firstChunkH + 10);
908
+
909
+ await this.addCodeContent(processedContent, { filename: file.relativePath });
910
+
302
911
  } catch (error) {
303
- // Handle unreadable files with appropriate error messages
304
- let errorMessage = 'Could not read file';
305
-
306
- if (error.code === 'EACCES') {
307
- errorMessage = 'Permission denied';
308
- } else if (error.code === 'ENOENT') {
309
- errorMessage = 'File not found';
310
- } else if (error.code === 'EISDIR') {
311
- errorMessage = 'Path is a directory';
312
- } else if (error.message) {
313
- errorMessage = error.message;
314
- }
315
-
316
- this.addErrorContent(errorMessage);
912
+ const msg =
913
+ error.code === 'EACCES' ? 'Permission denied' :
914
+ error.code === 'ENOENT' ? 'File not found' :
915
+ error.code === 'EISDIR' ? 'Path is a directory' :
916
+ error.message || 'Could not read file';
917
+ this.addErrorContent(msg);
317
918
  }
318
919
  }
319
920
 
320
- /**
321
- * Add file header
322
- * @param {string} filePath - Relative file path
323
- */
921
+ // ═══════════════════════════════════════════════════════════════════════ //
922
+ // File header (used in source code section)
923
+ // ═══════════════════════════════════════════════════════════════════════ //
924
+
324
925
  addFileHeader(filePath) {
325
- this.doc
326
- .moveDown(1)
327
- .fontSize(12)
328
- .fillColor(this.config.styles.colors.section)
329
- .font('Helvetica-Bold')
330
- .text(`File: ${filePath}`, {
331
- width: this.getContentWidth()
332
- })
333
- .moveDown(0.5);
926
+ this.ensureSpace(30);
927
+ const t = this.t || this.themeRenderer.tokens;
928
+ const x = this.doc.page.margins.left;
929
+ const topM = this.doc.page.margins.top;
930
+ const y = Math.max(this.doc.y, topM);
931
+ const w = this.getContentWidth();
932
+
933
+ // Header tab — same dark as code background
934
+ this.doc.save()
935
+ .roundedRect(x, y, w, 22, 5)
936
+ .fillColor(t.codeTabBg)
937
+ .fill()
938
+ .restore();
939
+
940
+ // Decorative 3-dot traffic lights
941
+ const dotY = y + 11;
942
+ const dots = [{ x: x + 10, color: '#FF5F57' }, { x: x + 22, color: '#FEBC2E' }, { x: x + 34, color: '#28C840' }];
943
+ for (const d of dots) {
944
+ this.doc.save().circle(d.x, dotY, 3.5).fillColor(d.color).fill().restore();
945
+ }
946
+
947
+ // Language badge (right side)
948
+ const badge = this._langBadge(filePath);
949
+ const bFs = 7;
950
+ const bPadX = 6, bPadY = 2.5;
951
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(bFs);
952
+ const bTextW = this.doc.widthOfString(badge.label);
953
+ const bW = bTextW + bPadX * 2;
954
+ const bH = bFs + bPadY * 2;
955
+ const bX = x + w - bW - 6;
956
+ const bY = y + (22 - bH) / 2;
957
+ this.doc.save().roundedRect(bX, bY, bW, bH, 3).fillColor(badge.color).fill().restore();
958
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(bFs)
959
+ .fillColor(badge.text)
960
+ .text(badge.label, bX + bPadX, bY + bPadY, { lineBreak: false });
961
+
962
+ // File path text (leave room for badge)
963
+ this.doc.font(this.themeRenderer.fontNames.mono)
964
+ .fontSize(8.5)
965
+ .fillColor(t.codeTabText)
966
+ .text(filePath, x + 48, y + 6.5, { width: w - bW - 58, lineBreak: false });
967
+
968
+ this.doc.y = y + 22;
334
969
  }
335
970
 
336
- /**
337
- * Add code content with proper formatting
338
- * @param {string} content - File content
339
- */
340
- async addCodeContent(content) {
341
- // Clean and prepare content
342
- const cleanContent = this.prepareContent(content);
343
-
344
- this.doc
345
- .fontSize(9)
346
- .fillColor(this.config.styles.colors.text)
347
- .font('Courier')
348
- .text(cleanContent, {
349
- width: this.getContentWidth(),
350
- align: 'left'
351
- });
352
-
353
- // Add some spacing after code block
354
- this.doc.moveDown(1);
971
+ /** Returns { label, color, text } for a file extension badge. */
972
+ _langBadge(filePath) {
973
+ const ext = path.extname(filePath).toLowerCase().slice(1);
974
+ const MAP = {
975
+ js: { label: 'JS', color: '#F7DF1E', text: '#1A1A1A' },
976
+ mjs: { label: 'MJS', color: '#F7DF1E', text: '#1A1A1A' },
977
+ cjs: { label: 'CJS', color: '#F7DF1E', text: '#1A1A1A' },
978
+ ts: { label: 'TS', color: '#3178C6', text: '#FFFFFF' },
979
+ tsx: { label: 'TSX', color: '#61DAFB', text: '#1A1A1A' },
980
+ jsx: { label: 'JSX', color: '#61DAFB', text: '#1A1A1A' },
981
+ py: { label: 'PY', color: '#3776AB', text: '#FFFFFF' },
982
+ json: { label: 'JSON', color: '#F59E0B', text: '#1A1A1A' },
983
+ md: { label: 'MD', color: '#6B7280', text: '#FFFFFF' },
984
+ yaml: { label: 'YAML', color: '#CB171E', text: '#FFFFFF' },
985
+ yml: { label: 'YML', color: '#CB171E', text: '#FFFFFF' },
986
+ toml: { label: 'TOML', color: '#9C4121', text: '#FFFFFF' },
987
+ css: { label: 'CSS', color: '#1572B6', text: '#FFFFFF' },
988
+ scss: { label: 'SCSS', color: '#CC6699', text: '#FFFFFF' },
989
+ html: { label: 'HTML', color: '#E34F26', text: '#FFFFFF' },
990
+ rs: { label: 'RS', color: '#F74C00', text: '#FFFFFF' },
991
+ go: { label: 'GO', color: '#00ADD8', text: '#FFFFFF' },
992
+ java: { label: 'JAVA', color: '#ED8B00', text: '#FFFFFF' },
993
+ kt: { label: 'KT', color: '#7F52FF', text: '#FFFFFF' },
994
+ swift: { label: 'SWIFT', color: '#F05138', text: '#FFFFFF' },
995
+ rb: { label: 'RB', color: '#CC342D', text: '#FFFFFF' },
996
+ php: { label: 'PHP', color: '#777BB4', text: '#FFFFFF' },
997
+ sh: { label: 'SH', color: '#4EAA25', text: '#FFFFFF' },
998
+ sql: { label: 'SQL', color: '#336791', text: '#FFFFFF' },
999
+ graphql:{ label: 'GQL', color: '#E10098', text: '#FFFFFF' },
1000
+ vue: { label: 'VUE', color: '#4FC08D', text: '#FFFFFF' },
1001
+ svelte: { label: 'SVELTE', color: '#FF3E00', text: '#FFFFFF' },
1002
+ dart: { label: 'DART', color: '#00B4AB', text: '#FFFFFF' },
1003
+ c: { label: 'C', color: '#A8B9CC', text: '#1A1A1A' },
1004
+ cpp: { label: 'C++', color: '#00599C', text: '#FFFFFF' },
1005
+ cs: { label: 'C#', color: '#178600', text: '#FFFFFF' },
1006
+ };
1007
+ return MAP[ext] || { label: ext.toUpperCase() || 'TXT', color: '#4A5568', text: '#FFFFFF' };
1008
+ }
1009
+
1010
+ // ═══════════════════════════════════════════════════════════════════════ //
1011
+ // Code content block
1012
+ // ═══════════════════════════════════════════════════════════════════════ //
1013
+
1014
+ // Draw a rectangle with selective corner rounding (top/bottom independently).
1015
+ // Returns doc for chaining (e.g. .clip()).
1016
+ _selectiveRoundRect(x, y, w, h, r, roundTop, roundBottom) {
1017
+ const doc = this.doc;
1018
+ const k = 0.5523; // bezier approximation constant for 90° circular arc
1019
+ if (roundTop && roundBottom) {
1020
+ doc.roundedRect(x, y, w, h, r);
1021
+ } else if (!roundTop && !roundBottom) {
1022
+ doc.rect(x, y, w, h);
1023
+ } else if (roundTop) {
1024
+ // Rounded top corners, flat bottom
1025
+ doc.moveTo(x + r, y)
1026
+ .lineTo(x + w - r, y)
1027
+ .bezierCurveTo(x + w - r * (1 - k), y, x + w, y + r * (1 - k), x + w, y + r)
1028
+ .lineTo(x + w, y + h)
1029
+ .lineTo(x, y + h)
1030
+ .lineTo(x, y + r)
1031
+ .bezierCurveTo(x, y + r * (1 - k), x + r * (1 - k), y, x + r, y)
1032
+ .closePath();
1033
+ } else {
1034
+ // Flat top, rounded bottom corners
1035
+ doc.moveTo(x, y)
1036
+ .lineTo(x + w, y)
1037
+ .lineTo(x + w, y + h - r)
1038
+ .bezierCurveTo(x + w, y + h - r * (1 - k), x + w - r * (1 - k), y + h, x + w - r, y + h)
1039
+ .lineTo(x + r, y + h)
1040
+ .bezierCurveTo(x + r * (1 - k), y + h, x, y + h - r * (1 - k), x, y + h - r)
1041
+ .lineTo(x, y)
1042
+ .closePath();
1043
+ }
1044
+ return doc;
1045
+ }
1046
+
1047
+ async addCodeContent(content, options = {}) {
1048
+ const t = this.t || this.themeRenderer.tokens;
1049
+ const clean = this.prepareContent(content);
1050
+ const allLines = clean.split('\n');
1051
+ const compact = options.compact || false;
1052
+ const lineStart = options.lineStart || 1;
1053
+ const showHeader = Boolean(options.filename) && !compact;
1054
+ const CHUNK_LIMIT = compact ? 120 : 100;
1055
+ const GUTTER_W = compact ? 0 : 50;
1056
+ const fontSize = compact ? 8.5 : 9;
1057
+ const LINE_GAP = 1.8;
1058
+ const RADIUS = 5;
1059
+
1060
+ // Pre-compute metrics
1061
+ this.doc.font(this.themeRenderer.fontNames.mono).fontSize(fontSize);
1062
+ const _h1 = this.doc.heightOfString('X', {});
1063
+ const _h2 = this.doc.heightOfString('X\nX', { lineGap: LINE_GAP });
1064
+ const lineStride = _h2 - _h1;
1065
+
1066
+ if (showHeader) {
1067
+ // First chunk check: header (22) + padding (16) + 10 lines + buffer
1068
+ const minFirstChunkH = 22 + (9 * lineStride + _h1) + 16 + 10;
1069
+ this.ensureSpace(minFirstChunkH);
1070
+ this.addFileHeader(options.filename);
1071
+ }
1072
+
1073
+ const totalLines = allLines.length;
1074
+ let isFirstChunk = true;
1075
+
1076
+ for (let start = 0; start < allLines.length;) {
1077
+ const footerReserve = 32;
1078
+ const bottomLimit = this.doc.page.height - this.doc.page.margins.bottom - footerReserve;
1079
+ const padX = 12, padY = 8;
1080
+
1081
+ // If we don't even have room for 5 lines of code (plus header buffer), skip to next page
1082
+ // For the last few lines, allow a smaller minimum height so they
1083
+ // don't get orphaned onto a nearly-empty next page unnecessarily.
1084
+ const remainingLines = allLines.length - start;
1085
+ const minH = remainingLines <= 4
1086
+ ? padY * 2 + _h1 + Math.max(0, remainingLines - 1) * lineStride
1087
+ : 80;
1088
+ if (this.doc.y + minH > bottomLimit) this.doc.addPage();
1089
+
1090
+ const y = this.doc.y;
1091
+ const availH = bottomLimit - y;
1092
+ const x = this.doc.page.margins.left;
1093
+ const w = this.getContentWidth();
1094
+ const codeX = x + GUTTER_W + padX;
1095
+ const codeW = w - GUTTER_W - padX * 2;
1096
+ const txtOpts = { width: codeW, align: 'left', lineGap: LINE_GAP };
1097
+
1098
+ // Determine how many lines fit on this page (initial estimate)
1099
+ let n = Math.floor((availH - padY * 2 - _h1) / lineStride) + 1;
1100
+ n = Math.max(1, Math.min(n, CHUNK_LIMIT, allLines.length - start));
1101
+
1102
+ let chunkLines = allLines.slice(start, start + n);
1103
+ let chunk = chunkLines.join('\n');
1104
+
1105
+ // Calculate real height (handles wrapping accurately)
1106
+ let textH = this.doc.heightOfString(chunk, txtOpts);
1107
+ let boxH = textH + padY * 2;
1108
+
1109
+ // Final overflow guard: if box exceeds availH (likely due to wrapping), back off lines
1110
+ while (boxH > availH && chunkLines.length > 1) {
1111
+ chunkLines = chunkLines.slice(0, chunkLines.length - 1);
1112
+ chunk = chunkLines.join('\n');
1113
+ textH = this.doc.heightOfString(chunk, txtOpts);
1114
+ boxH = textH + padY * 2;
1115
+ }
1116
+
1117
+ const isLastChunk = start + chunkLines.length >= totalLines;
1118
+ const roundTop = isFirstChunk;
1119
+ const roundBottom = isLastChunk;
1120
+
1121
+ // ── Backgrounds ──
1122
+ this.doc.save();
1123
+ this._selectiveRoundRect(x, y, w, boxH, RADIUS, roundTop, roundBottom).clip();
1124
+ this.doc.rect(x, y, w, boxH).fillColor(t.codeBg).fill();
1125
+ if (GUTTER_W > 0) {
1126
+ this.doc.rect(x, y, GUTTER_W, boxH).fillColor(t.codeGutter).fill();
1127
+ this.doc.save().moveTo(x + GUTTER_W, y).lineTo(x + GUTTER_W, y + boxH)
1128
+ .lineWidth(0.3).strokeColor(t.codeBorder).stroke().restore();
1129
+ }
1130
+ this.doc.restore();
1131
+
1132
+ // ── Text ──
1133
+ this.doc.font(this.themeRenderer.fontNames.mono).fontSize(fontSize).fillColor(t.codeFg)
1134
+ .text(chunk, codeX, y + padY, { ...txtOpts, height: textH + 2 });
1135
+
1136
+ if (GUTTER_W > 0) {
1137
+ // Build line number block
1138
+ const nums = [];
1139
+ for (let i = 0; i < chunkLines.length; i++) nums.push(String(start + i + lineStart));
1140
+
1141
+ this.doc.font(this.themeRenderer.fontNames.mono).fontSize(fontSize - 1).fillColor(t.codeGutterText)
1142
+ .text(nums.join('\n'), x + 2, y + padY, {
1143
+ width: GUTTER_W - 8,
1144
+ align: 'right',
1145
+ lineGap: lineStride - (fontSize - 1) * 1.15 // heuristic for mono leading
1146
+ });
1147
+ }
1148
+
1149
+ // No vertical gap between continuation chunks — seamless split visual
1150
+ this.doc.y = y + boxH + (isLastChunk ? 4 : 0);
1151
+ start += chunkLines.length;
1152
+ isFirstChunk = false;
1153
+ }
1154
+
1155
+ // Bottom accent line
1156
+ const bX = this.doc.page.margins.left;
1157
+ const bW = this.getContentWidth();
1158
+ this.doc.save()
1159
+ .moveTo(bX, this.doc.y - 4).lineTo(bX + bW, this.doc.y - 4)
1160
+ .lineWidth(0.4).strokeColor(t.codeBorder).stroke()
1161
+ .restore();
1162
+
1163
+ this.doc.x = this.doc.page.margins.left;
1164
+ this.doc.moveDown(0.2);
355
1165
  }
356
1166
 
357
- /**
358
- * Add error content for unreadable files
359
- * @param {string} errorMessage - Error message to display
360
- */
361
1167
  addErrorContent(errorMessage) {
362
- this.doc
363
- .fontSize(10)
364
- .fillColor(this.config.styles.colors.error)
365
- .font('Helvetica-Oblique')
366
- .text(`ERROR: ${errorMessage}`, {
367
- width: this.getContentWidth()
368
- })
1168
+ this.ensureSpace(28);
1169
+ const t = this.t || this.themeRenderer.tokens;
1170
+ this.doc.fontSize(10)
1171
+ .fillColor(t.red)
1172
+ .font(this.themeRenderer.fontNames.regular)
1173
+ .text(`ERROR: ${errorMessage}`, { width: this.getContentWidth() })
369
1174
  .moveDown(0.5);
370
1175
  }
371
1176
 
372
- /**
373
- * Prepare content for PDF rendering
374
- * @param {string} content - Raw file content
375
- * @returns {string} Cleaned content
376
- */
1177
+ // ═══════════════════════════════════════════════════════════════════════ //
1178
+ // Utilities
1179
+ // ═══════════════════════════════════════════════════════════════════════ //
1180
+
377
1181
  prepareContent(content) {
378
1182
  return content
379
- .replace(/\r\n/g, '\n') // Normalize line endings
380
- .replace(/\r/g, '\n') // Handle old Mac line endings
381
- .replace(/\t/g, ' ') // Replace tabs with 4 spaces for better alignment
382
- .replace(/[^\x20-\x7E\n\u00A0-\uFFFF]/g, ''); // Remove control chars, keep Unicode text
1183
+ .replace(/\r\n/g, '\n')
1184
+ .replace(/\r/g, '\n')
1185
+ .replace(/\t/g, ' ')
1186
+ .replace(/[^\x20-\x7E\n\u00A0-\uFFFF]/g, '');
1187
+ }
1188
+
1189
+ sanitizeFlowText(flowRaw) {
1190
+ if (typeof flowRaw !== 'string') return '';
1191
+ return flowRaw
1192
+ .replace(/[→➜➝➞➟➠]/g, '->')
1193
+ .replace(/âž"/g, '->')
1194
+ .split(/\s*->\s*/g)
1195
+ .map(p => p.trim())
1196
+ .filter(Boolean)
1197
+ .join(' -> ');
1198
+ }
1199
+
1200
+ renderSectionHeader(title) {
1201
+ // Breathing room between sections — only when content is already on the page
1202
+ if (this.doc.y > this.doc.page.margins.top + 10) this.doc.y += 18;
1203
+ this.ensureSpace(52);
1204
+ this.themeRenderer.renderSectionHeader(this.doc, title);
1205
+ }
1206
+
1207
+ renderInfoRow(label, value) {
1208
+ this.ensureSpace(18);
1209
+ const t = this.t || this.themeRenderer.tokens;
1210
+ this.doc
1211
+ .font(this.themeRenderer.fontNames.bold).fontSize(10.5).fillColor(t.textSecond)
1212
+ .text(label, { continued: true, width: this.getContentWidth() })
1213
+ .font(this.themeRenderer.fontNames.regular).fillColor(t.text)
1214
+ .text(` ${value}`, { width: this.getContentWidth() });
1215
+ }
1216
+
1217
+ renderInfoParagraph(label, value) {
1218
+ if (!value) return;
1219
+ this.ensureSpace(26);
1220
+ const t = this.t || this.themeRenderer.tokens;
1221
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(10.5).fillColor(t.textSecond)
1222
+ .text(`${label}:`, { width: this.getContentWidth() });
1223
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(10.3).fillColor(t.text)
1224
+ .text(value, { width: this.getContentWidth(), lineGap: 1.5 })
1225
+ .moveDown(0.3);
383
1226
  }
384
1227
 
385
- // All text methods removed - using direct PDFKit calls for simplicity
1228
+ renderListSection(label, items = []) {
1229
+ if (!Array.isArray(items) || items.length === 0) return;
1230
+ const t = this.t || this.themeRenderer.tokens;
1231
+ this.ensureSpace(24);
1232
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(10.5).fillColor(t.textSecond)
1233
+ .text(`${label}:`, { width: this.getContentWidth() });
386
1234
 
387
- // Page numbering removed to prevent duplicate pages
1235
+ for (const item of items) {
1236
+ this.ensureSpace(16);
1237
+ const dotX = this.doc.page.margins.left;
1238
+ const dotY = this.doc.y + 4;
1239
+ this.doc.save().circle(dotX + 4, dotY, 2).fillColor(t.primaryMid).fill().restore();
1240
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(10).fillColor(t.text)
1241
+ .text(item, dotX + 12, this.doc.y, { width: this.getContentWidth() - 12, lineGap: 1.5 });
1242
+ }
1243
+ this.doc.moveDown(0.35);
1244
+ }
1245
+
1246
+ async renderMonospaceList(label, items = []) {
1247
+ if (!Array.isArray(items) || items.length === 0) return;
1248
+ const t = this.t || this.themeRenderer.tokens;
1249
+ this.ensureSpace(20);
1250
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(10.5).fillColor(t.textSecond)
1251
+ .text(`${label}:`, { width: this.getContentWidth() });
1252
+ const text = items.join('\n');
1253
+ await this.addCodeContent(text, { compact: true });
1254
+ }
1255
+
1256
+ ensureSpace(minHeight) {
1257
+ // Reserve 24px above margins.bottom for the footer separator + text
1258
+ const footerReserve = 24;
1259
+ const bottomLimit = this.pageHeight - this.doc.page.margins.bottom - footerReserve;
1260
+ if (this.doc.y + minHeight >= bottomLimit) this.doc.addPage();
1261
+ }
1262
+
1263
+ addPageIfContentExists() {
1264
+ if (this.doc.y > this.doc.page.margins.top + 10) this.doc.addPage();
1265
+ }
1266
+
1267
+ renderPageNumbers() {
1268
+ const range = this.doc.bufferedPageRange();
1269
+ const t = this.themeRenderer.tokens;
1270
+
1271
+ // Collect only pages with real content.
1272
+ // Threshold: doc.y must be well above margins.top (pdfkit-table blank-overflow pages
1273
+ // may advance doc.y by ~30–50px for a table header row, so use +80 threshold).
1274
+ const contentPages = [];
1275
+ for (let i = range.start; i < range.start + range.count; i++) {
1276
+ if (i === range.start) continue; // skip cover
1277
+ this.doc.switchToPage(i);
1278
+ const threshold = this.doc.page.margins.top + 80;
1279
+ if (this.doc.y >= threshold) {
1280
+ contentPages.push(i);
1281
+ }
1282
+ }
1283
+
1284
+ const total = contentPages.length;
1285
+ for (let idx = 0; idx < total; idx++) {
1286
+ const i = contentPages[idx];
1287
+ this.doc.switchToPage(i);
1288
+ const left = this.doc.page.margins.left;
1289
+ const w = this.getContentWidth();
1290
+ // Must be inside the printable area (< page.height - margins.bottom)
1291
+ const footerY = this.doc.page.height - this.doc.page.margins.bottom - 12;
1292
+
1293
+ // Subtle separator line above footer
1294
+ this.doc.save()
1295
+ .moveTo(left, footerY - 3).lineTo(left + w, footerY - 3)
1296
+ .lineWidth(0.25).strokeColor(t.divider).stroke()
1297
+ .restore();
1298
+
1299
+ // Project name on the left
1300
+ this.doc.font(this.themeRenderer.fontNames.regular).fontSize(7.5)
1301
+ .fillColor(t.faint)
1302
+ .text(this._projectName, left, footerY, { width: w * 0.6, lineBreak: false });
1303
+
1304
+ // Page number on the right
1305
+ this.doc.font(this.themeRenderer.fontNames.bold).fontSize(7.5)
1306
+ .fillColor(t.muted)
1307
+ .text(`${idx + 1} / ${total}`, left, footerY, {
1308
+ width: w, align: 'right', lineBreak: false
1309
+ });
1310
+ }
1311
+ }
388
1312
 
389
- /**
390
- * Generate output file path with timestamp fallback if file is in use
391
- * @param {string} projectName - Project name
392
- * @param {string} outputDir - Output directory
393
- * @returns {string} Complete output file path
394
- */
395
1313
  static generateOutputPath(projectName, outputDir) {
396
1314
  const basePath = path.join(outputDir, `${projectName.toUpperCase()}_code.pdf`);
397
1315
  return resolveVersionedPath(basePath);
398
1316
  }
399
1317
 
400
- /**
401
- * Ensure output directory exists
402
- * @param {string} outputDir - Output directory path
403
- */
404
1318
  static async ensureOutputDirectory(outputDir) {
405
1319
  await fs.ensureDir(outputDir);
406
1320
  }
407
1321
  }
408
1322
 
409
- export default PDFGenerator;
1323
+ export default PDFGenerator;