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.
- package/CHANGELOG.md +26 -213
- package/README.md +61 -395
- package/features.md +25 -386
- package/package.json +13 -17
- package/src/ai/errors.js +85 -0
- package/src/ai/featureFlags.js +8 -0
- package/src/ai/promptTemplates.js +337 -0
- package/src/ai/providerClient.js +81 -0
- package/src/ai/providers/ollama.js +92 -0
- package/src/ai/providers/openaiCompatible.js +96 -0
- package/src/analysis/repositorySignals.js +196 -0
- package/src/cli.js +819 -77
- package/src/configManager.js +21 -0
- package/src/graph/adapters/baseAdapter.js +24 -0
- package/src/graph/adapters/javascriptAdapter.js +53 -0
- package/src/graph/adapters/pythonAdapter.js +77 -0
- package/src/graph/graphEngine.js +151 -0
- package/src/graph/graphMetrics.js +79 -0
- package/src/graph/graphSchema.js +30 -0
- package/src/graph/universalExtractor.js +29 -0
- package/src/llmGenerator.js +723 -8
- package/src/pdfGenerator.js +1189 -275
- package/src/renderers/llmSummaryRenderer.js +14 -0
- package/src/renderers/pdfThemeRenderer.js +685 -0
- package/src/scanner.js +115 -8
- package/rag-schema.json +0 -114
- package/src/ragConfig.js +0 -369
- package/src/ragGenerator.js +0 -1740
package/src/pdfGenerator.js
CHANGED
|
@@ -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
|
|
15
|
-
this.doc
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
44
|
-
bottom: this.config.styles.layout.marginTop,
|
|
45
|
-
left:
|
|
46
|
-
right:
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.doc
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
141
|
-
.fillColor(
|
|
142
|
-
.
|
|
143
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
processedContent =
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
921
|
+
// ═══════════════════════════════════════════════════════════════════════ //
|
|
922
|
+
// File header (used in source code section)
|
|
923
|
+
// ═══════════════════════════════════════════════════════════════════════ //
|
|
924
|
+
|
|
324
925
|
addFileHeader(filePath) {
|
|
325
|
-
this.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
.
|
|
366
|
-
.
|
|
367
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
*/
|
|
1177
|
+
// ═══════════════════════════════════════════════════════════════════════ //
|
|
1178
|
+
// Utilities
|
|
1179
|
+
// ═══════════════════════════════════════════════════════════════════════ //
|
|
1180
|
+
|
|
377
1181
|
prepareContent(content) {
|
|
378
1182
|
return content
|
|
379
|
-
.replace(/\r\n/g, '\n')
|
|
380
|
-
.replace(/\r/g, '\n')
|
|
381
|
-
.replace(/\t/g, ' ')
|
|
382
|
-
.replace(/[^\x20-\x7E\n\u00A0-\uFFFF]/g, '');
|
|
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
|
-
|
|
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
|
-
|
|
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;
|