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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""PDF report generation for TraceKit.
3This module provides high-quality PDF report generation with embedded plots,
4metadata, and PDF/A compliance for archival.
7Example:
8 >>> from tracekit.reporting.pdf import generate_pdf_report
9 >>> pdf_bytes = generate_pdf_report(report, dpi=300, pdfa_compliance=True)
10"""
12from __future__ import annotations
14import io
15from pathlib import Path
16from typing import TYPE_CHECKING, Any
18if TYPE_CHECKING:
19 from tracekit.reporting.core import Report, Section
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 )
36 REPORTLAB_AVAILABLE = True
37except ImportError:
38 REPORTLAB_AVAILABLE = False
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.
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.
60 Returns:
61 PDF data as bytes.
63 Raises:
64 ImportError: If reportlab is not installed.
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 )
74 buffer = io.BytesIO()
76 # Determine page size
77 page_size = A4 if report.config.page_size == "A4" else LETTER
78 margin = report.config.margins * inch
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 )
92 # Build document story
93 story = []
94 styles = _create_styles()
96 # Add title
97 story.append(Paragraph(report.config.title, styles["Title"]))
98 story.append(Spacer(1, 0.3 * inch))
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))
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
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())
119 # Add sections
120 for section in report.sections:
121 if not section.visible:
122 continue
124 _add_pdf_section(story, section, styles, report)
126 # Build PDF
127 doc.build(story)
129 return buffer.getvalue()
132def _create_styles() -> dict[str, ParagraphStyle]:
133 """Create PDF paragraph styles."""
134 base_styles = getSampleStyleSheet()
136 # Create a new dict to hold our custom styles
137 styles: dict[str, ParagraphStyle] = {}
139 # Copy base styles we want to keep
140 styles["Normal"] = base_styles["Normal"]
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
236 return styles
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}")
248 return " | ".join(parts)
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))
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))
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))
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))
297 story.append(Spacer(1, 0.3 * inch))
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", [])
305 # Build table data
306 table_data = []
307 if headers:
308 table_data.append(headers)
309 table_data.extend(data)
311 # Create table
312 table = Table(table_data)
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 ]
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 )
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")))
352 table.setStyle(TableStyle(style_commands))
354 return table
357def save_pdf_report(
358 report: Report,
359 path: str | Path,
360 **kwargs: Any,
361) -> None:
362 """Save report as PDF file.
364 Args:
365 report: Report object.
366 path: Output file path.
367 **kwargs: Additional options for generate_pdf_report.
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)