git-repo-analyzer-test 1.0.0
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/.github/copilot-instructions.md +108 -0
- package/.idea/aianalyzer.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/API_REFERENCE.md +244 -0
- package/ENHANCEMENTS.md +282 -0
- package/README.md +179 -0
- package/USAGE.md +189 -0
- package/analysis.txt +0 -0
- package/bin/cli.js +135 -0
- package/docs/SONARCLOUD_ANALYSIS_COVERED.md +144 -0
- package/docs/SonarCloud_Presentation_Points.md +81 -0
- package/docs/UI_IMPROVEMENTS.md +117 -0
- package/package-lock_cmd.json +542 -0
- package/package.json +44 -0
- package/package_command.json +16 -0
- package/public/analysis-options.json +31 -0
- package/public/images/README.txt +2 -0
- package/public/images/rws-logo.png +0 -0
- package/public/index.html +2433 -0
- package/repositories.example.txt +17 -0
- package/sample-repos.txt +20 -0
- package/src/analyzers/accessibility.js +47 -0
- package/src/analyzers/cicd-enhanced.js +113 -0
- package/src/analyzers/codeReview-enhanced.js +599 -0
- package/src/analyzers/codeReview-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/codeReview.js +171 -0
- package/src/analyzers/codeReview.js:Zone.Identifier +3 -0
- package/src/analyzers/documentation-enhanced.js +137 -0
- package/src/analyzers/performance-enhanced.js +747 -0
- package/src/analyzers/performance-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/performance.js +211 -0
- package/src/analyzers/performance.js:Zone.Identifier +3 -0
- package/src/analyzers/performance_cmd.js +216 -0
- package/src/analyzers/quality-enhanced.js +386 -0
- package/src/analyzers/quality-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/quality.js +92 -0
- package/src/analyzers/quality.js:Zone.Identifier +3 -0
- package/src/analyzers/security-enhanced.js +512 -0
- package/src/analyzers/security-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/snyk-ai.js:Zone.Identifier +3 -0
- package/src/analyzers/sonarcloud.js +928 -0
- package/src/analyzers/vulnerability.js +185 -0
- package/src/analyzers/vulnerability.js:Zone.Identifier +3 -0
- package/src/cli.js:Zone.Identifier +3 -0
- package/src/config.js +43 -0
- package/src/core/analyzerEngine.js +68 -0
- package/src/core/reportGenerator.js +21 -0
- package/src/gemini.js +321 -0
- package/src/github/client.js +124 -0
- package/src/github/client.js:Zone.Identifier +3 -0
- package/src/index.js +93 -0
- package/src/index_cmd.js +130 -0
- package/src/openai.js +297 -0
- package/src/report/generator.js +459 -0
- package/src/report/generator_cmd.js +459 -0
- package/src/report/pdf-generator.js +387 -0
- package/src/report/pdf-generator.js:Zone.Identifier +3 -0
- package/src/server.js +431 -0
- package/src/server.js:Zone.Identifier +3 -0
- package/src/server_cmd.js +434 -0
- package/src/sonarcloud/client.js +365 -0
- package/src/sonarcloud/scanner.js +171 -0
- package/src.zip +0 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import PDFDocument from 'pdfkit';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
// Layout constants
|
|
5
|
+
const MARGIN = 48;
|
|
6
|
+
const PAGE_WIDTH = 595;
|
|
7
|
+
const PAGE_HEIGHT = 842;
|
|
8
|
+
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
|
|
9
|
+
const HEADER_BAR_HEIGHT = 32;
|
|
10
|
+
const SECTION_PAD = 14;
|
|
11
|
+
const ROW_HEIGHT = 22;
|
|
12
|
+
const PAGE_BOTTOM = PAGE_HEIGHT - MARGIN - 20; // safe bottom before footer
|
|
13
|
+
|
|
14
|
+
export class PDFReportGenerator {
|
|
15
|
+
static generatePDF(data, filename) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
try {
|
|
18
|
+
const doc = new PDFDocument({
|
|
19
|
+
margin: MARGIN,
|
|
20
|
+
size: 'A4',
|
|
21
|
+
bufferPages: true,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const stream = fs.createWriteStream(filename);
|
|
25
|
+
doc.pipe(stream);
|
|
26
|
+
|
|
27
|
+
const analysis = data?.analysis ?? {};
|
|
28
|
+
const repository = data?.repository ?? 'Unknown repository';
|
|
29
|
+
const defaultSummary = { healthStatus: 'Unavailable', overallScore100: null, overallScore: null, overallRating: null };
|
|
30
|
+
const summary = (data?.report?.summary != null && typeof data.report.summary === 'object')
|
|
31
|
+
? { ...defaultSummary, ...data.report.summary }
|
|
32
|
+
: defaultSummary;
|
|
33
|
+
|
|
34
|
+
// ----- Header: colored bar + title block -----
|
|
35
|
+
doc.rect(0, 0, PAGE_WIDTH, HEADER_BAR_HEIGHT).fill('#1e293b');
|
|
36
|
+
doc.fontSize(16).font('Helvetica-Bold').fillColor('#ffffff').text('GitHub Repository Analyzer', MARGIN, 10, { width: CONTENT_WIDTH });
|
|
37
|
+
doc.fontSize(9).font('Helvetica').fillColor('#94a3b8').text(`Repository: ${repository}`, MARGIN, 28, { width: CONTENT_WIDTH });
|
|
38
|
+
doc.y = HEADER_BAR_HEIGHT + 24;
|
|
39
|
+
|
|
40
|
+
doc.fontSize(9).fillColor('#64748b').text(`Generated: ${new Date().toLocaleString()}`, MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
41
|
+
doc.y += 28;
|
|
42
|
+
|
|
43
|
+
// ----- Overall Assessment card -----
|
|
44
|
+
const cardY = doc.y;
|
|
45
|
+
doc.rect(MARGIN, cardY, CONTENT_WIDTH, 72).fill('#f8fafc');
|
|
46
|
+
doc.rect(MARGIN, cardY, 4, 72).fill('#6366f1');
|
|
47
|
+
doc.fontSize(11).font('Helvetica-Bold').fillColor('#334155').text('Overall Assessment', MARGIN + 16, cardY + 12, { width: CONTENT_WIDTH - 20 });
|
|
48
|
+
const healthStatus = summary.healthStatus || 'Unavailable';
|
|
49
|
+
const healthColor = this.getHealthColor(healthStatus);
|
|
50
|
+
const overall100 = summary.overallScore100 != null ? summary.overallScore100 : (summary.overallScore != null ? Math.round(summary.overallScore * 10) : null);
|
|
51
|
+
doc.fontSize(22).font('Helvetica-Bold').fillColor(healthColor);
|
|
52
|
+
doc.text(overall100 != null ? `${overall100}/100` : 'Not available', MARGIN + 16, cardY + 28, { width: 120 });
|
|
53
|
+
doc.fontSize(10).font('Helvetica').fillColor('#64748b').text(healthStatus, MARGIN + 16, cardY + 52, { width: 200 });
|
|
54
|
+
doc.y = cardY + 72 + SECTION_PAD;
|
|
55
|
+
|
|
56
|
+
// ----- Score breakdown table -----
|
|
57
|
+
const scores = [];
|
|
58
|
+
if (summary.qualityScore != null) scores.push({ label: 'Code Quality', value: String(summary.qualityScore) });
|
|
59
|
+
if (summary.securityScore != null) scores.push({ label: 'Security', value: String(summary.securityScore) });
|
|
60
|
+
if (summary.collaborationScore != null) scores.push({ label: 'Collaboration', value: String(summary.collaborationScore) });
|
|
61
|
+
if (summary.performanceScore != null) scores.push({ label: 'Performance', value: String(summary.performanceScore) });
|
|
62
|
+
if (summary.sonarCloudScore != null) {
|
|
63
|
+
const sonarScoreLabel = (Array.isArray(data.sonarSelectedOptions) && data.sonarSelectedOptions.length > 0)
|
|
64
|
+
? data.sonarSelectedOptions.join(', ')
|
|
65
|
+
: 'Sonar';
|
|
66
|
+
scores.push({ label: sonarScoreLabel, value: String(Math.round(summary.sonarCloudScore * 10)) });
|
|
67
|
+
}
|
|
68
|
+
if (scores.length) {
|
|
69
|
+
this.drawTable(doc, [{ label: 'Metric', value: 'Score' }, ...scores], ['#334155', '#6366f1'], true);
|
|
70
|
+
} else {
|
|
71
|
+
doc.fontSize(10).font('Helvetica').fillColor('#64748b').text('No score breakdown available.', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
72
|
+
doc.y += ROW_HEIGHT;
|
|
73
|
+
}
|
|
74
|
+
doc.y += SECTION_PAD;
|
|
75
|
+
|
|
76
|
+
// Code Quality Section (skip when Sonar-only)
|
|
77
|
+
if (analysis.quality) {
|
|
78
|
+
this.sectionTitle(doc, 'Code Quality', '#6366f1');
|
|
79
|
+
const quality = analysis.quality;
|
|
80
|
+
const qualityMetrics = [
|
|
81
|
+
{ label: 'Stars', value: String(quality.metrics?.stars ?? 0) },
|
|
82
|
+
{ label: 'Forks', value: String(quality.metrics?.forks ?? 0) },
|
|
83
|
+
{ label: 'Watchers', value: String(quality.metrics?.watchers ?? 0) },
|
|
84
|
+
{ label: 'Open Issues', value: String(quality.metrics?.openIssues ?? '—') },
|
|
85
|
+
{ label: 'Primary Language', value: quality.metrics?.primaryLanguage || 'N/A' },
|
|
86
|
+
{ label: 'Languages Used', value: quality.metrics?.languages ?? '—' },
|
|
87
|
+
{ label: 'Days Since Update', value: String(quality.metrics?.daysInactive ?? '—') },
|
|
88
|
+
];
|
|
89
|
+
this.drawTable(doc, qualityMetrics, ['#475569', '#1e293b'], false);
|
|
90
|
+
doc.y += SECTION_PAD;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Vulnerability/Security Section
|
|
94
|
+
const vulnerability = analysis.vulnerability || analysis.security;
|
|
95
|
+
if (vulnerability) {
|
|
96
|
+
this.sectionTitle(doc, 'Security & Vulnerability', '#dc2626');
|
|
97
|
+
const riskColor = this.getRiskColor(vulnerability.riskLevel);
|
|
98
|
+
doc.fontSize(10).font('Helvetica-Bold').fillColor(riskColor).text(`Risk: ${vulnerability.riskLevel || '—'} (Score: ${vulnerability.score ?? '—'}/100)`, MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
99
|
+
doc.y += 18;
|
|
100
|
+
doc.fontSize(9).font('Helvetica-Bold').fillColor('#334155').text('Risk factors', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
101
|
+
doc.y += 14;
|
|
102
|
+
(vulnerability.riskFactors || []).forEach((factor) => {
|
|
103
|
+
doc.fontSize(9).font('Helvetica').fillColor('#475569').text(`• ${String(factor)}`, MARGIN + 8, doc.y, { width: CONTENT_WIDTH - 8 });
|
|
104
|
+
doc.y += 16;
|
|
105
|
+
});
|
|
106
|
+
doc.y += 4;
|
|
107
|
+
doc.fontSize(9).font('Helvetica-Bold').fillColor('#334155').text('Recommendations', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
108
|
+
doc.y += 14;
|
|
109
|
+
(vulnerability.recommendations || []).forEach((rec) => {
|
|
110
|
+
doc.fontSize(9).font('Helvetica').fillColor('#475569').text(`→ ${String(rec)}`, MARGIN + 8, doc.y, { width: CONTENT_WIDTH - 8 });
|
|
111
|
+
doc.y += 16;
|
|
112
|
+
});
|
|
113
|
+
doc.y += SECTION_PAD;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Code Review Section
|
|
117
|
+
if (analysis.codeReview) {
|
|
118
|
+
const codeReview = analysis.codeReview;
|
|
119
|
+
this.sectionTitle(doc, 'Code Review & Collaboration', '#2563eb');
|
|
120
|
+
const rm = codeReview.reviewMetrics || {};
|
|
121
|
+
const cm = codeReview.commitMetrics || {};
|
|
122
|
+
const reviewMetrics = [
|
|
123
|
+
{ label: 'Total Pull Requests', value: String(rm.totalPullRequests ?? '—') },
|
|
124
|
+
{ label: 'Merged PRs', value: String(rm.mergedPullRequests ?? '—') },
|
|
125
|
+
{ label: 'Open PRs', value: String(rm.openPullRequests ?? '—') },
|
|
126
|
+
{ label: 'PR Closure Rate', value: rm.prClosureRate != null ? `${rm.prClosureRate}%` : '—' },
|
|
127
|
+
{ label: 'Avg Review Time', value: rm.averageReviewTimeHours != null ? `${rm.averageReviewTimeHours} h` : '—' },
|
|
128
|
+
{ label: 'Contributors', value: String(codeReview.contributors ?? '—') },
|
|
129
|
+
{ label: 'Total Commits', value: String(cm.totalCommits ?? '—') },
|
|
130
|
+
];
|
|
131
|
+
this.drawTable(doc, reviewMetrics, ['#475569', '#1e293b'], false);
|
|
132
|
+
if ((codeReview.recommendations || []).length) {
|
|
133
|
+
doc.y += 8;
|
|
134
|
+
doc.fontSize(9).font('Helvetica-Bold').fillColor('#334155').text('Recommendations', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
135
|
+
doc.y += 14;
|
|
136
|
+
(codeReview.recommendations || []).forEach((rec) => {
|
|
137
|
+
doc.fontSize(9).font('Helvetica').fillColor('#475569').text(`→ ${String(rec)}`, MARGIN + 8, doc.y, { width: CONTENT_WIDTH - 8 });
|
|
138
|
+
doc.y += 16;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
doc.y += SECTION_PAD;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Performance Section
|
|
145
|
+
if (analysis.performance) {
|
|
146
|
+
const performance = analysis.performance;
|
|
147
|
+
this.sectionTitle(doc, 'Performance & Release', '#7c3aed');
|
|
148
|
+
const prm = performance.releaseMetrics || {};
|
|
149
|
+
const dv = performance.developmentVelocity || {};
|
|
150
|
+
const perfMetrics = [
|
|
151
|
+
{ label: 'Total Releases', value: String(prm.totalReleases ?? '—') },
|
|
152
|
+
{ label: 'Release Frequency', value: String(prm.releaseFrequency ?? '—') },
|
|
153
|
+
{ label: 'Days Between Releases', value: String(prm.averageDaysBetweenReleases ?? '—') },
|
|
154
|
+
{ label: 'Velocity Trend', value: String(dv.trend ?? '—') },
|
|
155
|
+
{ label: 'Additions/Week', value: String(dv.additionsPerWeek ?? '—') },
|
|
156
|
+
{ label: 'Deletions/Week', value: String(dv.deletionsPerWeek ?? '—') },
|
|
157
|
+
];
|
|
158
|
+
this.drawTable(doc, perfMetrics, ['#475569', '#1e293b'], false);
|
|
159
|
+
if ((performance.recommendations || []).length) {
|
|
160
|
+
doc.y += 8;
|
|
161
|
+
doc.fontSize(9).font('Helvetica-Bold').fillColor('#334155').text('Recommendations', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
162
|
+
doc.y += 14;
|
|
163
|
+
(performance.recommendations || []).forEach((rec) => {
|
|
164
|
+
doc.fontSize(9).font('Helvetica').fillColor('#475569').text(`→ ${String(rec)}`, MARGIN + 8, doc.y, { width: CONTENT_WIDTH - 8 });
|
|
165
|
+
doc.y += 16;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
doc.y += SECTION_PAD;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SonarCloud Section — use selected options as title when present
|
|
172
|
+
const sonarTitle = (Array.isArray(data.sonarSelectedOptions) && data.sonarSelectedOptions.length > 0)
|
|
173
|
+
? data.sonarSelectedOptions.join(', ')
|
|
174
|
+
: 'Sonar Analysis';
|
|
175
|
+
this.sectionTitle(doc, sonarTitle, '#0ea5e9');
|
|
176
|
+
if (analysis.sonarCloud?.available && analysis.sonarCloud.metrics) {
|
|
177
|
+
const sc = analysis.sonarCloud;
|
|
178
|
+
doc.fontSize(9).font('Helvetica').fillColor('#475569');
|
|
179
|
+
// Quality Gate not shown in report (commented out)
|
|
180
|
+
doc.text(`Score: ${sc.score != null ? sc.score + '/10' : 'Not available'} · Rating: ${sc.rating || 'Not available'} · Project: ${sc.projectKey || 'Not available'}`, MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
181
|
+
doc.y += 18;
|
|
182
|
+
const m = sc.metrics || {};
|
|
183
|
+
const sonarMetrics = [
|
|
184
|
+
{ label: 'Lines of Code', value: String(m.ncloc ?? m.lines ?? 0) },
|
|
185
|
+
{ label: 'Bugs', value: String(m.bugs ?? 0) },
|
|
186
|
+
{ label: 'Vulnerabilities', value: String(m.vulnerabilities ?? 0) },
|
|
187
|
+
{ label: 'Security Hotspots', value: String(m.securityHotspots ?? 0) },
|
|
188
|
+
{ label: 'Duplication', value: m.duplicatedLinesDensity != null ? m.duplicatedLinesDensity + '%' : 'N/A' },
|
|
189
|
+
{ label: 'Coverage', value: m.coverage != null ? m.coverage + '%' : 'N/A' },
|
|
190
|
+
{ label: 'Complexity', value: String(m.complexity ?? 0) },
|
|
191
|
+
];
|
|
192
|
+
this.drawTable(doc, sonarMetrics, ['#475569', '#1e293b'], false);
|
|
193
|
+
if (sc.recommendations?.length > 0) {
|
|
194
|
+
doc.y += 8;
|
|
195
|
+
doc.fontSize(9).font('Helvetica-Bold').fillColor('#334155').text('Recommendations', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
196
|
+
doc.y += 14;
|
|
197
|
+
sc.recommendations.slice(0, 5).forEach((rec) => {
|
|
198
|
+
doc.fontSize(9).font('Helvetica').fillColor('#475569').text(`→ [${rec.priority ?? 'Not available'}] ${rec.category ?? 'Not available'}: ${rec.action ?? 'Not available'}`, MARGIN + 8, doc.y, { width: CONTENT_WIDTH - 8 });
|
|
199
|
+
doc.y += 16;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
doc.fontSize(9).font('Helvetica').fillColor('#64748b');
|
|
204
|
+
doc.text(analysis.sonarCloud?.unavailableReason || 'Unavailable. Configure SONAR_ORGANIZATION and project to include in the report.', MARGIN, doc.y, { width: CONTENT_WIDTH });
|
|
205
|
+
doc.y += 28;
|
|
206
|
+
}
|
|
207
|
+
doc.y += SECTION_PAD;
|
|
208
|
+
|
|
209
|
+
// Gemini AI Analysis (when present) — use selected options as title when present; bold/light same as page view
|
|
210
|
+
if (data.geminiAnalysis) {
|
|
211
|
+
if (doc.y > PAGE_BOTTOM - 80) doc.addPage();
|
|
212
|
+
const geminiTitle = (Array.isArray(data.geminiSelectedOptions) && data.geminiSelectedOptions.length > 0)
|
|
213
|
+
? data.geminiSelectedOptions.join(', ')
|
|
214
|
+
: 'Gemini Analysis';
|
|
215
|
+
this.sectionTitle(doc, geminiTitle, '#6366f1');
|
|
216
|
+
doc.rect(MARGIN, doc.y, CONTENT_WIDTH, 4).fill('#f1f5f9');
|
|
217
|
+
doc.y += 12;
|
|
218
|
+
const geminiText = String(data.geminiAnalysis).trim() || 'Unavailable.';
|
|
219
|
+
this.writeMarkdownLikeText(doc, geminiText, { lineGap: 4 });
|
|
220
|
+
doc.y += 16;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Open AI Analysis (when present); bold/light same as page view
|
|
224
|
+
if (data.openaiAnalysis) {
|
|
225
|
+
if (doc.y > PAGE_BOTTOM - 80) doc.addPage();
|
|
226
|
+
const openaiTitle = (Array.isArray(data.openaiSelectedOptions) && data.openaiSelectedOptions.length > 0)
|
|
227
|
+
? data.openaiSelectedOptions.join(', ')
|
|
228
|
+
: 'Open AI Analysis';
|
|
229
|
+
this.sectionTitle(doc, openaiTitle, '#0ea5e9');
|
|
230
|
+
doc.rect(MARGIN, doc.y, CONTENT_WIDTH, 4).fill('#f1f5f9');
|
|
231
|
+
doc.y += 12;
|
|
232
|
+
const openaiText = String(data.openaiAnalysis).trim() || 'Unavailable.';
|
|
233
|
+
this.writeMarkdownLikeText(doc, openaiText, { lineGap: 4 });
|
|
234
|
+
doc.y += 16;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Page numbers (after all content, before doc.end)
|
|
238
|
+
const range = doc.bufferedPageRange();
|
|
239
|
+
for (let i = 0; i < range.count; i++) {
|
|
240
|
+
doc.switchToPage(range.start + i);
|
|
241
|
+
doc.fontSize(8).fillColor('#94a3b8');
|
|
242
|
+
doc.text(`Page ${i + 1} of ${range.count}`, MARGIN, 812, { width: CONTENT_WIDTH, align: 'center' });
|
|
243
|
+
doc.text('GitHub Repository Analyzer', MARGIN, 822, { width: CONTENT_WIDTH, align: 'center' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
doc.end();
|
|
247
|
+
|
|
248
|
+
stream.on('finish', () => {
|
|
249
|
+
resolve(filename);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
stream.on('error', (error) => {
|
|
253
|
+
reject(error);
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
reject(error);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Render markdown-like text in PDF with same bold/light as page view: ## = section (bold), **text** = bold.
|
|
263
|
+
* Never pass newlines to doc.text() and always pass height so PDFKit does not add blank pages.
|
|
264
|
+
*/
|
|
265
|
+
static writeMarkdownLikeText(doc, text, opts = {}) {
|
|
266
|
+
const width = opts.width ?? CONTENT_WIDTH;
|
|
267
|
+
const lineGap = opts.lineGap ?? 4;
|
|
268
|
+
const fontSize = opts.fontSize ?? 9;
|
|
269
|
+
const color = opts.color ?? '#334155';
|
|
270
|
+
const x = opts.x ?? MARGIN;
|
|
271
|
+
const raw = String(text || '').trim().split(/\r?\n/);
|
|
272
|
+
const lines = [];
|
|
273
|
+
for (let i = 0; i < raw.length; i++) {
|
|
274
|
+
const line = raw[i];
|
|
275
|
+
const blank = line.trim() === '';
|
|
276
|
+
if (blank && lines.length > 0 && lines[lines.length - 1].trim() === '') continue;
|
|
277
|
+
lines.push(line);
|
|
278
|
+
}
|
|
279
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop();
|
|
280
|
+
doc.fontSize(fontSize).fillColor(color);
|
|
281
|
+
const lineHeight = fontSize + lineGap;
|
|
282
|
+
let firstInBlock = true;
|
|
283
|
+
for (let i = 0; i < lines.length; i++) {
|
|
284
|
+
const line = lines[i];
|
|
285
|
+
const isBlank = line.trim() === '';
|
|
286
|
+
if (isBlank) {
|
|
287
|
+
doc.y += lineHeight;
|
|
288
|
+
if (doc.y > PAGE_BOTTOM) {
|
|
289
|
+
doc.addPage();
|
|
290
|
+
doc.y = MARGIN;
|
|
291
|
+
}
|
|
292
|
+
firstInBlock = true;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (doc.y > PAGE_BOTTOM - 30) {
|
|
296
|
+
doc.addPage();
|
|
297
|
+
doc.y = MARGIN;
|
|
298
|
+
firstInBlock = true;
|
|
299
|
+
}
|
|
300
|
+
const maxHeight = Math.max(lineHeight, PAGE_BOTTOM - doc.y);
|
|
301
|
+
const isSection = /^##\s+/.test(line);
|
|
302
|
+
if (isSection) {
|
|
303
|
+
const heading = line.replace(/^##\s+/, '').trim();
|
|
304
|
+
doc.font('Helvetica-Bold').fontSize(Math.round(fontSize * 1.15)).fillColor(color);
|
|
305
|
+
doc.text(heading, firstInBlock ? x : undefined, firstInBlock ? doc.y : undefined, { width, lineGap, height: maxHeight });
|
|
306
|
+
firstInBlock = false;
|
|
307
|
+
doc.fontSize(fontSize);
|
|
308
|
+
doc.y += lineGap;
|
|
309
|
+
} else {
|
|
310
|
+
const segments = line.split(/\*\*(.+?)\*\*/g);
|
|
311
|
+
for (let s = 0; s < segments.length; s++) {
|
|
312
|
+
const segment = segments[s];
|
|
313
|
+
const isBold = s % 2 === 1;
|
|
314
|
+
doc.font(isBold ? 'Helvetica-Bold' : 'Helvetica');
|
|
315
|
+
const isLastInLine = s === segments.length - 1;
|
|
316
|
+
const textOpts = { width, lineGap, continued: !isLastInLine, height: PAGE_BOTTOM - doc.y };
|
|
317
|
+
if (firstInBlock) {
|
|
318
|
+
doc.text(segment, x, doc.y, textOpts);
|
|
319
|
+
firstInBlock = false;
|
|
320
|
+
} else {
|
|
321
|
+
doc.text(segment, textOpts);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
doc.y += lineGap;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
doc.font('Helvetica');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
static sectionTitle(doc, title, accentColor) {
|
|
331
|
+
if (doc.y > PAGE_BOTTOM) {
|
|
332
|
+
doc.addPage();
|
|
333
|
+
doc.y = MARGIN;
|
|
334
|
+
}
|
|
335
|
+
doc.rect(MARGIN, doc.y, 4, 20).fill(accentColor);
|
|
336
|
+
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1e293b').text(title, MARGIN + 12, doc.y + 2, { width: CONTENT_WIDTH - 12 });
|
|
337
|
+
doc.y += 26;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
static drawTable(doc, rows, colors, hasHeader) {
|
|
341
|
+
const labelWidth = Math.floor(CONTENT_WIDTH * 0.52);
|
|
342
|
+
const valueWidth = CONTENT_WIDTH - labelWidth - 2;
|
|
343
|
+
const startX = MARGIN;
|
|
344
|
+
let y = doc.y;
|
|
345
|
+
const [labelColor, valueColor] = colors || ['#475569', '#1e293b'];
|
|
346
|
+
|
|
347
|
+
rows.forEach((row, index) => {
|
|
348
|
+
if (y > PAGE_BOTTOM) {
|
|
349
|
+
doc.addPage();
|
|
350
|
+
y = MARGIN;
|
|
351
|
+
}
|
|
352
|
+
const isHeader = hasHeader && index === 0;
|
|
353
|
+
const rowBg = isHeader ? '#f1f5f9' : (index % 2 === (hasHeader ? 1 : 0) ? '#ffffff' : '#f8fafc');
|
|
354
|
+
doc.rect(startX, y - 2, CONTENT_WIDTH, ROW_HEIGHT + 2).fill(rowBg);
|
|
355
|
+
doc.fontSize(isHeader ? 9 : 9).font(isHeader ? 'Helvetica-Bold' : 'Helvetica').fillColor(isHeader ? '#334155' : labelColor);
|
|
356
|
+
doc.text(String(row.label), startX + 8, y + 2, { width: labelWidth - 8 });
|
|
357
|
+
doc.fontSize(9).font('Helvetica-Bold').fillColor(isHeader ? '#334155' : valueColor);
|
|
358
|
+
doc.text(String(row.value), startX + labelWidth, y + 2, { width: valueWidth - 8, align: 'right' });
|
|
359
|
+
doc.strokeColor('#e2e8f0').lineWidth(0.5).rect(startX, y - 2, CONTENT_WIDTH, ROW_HEIGHT + 2).stroke();
|
|
360
|
+
y += ROW_HEIGHT + 2;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
doc.y = y;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
static getHealthColor(status) {
|
|
367
|
+
const colorMap = {
|
|
368
|
+
Excellent: '#4caf50',
|
|
369
|
+
Good: '#ffc107',
|
|
370
|
+
Fair: '#ff9800',
|
|
371
|
+
Poor: '#f44336',
|
|
372
|
+
};
|
|
373
|
+
return colorMap[status] || '#667eea';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
static getRiskColor(riskLevel) {
|
|
377
|
+
const colorMap = {
|
|
378
|
+
Critical: '#f44336',
|
|
379
|
+
High: '#f44336',
|
|
380
|
+
Medium: '#ff9800',
|
|
381
|
+
Low: '#4caf50',
|
|
382
|
+
};
|
|
383
|
+
return colorMap[riskLevel] || '#667eea';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export default PDFReportGenerator;
|