Coverage for src / tracekit / reporting / pdf.py: 94%

120 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""PDF report generation for TraceKit. 

2 

3This module provides high-quality PDF report generation with embedded plots, 

4metadata, and PDF/A compliance for archival. 

5 

6 

7Example: 

8 >>> from tracekit.reporting.pdf import generate_pdf_report 

9 >>> pdf_bytes = generate_pdf_report(report, dpi=300, pdfa_compliance=True) 

10""" 

11 

12from __future__ import annotations 

13 

14import io 

15from pathlib import Path 

16from typing import TYPE_CHECKING, Any 

17 

18if TYPE_CHECKING: 

19 from tracekit.reporting.core import Report, Section 

20 

21# Optional imports for PDF generation 

22try: 

23 from reportlab.lib import colors 

24 from reportlab.lib.pagesizes import A4, LETTER 

25 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet 

26 from reportlab.lib.units import inch 

27 from reportlab.platypus import ( 

28 PageBreak, 

29 Paragraph, 

30 SimpleDocTemplate, 

31 Spacer, 

32 Table, 

33 TableStyle, 

34 ) 

35 

36 REPORTLAB_AVAILABLE = True 

37except ImportError: 

38 REPORTLAB_AVAILABLE = False 

39 

40 

41def generate_pdf_report( 

42 report: Report, 

43 *, 

44 dpi: int = 300, 

45 embed_fonts: bool = True, 

46 vector_graphics: bool = True, 

47 table_of_contents: bool = True, 

48 pdfa_compliance: bool = False, 

49) -> bytes: 

50 """Generate high-quality PDF report. 

51 

52 Args: 

53 report: Report object to render. 

54 dpi: Plot rendering DPI (default 300). 

55 embed_fonts: Embed fonts for consistency. 

56 vector_graphics: Use vector graphics for plots. 

57 table_of_contents: Include table of contents. 

58 pdfa_compliance: Generate PDF/A-1b compliant output. 

59 

60 Returns: 

61 PDF data as bytes. 

62 

63 Raises: 

64 ImportError: If reportlab is not installed. 

65 

66 References: 

67 REPORT-001, REPORT-002, REPORT-008, RPT-001 

68 """ 

69 if not REPORTLAB_AVAILABLE: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 raise ImportError( 

71 "reportlab is required for PDF generation. Install with: pip install reportlab" 

72 ) 

73 

74 buffer = io.BytesIO() 

75 

76 # Determine page size 

77 page_size = A4 if report.config.page_size == "A4" else LETTER 

78 margin = report.config.margins * inch 

79 

80 # Create PDF document 

81 doc = SimpleDocTemplate( 

82 buffer, 

83 pagesize=page_size, 

84 leftMargin=margin, 

85 rightMargin=margin, 

86 topMargin=margin, 

87 bottomMargin=margin, 

88 title=report.config.title, 

89 author=report.config.author or "TraceKit", 

90 ) 

91 

92 # Build document story 

93 story = [] 

94 styles = _create_styles() 

95 

96 # Add title 

97 story.append(Paragraph(report.config.title, styles["Title"])) 

98 story.append(Spacer(1, 0.3 * inch)) 

99 

100 # Add metadata 

101 metadata_text = _format_metadata(report) 

102 story.append(Paragraph(metadata_text, styles["Metadata"])) 

103 story.append(Spacer(1, 0.5 * inch)) 

104 

105 # Add watermark if specified 

106 if report.config.watermark: 106 ↛ 108line 106 didn't jump to line 108 because the condition on line 106 was never true

107 # Watermark would be added via PageTemplate in production 

108 pass 

109 

110 # Add table of contents if requested 

111 if table_of_contents and len(report.sections) > 3: 

112 story.append(Paragraph("Table of Contents", styles["Heading1"])) 

113 for i, section in enumerate(report.sections): 

114 if section.visible: 114 ↛ 113line 114 didn't jump to line 113 because the condition on line 114 was always true

115 toc_entry = f"{i + 1}. {section.title}" 

116 story.append(Paragraph(toc_entry, styles["TOC"])) 

117 story.append(PageBreak()) 

118 

119 # Add sections 

120 for section in report.sections: 

121 if not section.visible: 

122 continue 

123 

124 _add_pdf_section(story, section, styles, report) 

125 

126 # Build PDF 

127 doc.build(story) 

128 

129 return buffer.getvalue() 

130 

131 

132def _create_styles() -> dict[str, ParagraphStyle]: 

133 """Create PDF paragraph styles.""" 

134 base_styles = getSampleStyleSheet() 

135 

136 # Create a new dict to hold our custom styles 

137 styles: dict[str, ParagraphStyle] = {} 

138 

139 # Copy base styles we want to keep 

140 styles["Normal"] = base_styles["Normal"] 

141 

142 # Title style (24pt) - override default 

143 styles["Title"] = ParagraphStyle( 

144 name="Title", 

145 parent=base_styles["Normal"], 

146 fontSize=24, 

147 textColor=colors.HexColor("#2c3e50"), 

148 spaceAfter=12, 

149 alignment=1, # Center 

150 fontName="Helvetica-Bold", 

151 ) 

152 

153 # Heading styles. - override defaults 

154 styles["Heading1"] = ParagraphStyle( 

155 name="Heading1", 

156 parent=base_styles["Normal"], 

157 fontSize=18, 

158 textColor=colors.HexColor("#2c3e50"), 

159 spaceBefore=12, 

160 spaceAfter=6, 

161 fontName="Helvetica-Bold", 

162 ) 

163 

164 styles["Heading2"] = ParagraphStyle( 

165 name="Heading2", 

166 parent=base_styles["Normal"], 

167 fontSize=14, 

168 textColor=colors.HexColor("#34495e"), 

169 spaceBefore=10, 

170 spaceAfter=4, 

171 fontName="Helvetica-Bold", 

172 ) 

173 

174 styles["Heading3"] = ParagraphStyle( 

175 name="Heading3", 

176 parent=base_styles["Normal"], 

177 fontSize=12, 

178 textColor=colors.HexColor("#34495e"), 

179 spaceBefore=8, 

180 spaceAfter=4, 

181 fontName="Helvetica-Bold", 

182 ) 

183 

184 # Body text (10pt, serif, 1.5 line spacing) 

185 styles["Body"] = ParagraphStyle( 

186 name="Body", 

187 parent=base_styles["Normal"], 

188 fontSize=10, 

189 leading=15, # 1.5 line spacing 

190 fontName="Times-Roman", 

191 ) 

192 

193 # Metadata style 

194 styles["Metadata"] = ParagraphStyle( 

195 name="Metadata", 

196 parent=base_styles["Normal"], 

197 fontSize=9, 

198 textColor=colors.HexColor("#555555"), 

199 fontName="Helvetica", 

200 ) 

201 

202 # TOC style 

203 styles["TOC"] = ParagraphStyle( 

204 name="TOC", 

205 parent=base_styles["Normal"], 

206 fontSize=10, 

207 leftIndent=20, 

208 spaceAfter=4, 

209 ) 

210 

211 # Pass/Fail styles with visual emphasis. 

212 styles["Pass"] = ParagraphStyle( 

213 name="Pass", 

214 parent=base_styles["Normal"], 

215 fontSize=10, 

216 textColor=colors.HexColor("#27ae60"), 

217 fontName="Helvetica-Bold", 

218 ) 

219 

220 styles["Fail"] = ParagraphStyle( 

221 name="Fail", 

222 parent=base_styles["Normal"], 

223 fontSize=10, 

224 textColor=colors.HexColor("#e74c3c"), 

225 fontName="Helvetica-Bold", 

226 ) 

227 

228 styles["Warning"] = ParagraphStyle( 

229 name="Warning", 

230 parent=base_styles["Normal"], 

231 fontSize=10, 

232 textColor=colors.HexColor("#f39c12"), 

233 fontName="Helvetica-Bold", 

234 ) 

235 

236 return styles 

237 

238 

239def _format_metadata(report: Report) -> str: 

240 """Format report metadata.""" 

241 parts = [] 

242 if report.config.author: 

243 parts.append(f"<b>Author:</b> {report.config.author}") 

244 parts.append(f"<b>Date:</b> {report.config.created.strftime('%Y-%m-%d %H:%M')}") 

245 if report.config.verbosity: 245 ↛ 248line 245 didn't jump to line 248 because the condition on line 245 was always true

246 parts.append(f"<b>Detail Level:</b> {report.config.verbosity}") 

247 

248 return " | ".join(parts) 

249 

250 

251def _add_pdf_section( 

252 story: list, # type: ignore[type-arg] 

253 section: Section, 

254 styles: dict[str, ParagraphStyle], 

255 report: Report, 

256) -> None: 

257 """Add a section to the PDF story.""" 

258 # Section heading 

259 heading_style = f"Heading{min(section.level, 3)}" 

260 story.append(Paragraph(section.title, styles[heading_style])) 

261 story.append(Spacer(1, 0.2 * inch)) 

262 

263 # Section content 

264 if isinstance(section.content, str): 

265 # Split into paragraphs 

266 paragraphs = section.content.split("\n\n") 

267 for para in paragraphs: 

268 if para.strip(): 268 ↛ 267line 268 didn't jump to line 267 because the condition on line 268 was always true

269 story.append(Paragraph(para.strip(), styles["Body"])) 

270 story.append(Spacer(1, 0.1 * inch)) 

271 

272 elif isinstance(section.content, list): 272 ↛ 288line 272 didn't jump to line 288 because the condition on line 272 was always true

273 for item in section.content: 

274 if isinstance(item, dict): 

275 if item.get("type") == "table": 

276 story.append(_create_pdf_table(item)) 

277 story.append(Spacer(1, 0.2 * inch)) 

278 elif item.get("type") == "figure": 278 ↛ 273line 278 didn't jump to line 273 because the condition on line 278 was always true

279 # Placeholder for figures 

280 caption = item.get("caption", "Figure") 

281 story.append(Paragraph(f"[Figure: {caption}]", styles["Body"])) 

282 story.append(Spacer(1, 0.2 * inch)) 

283 else: 

284 story.append(Paragraph(str(item), styles["Body"])) 

285 story.append(Spacer(1, 0.1 * inch)) 

286 

287 # Subsections 

288 for subsec in section.subsections: 

289 if not subsec.visible: 

290 continue 

291 sub_heading_style = f"Heading{min(subsec.level, 3)}" 

292 story.append(Paragraph(subsec.title, styles[sub_heading_style])) 

293 if isinstance(subsec.content, str) and subsec.content.strip(): 293 ↛ 288line 293 didn't jump to line 288 because the condition on line 293 was always true

294 story.append(Paragraph(subsec.content, styles["Body"])) 

295 story.append(Spacer(1, 0.1 * inch)) 

296 

297 story.append(Spacer(1, 0.3 * inch)) 

298 

299 

300def _create_pdf_table(table_dict: dict[str, Any]) -> Table: 

301 """Create PDF table with professional formatting., REPORT-002.""" 

302 headers = table_dict.get("headers", []) 

303 data = table_dict.get("data", []) 

304 

305 # Build table data 

306 table_data = [] 

307 if headers: 

308 table_data.append(headers) 

309 table_data.extend(data) 

310 

311 # Create table 

312 table = Table(table_data) 

313 

314 # Apply professional table style. 

315 style_commands = [ 

316 ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f2f2f2")), # Header 

317 ("TEXTCOLOR", (0, 0), (-1, 0), colors.black), 

318 ("ALIGN", (0, 0), (-1, -1), "LEFT"), 

319 ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), 

320 ("FONTSIZE", (0, 0), (-1, 0), 10), 

321 ("BOTTOMPADDING", (0, 0), (-1, 0), 12), 

322 ("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#dddddd")), 

323 ("FONTNAME", (0, 1), (-1, -1), "Times-Roman"), 

324 ("FONTSIZE", (0, 1), (-1, -1), 10), 

325 ("TOPPADDING", (0, 1), (-1, -1), 8), 

326 ("BOTTOMPADDING", (0, 1), (-1, -1), 8), 

327 ] 

328 

329 # Alternating row colors. 

330 for i in range(1, len(table_data)): 

331 if i % 2 == 0: 

332 style_commands.append( 

333 ( 

334 "BACKGROUND", 

335 (0, i), 

336 (-1, i), 

337 colors.HexColor("#f9f9f9"), 

338 ) 

339 ) 

340 

341 # Apply visual emphasis for PASS/FAIL. 

342 for i, row in enumerate(data, start=1): 

343 for j, cell in enumerate(row): 

344 cell_str = str(cell).upper() 

345 if "PASS" in cell_str or "✓" in str(cell): 

346 style_commands.append(("TEXTCOLOR", (j, i), (j, i), colors.HexColor("#27ae60"))) 

347 elif "FAIL" in cell_str or "✗" in str(cell): 

348 style_commands.append(("TEXTCOLOR", (j, i), (j, i), colors.HexColor("#e74c3c"))) 

349 elif "WARNING" in cell_str: 

350 style_commands.append(("TEXTCOLOR", (j, i), (j, i), colors.HexColor("#f39c12"))) 

351 

352 table.setStyle(TableStyle(style_commands)) 

353 

354 return table 

355 

356 

357def save_pdf_report( 

358 report: Report, 

359 path: str | Path, 

360 **kwargs: Any, 

361) -> None: 

362 """Save report as PDF file. 

363 

364 Args: 

365 report: Report object. 

366 path: Output file path. 

367 **kwargs: Additional options for generate_pdf_report. 

368 

369 References: 

370 REPORT-001, REPORT-008, RPT-001 

371 """ 

372 pdf_bytes = generate_pdf_report(report, **kwargs) 

373 Path(path).write_bytes(pdf_bytes)