PlanOpticon

planopticon / video_processor / exporters / pdf_export.py
Blame History Raw 278 lines
1
"""Generate PDF reports from knowledge graph data.
2
3
Uses reportlab for PDF generation. Falls back gracefully if not installed.
4
No LLM required — pure template-based generation from KG data.
5
"""
6
7
import logging
8
from datetime import datetime
9
from pathlib import Path
10
from typing import Any, Dict, List, Optional
11
12
logger = logging.getLogger(__name__)
13
14
15
def _get_styles():
16
"""Import and configure reportlab styles."""
17
from reportlab.lib import colors
18
from reportlab.lib.pagesizes import letter
19
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
20
from reportlab.lib.units import inch
21
22
styles = getSampleStyleSheet()
23
24
styles.add(
25
ParagraphStyle(
26
"KGTitle",
27
parent=styles["Title"],
28
fontSize=24,
29
spaceAfter=20,
30
textColor=colors.HexColor("#1a1a2e"),
31
)
32
)
33
styles.add(
34
ParagraphStyle(
35
"KGHeading2",
36
parent=styles["Heading2"],
37
fontSize=16,
38
spaceBefore=16,
39
spaceAfter=8,
40
textColor=colors.HexColor("#16213e"),
41
)
42
)
43
styles.add(
44
ParagraphStyle(
45
"KGBody",
46
parent=styles["Normal"],
47
fontSize=10,
48
leading=14,
49
spaceBefore=4,
50
spaceAfter=4,
51
)
52
)
53
styles.add(
54
ParagraphStyle(
55
"KGBullet",
56
parent=styles["Normal"],
57
fontSize=10,
58
leading=14,
59
leftIndent=20,
60
bulletIndent=10,
61
spaceBefore=2,
62
spaceAfter=2,
63
)
64
)
65
66
return styles, letter, inch, colors
67
68
69
def _build_entity_table(nodes: List[dict], colors) -> Any:
70
"""Build a table of entities grouped by type."""
71
from reportlab.lib.units import inch
72
from reportlab.platypus import Table, TableStyle
73
74
by_type: Dict[str, list] = {}
75
for n in nodes:
76
t = n.get("type", "concept")
77
by_type.setdefault(t, []).append(n)
78
79
data = [["Type", "Count", "Examples"]]
80
for etype, elist in sorted(by_type.items(), key=lambda x: -len(x[1])):
81
examples = ", ".join(e.get("name", "") for e in elist[:3])
82
if len(elist) > 3:
83
examples += f" (+{len(elist) - 3} more)"
84
data.append([etype.title(), str(len(elist)), examples])
85
86
table = Table(data, colWidths=[1.2 * inch, 0.8 * inch, 4.0 * inch])
87
table.setStyle(
88
TableStyle(
89
[
90
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8eaf6")),
91
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
92
("FONTSIZE", (0, 0), (-1, -1), 9),
93
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
94
("VALIGN", (0, 0), (-1, -1), "TOP"),
95
("TOPPADDING", (0, 0), (-1, -1), 4),
96
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
97
]
98
)
99
)
100
return table
101
102
103
def _build_relationship_table(rels: List[dict], colors, max_rows: int = 30) -> Any:
104
"""Build a table of relationships."""
105
from reportlab.lib.units import inch
106
from reportlab.platypus import Table, TableStyle
107
108
data = [["Source", "Relationship", "Target"]]
109
for r in rels[:max_rows]:
110
data.append([r.get("source", ""), r.get("type", ""), r.get("target", "")])
111
if len(rels) > max_rows:
112
data.append(["...", f"({len(rels) - max_rows} more)", "..."])
113
114
table = Table(data, colWidths=[2.0 * inch, 2.0 * inch, 2.0 * inch])
115
table.setStyle(
116
TableStyle(
117
[
118
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8eaf6")),
119
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
120
("FONTSIZE", (0, 0), (-1, -1), 9),
121
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
122
("VALIGN", (0, 0), (-1, -1), "TOP"),
123
("TOPPADDING", (0, 0), (-1, -1), 4),
124
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
125
]
126
)
127
)
128
return table
129
130
131
def _build_key_entities_table(rels: List[dict], colors) -> Any:
132
"""Build a table of top entities by connection count."""
133
from reportlab.lib.units import inch
134
from reportlab.platypus import Table, TableStyle
135
136
degree: Dict[str, int] = {}
137
for r in rels:
138
degree[r.get("source", "")] = degree.get(r.get("source", ""), 0) + 1
139
degree[r.get("target", "")] = degree.get(r.get("target", ""), 0) + 1
140
141
top = sorted(degree.items(), key=lambda x: -x[1])[:10]
142
if not top:
143
return None
144
145
data = [["Entity", "Connections"]]
146
for name, deg in top:
147
data.append([name, str(deg)])
148
149
table = Table(data, colWidths=[4.0 * inch, 1.5 * inch])
150
table.setStyle(
151
TableStyle(
152
[
153
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e8eaf6")),
154
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
155
("FONTSIZE", (0, 0), (-1, -1), 9),
156
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
157
("TOPPADDING", (0, 0), (-1, -1), 4),
158
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
159
]
160
)
161
)
162
return table
163
164
165
def generate_pdf(
166
kg_data: dict,
167
output_path: Path,
168
title: Optional[str] = None,
169
diagrams_dir: Optional[Path] = None,
170
) -> Path:
171
"""Generate a PDF report from knowledge graph data.
172
173
Args:
174
kg_data: Knowledge graph dict with 'nodes' and 'relationships'.
175
output_path: Path to write the PDF file.
176
title: Optional report title.
177
diagrams_dir: Optional directory containing diagram images to embed.
178
179
Returns:
180
Path to the generated PDF.
181
182
Raises:
183
ImportError: If reportlab is not installed.
184
"""
185
from reportlab.platypus import (
186
Paragraph,
187
SimpleDocTemplate,
188
Spacer,
189
)
190
191
styles, letter, inch, colors = _get_styles()
192
193
output_path = Path(output_path)
194
output_path.parent.mkdir(parents=True, exist_ok=True)
195
196
doc = SimpleDocTemplate(
197
str(output_path),
198
pagesize=letter,
199
topMargin=0.75 * inch,
200
bottomMargin=0.75 * inch,
201
leftMargin=0.75 * inch,
202
rightMargin=0.75 * inch,
203
)
204
205
story = []
206
nodes = kg_data.get("nodes", [])
207
rels = kg_data.get("relationships", [])
208
209
# Title
210
report_title = title or "Knowledge Graph Report"
211
story.append(Paragraph(report_title, styles["KGTitle"]))
212
story.append(
213
Paragraph(
214
f"Generated {datetime.now().strftime('%Y-%m-%d %H:%M')} • "
215
f"{len(nodes)} entities • {len(rels)} relationships",
216
styles["KGBody"],
217
)
218
)
219
story.append(Spacer(1, 20))
220
221
# Entity breakdown
222
if nodes:
223
story.append(Paragraph("Entity Breakdown", styles["KGHeading2"]))
224
story.append(_build_entity_table(nodes, colors))
225
story.append(Spacer(1, 12))
226
227
# Key entities
228
if rels:
229
key_table = _build_key_entities_table(rels, colors)
230
if key_table:
231
story.append(Paragraph("Key Entities (by connections)", styles["KGHeading2"]))
232
story.append(key_table)
233
story.append(Spacer(1, 12))
234
235
# Embed diagram images
236
if diagrams_dir and diagrams_dir.exists():
237
_embed_diagrams(story, styles, diagrams_dir, inch)
238
239
# Relationship table
240
if rels:
241
story.append(Paragraph("Relationships", styles["KGHeading2"]))
242
story.append(_build_relationship_table(rels, colors))
243
story.append(Spacer(1, 12))
244
245
# Entity details
246
if nodes:
247
story.append(Paragraph("Entity Details", styles["KGHeading2"]))
248
for node in sorted(nodes, key=lambda n: n.get("name", "")):
249
name = node.get("name", "")
250
etype = node.get("type", "concept")
251
descs = node.get("descriptions", [])
252
desc = descs[0] if descs else "No description."
253
story.append(Paragraph(f"<b>{name}</b> <i>({etype})</i>: {desc}", styles["KGBullet"]))
254
255
doc.build(story)
256
logger.info(f"Generated PDF report: {output_path}")
257
return output_path
258
259
260
def _embed_diagrams(story, styles, diagrams_dir: Path, inch):
261
"""Embed diagram PNG images from a directory."""
262
from reportlab.platypus import Image, Paragraph, Spacer
263
264
pngs = sorted(diagrams_dir.glob("*.png"))
265
if not pngs:
266
return
267
268
story.append(Paragraph("Diagrams", styles["KGHeading2"]))
269
270
for png in pngs:
271
try:
272
img = Image(str(png), width=5 * inch, height=3.5 * inch)
273
img.hAlign = "CENTER"
274
story.append(img)
275
story.append(Spacer(1, 8))
276
except Exception as e:
277
logger.warning(f"Could not embed diagram {png.name}: {e}")
278

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button