PlanOpticon

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

Keyboard Shortcuts

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