render.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. """Render a session-summary SVG from a content JSON and the session's transcripts.jsonl.
  2. Usage: uv run python render.py <session_dir> <content.json> <out.svg> [--no-png]
  3. Also exports <out>.png next to the SVG (Inkscape + ImageMagick, 64-color quantized); pass
  4. --no-png to skip. A missing tool or failed export exits non-zero.
  5. Layout: header, main-point band, a fixed left sidebar (session focus, participants and roles,
  6. transcription quality), then any number of topic cards. Topic cards fill the main column next
  7. to the sidebar, then continue below it in two-column rows. A full-width signals block follows
  8. (opportunities and blockers/risks always, open questions when any), then an optional next-steps
  9. table and the footer. Text is wrapped and the canvas height is computed, so nothing is clipped.
  10. Transcription-quality metrics are computed from the JSONL, never typed in by hand.
  11. """
  12. import json
  13. import shutil
  14. import statistics
  15. import subprocess
  16. import sys
  17. import tempfile
  18. from collections import Counter
  19. from datetime import datetime, timedelta
  20. from pathlib import Path
  21. from xml.dom import minidom
  22. from xml.sax.saxutils import escape
  23. W = 1680
  24. M = 48
  25. GAP = 24
  26. PAD = 28
  27. SIDE_W = 468
  28. MAIN_X = M + SIDE_W + 32
  29. MAIN_W = W - M - MAIN_X
  30. FULL_W = W - 2 * M
  31. HALF_W = (FULL_W - GAP) // 2
  32. FONT = "'Inter','Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif"
  33. RED = "#B91C1C"
  34. ACCENTS = {
  35. "amber": "#B45309", "teal": "#0D9488", "red": "#B91C1C", "violet": "#7C3AED",
  36. "green": "#15803D", "blue": "#2563EB", "rose": "#BE123C", "slate": "#475569",
  37. }
  38. PEOPLE_COLORS = ["#7C3AED", "#2563EB", "#B45309", "#0F766E", "#15803D", "#BE123C", "#0369A1"]
  39. BAR_COLORS = ["#2563EB", "#60A5FA", "#93C5FD"]
  40. L = {
  41. "en": {
  42. "eyebrow": "SESSION RECONSTRUCTION", "segments": "transcribed segments",
  43. "length": "session length", "rt": "faster than real time", "main": "MAIN POINT",
  44. "focus": "SESSION FOCUS", "people": "PARTICIPANTS AND ROLES",
  45. "quality": "TRANSCRIPTION QUALITY", "next": "NEXT STEPS", "action": "ACTION",
  46. "owner": "OWNER", "when": "WHEN", "q_length": "Session length",
  47. "q_voiced": "Reconstructed speech", "q_pause": "Pause cuts",
  48. "q_low": "Low-confidence segments (p < 0.8)", "q_spk": "Speaker identified by the model",
  49. "q_unc": "Uncertain / unknown speaker", "q_mis": "Mis-detected languages",
  50. "q_lat": "Median latency per segment", "corr": "Names and terms corrected",
  51. "others": "Others", "none": "none",
  52. "opps": "OPPORTUNITIES", "risks": "BLOCKERS / RISKS", "qs": "OPEN QUESTIONS",
  53. "none_id": "None identified in this session",
  54. "langs": {"es": "Spanish", "en": "English"},
  55. "months": "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec",
  56. },
  57. "es": {
  58. "eyebrow": "RECONSTRUCCIÓN DE SESIÓN", "segments": "segmentos transcritos",
  59. "length": "de sesión", "rt": "más rápido que tiempo real", "main": "PUNTO PRINCIPAL",
  60. "focus": "ENFOQUE DE LA SESIÓN", "people": "PARTICIPANTES Y ROLES",
  61. "quality": "CALIDAD DE LA TRANSCRIPCIÓN", "next": "PRÓXIMOS PASOS",
  62. "action": "ACCIÓN", "owner": "RESPONSABLE", "when": "CUÁNDO",
  63. "q_length": "Duración de la sesión", "q_voiced": "Voz reconstruida",
  64. "q_pause": "Cortes por pausa", "q_low": "Segmentos con confianza baja (p < 0.8)",
  65. "q_spk": "Voz identificada por el modelo", "q_unc": "Hablante dudoso / desconocido",
  66. "q_mis": "Idiomas mal detectados", "q_lat": "Latencia mediana por segmento",
  67. "corr": "Nombres y términos corregidos", "others": "Otros", "none": "ninguno",
  68. "opps": "OPORTUNIDADES", "risks": "BLOQUEOS / RIESGOS", "qs": "PREGUNTAS ABIERTAS",
  69. "none_id": "Ninguno identificado en esta sesión",
  70. "langs": {"es": "Español", "en": "Inglés"},
  71. "months": "ene feb mar abr may jun jul ago sep oct nov dic",
  72. },
  73. }
  74. def char_w(c):
  75. if c in "ilI.,:;'|!·":
  76. return 0.26
  77. if c in "fjrt()[]-":
  78. return 0.34
  79. if c == " ":
  80. return 0.28
  81. if c in "mwMW—":
  82. return 0.86
  83. if c.isdigit():
  84. return 0.56
  85. if c.isupper():
  86. return 0.68
  87. return 0.53
  88. def text_w(s, size, bold=False, spacing=0.0):
  89. w = sum(char_w(c) for c in s) * size * 1.05
  90. return (w * 1.07 if bold else w) + spacing * len(s)
  91. def wrap(s, size, width, bold=False, spacing=0.0):
  92. lines, cur = [], ""
  93. for word in str(s).split():
  94. trial = f"{cur} {word}" if cur else word
  95. if not cur or text_w(trial, size, bold, spacing) <= width:
  96. cur = trial
  97. else:
  98. lines.append(cur)
  99. cur = word
  100. return lines + [cur] if cur else lines or [""]
  101. def txt(x, y, s, size, fill, bold=False, anchor=None, spacing=None, italic=False):
  102. attrs = f'x="{x:.0f}" y="{y:.0f}" font-size="{size}" fill="{fill}"'
  103. if bold:
  104. attrs += ' font-weight="700"'
  105. if anchor:
  106. attrs += f' text-anchor="{anchor}"'
  107. if spacing:
  108. attrs += f' letter-spacing="{spacing}"'
  109. if italic:
  110. attrs += ' font-style="italic"'
  111. return f"<text {attrs}>{escape(str(s))}</text>"
  112. def para(out, x, y, s, size, width, lh, fill, **style):
  113. """Draw wrapped text with the first baseline at y; return the last baseline."""
  114. lines = wrap(s, size, width, style.get("bold", False), style.get("spacing") or 0.0)
  115. for i, line in enumerate(lines):
  116. out.append(txt(x, y + i * lh, line, size, fill, **style))
  117. return y + (len(lines) - 1) * lh
  118. def box(x, y, w, h, fill="#FFFFFF", stroke="#DCE2EC", rx=12):
  119. return (f'<rect x="{x:.0f}" y="{y:.0f}" width="{w:.0f}" height="{h:.0f}" rx="{rx}" '
  120. f'fill="{fill}" stroke="{stroke}"/>')
  121. def hline(x1, y, x2, color="#EDF1F7"):
  122. return f'<line x1="{x1:.0f}" y1="{y:.0f}" x2="{x2:.0f}" y2="{y:.0f}" stroke="{color}"/>'
  123. def fmt_dur(sec):
  124. sec = int(round(sec))
  125. h, rem = divmod(sec, 3600)
  126. m, s = divmod(rem, 60)
  127. return f"{h} h {m:02d} min {s:02d} s" if h else f"{m} min {s:02d} s"
  128. def metrics(session_dir, expected):
  129. path = Path(session_dir) / "transcripts.jsonl"
  130. segs = [json.loads(line) for line in path.open(encoding="utf-8") if line.strip()]
  131. t0 = datetime.fromisoformat(segs[0]["ts"])
  132. t1 = datetime.fromisoformat(segs[-1]["ts"]) + timedelta(seconds=segs[-1].get("duration_s") or 0)
  133. voiced = sum(s.get("duration_s") or 0 for s in segs)
  134. tx = [s["transcribe_s"] for s in segs if s.get("transcribe_s")]
  135. status = Counter(s.get("speaker_status") for s in segs)
  136. langs = Counter(s.get("lang") or "?" for s in segs)
  137. return {
  138. "n": len(segs), "t0": t0, "t1": t1, "length": (t1 - t0).total_seconds(),
  139. "voiced": voiced, "pause": sum(1 for s in segs if s.get("end_reason") == "pause"),
  140. "low": sum(1 for s in segs if (s.get("lang_prob") or 0) < 0.8),
  141. "matched": status["matched"], "uncertain": status["uncertain"],
  142. "unknown": status["unknown"], "langs": langs,
  143. "misdetected": [code for code, _ in langs.most_common() if code not in expected],
  144. "latency": statistics.median(tx) if tx else 0.0,
  145. "rt": voiced / sum(tx) if tx else 0.0,
  146. }
  147. def panel_title(out, x, y, w, title):
  148. out.append(txt(x + PAD, y + 34, title, 13, "#5A6878", bold=True, spacing=2.2))
  149. out.append(hline(x + PAD, y + 50, x + w - PAD))
  150. return y + 50
  151. def render_focus(x, y, w, items, lang):
  152. out = []
  153. cur = panel_title(out, x, y, w, L[lang]["focus"])
  154. inner = w - 2 * PAD
  155. for i, item in enumerate(items):
  156. base = cur + (28 if i == 0 else 30)
  157. color = ACCENTS["amber"] if item.get("tone") == "warn" else ACCENTS["blue"]
  158. out.append(f'<rect x="{x + PAD}" y="{base - 9:.0f}" width="8" height="8" rx="2" '
  159. f'fill="{color}"/>')
  160. cur = para(out, x + PAD + 20, base, item["label"], 14.5, inner - 20, 19, "#0F172A",
  161. bold=True)
  162. if item.get("detail"):
  163. cur = para(out, x + PAD + 20, cur + 20, item["detail"], 12, inner - 20, 16,
  164. "#5A6878", italic=item.get("tone") == "warn")
  165. h = cur + 26 - y
  166. return [box(x, y, w, h)] + out, h
  167. def render_people(x, y, w, people, note, lang):
  168. out = []
  169. cur = panel_title(out, x, y, w, L[lang]["people"])
  170. inner = w - 2 * PAD
  171. for i, p in enumerate(people):
  172. base = cur + (32 if i == 0 else 30)
  173. speaks = p.get("status", "speaks") == "speaks"
  174. color = PEOPLE_COLORS[i % len(PEOPLE_COLORS)] if speaks else "#94A3B8"
  175. initial = p.get("initial") or p["name"][:1].upper()
  176. out.append(f'<circle cx="{x + PAD + 14}" cy="{base - 5:.0f}" r="14" fill="{color}"/>')
  177. out.append(txt(x + PAD + 14, base - 1, initial, 12, "#FFFFFF", bold=True,
  178. anchor="middle"))
  179. cur = para(out, x + PAD + 40, base, p["name"], 14.5, inner - 40, 18, "#0F172A",
  180. bold=True)
  181. if p.get("role"):
  182. cur = para(out, x + PAD + 40, cur + 18, p["role"], 12, inner - 40, 16, "#5A6878")
  183. if note:
  184. out.append(hline(x + PAD, cur + 16, x + w - PAD, "#F3F6FA"))
  185. cur = para(out, x + PAD, cur + 38, note, 12, inner, 16, "#5A6878", italic=True)
  186. h = cur + 26 - y
  187. return [box(x, y, w, h)] + out, h
  188. def render_quality(x, y, w, mt, expected, corrections, lang):
  189. t = L[lang]
  190. out = []
  191. cur = panel_title(out, x, y, w, t["quality"])
  192. inner = w - 2 * PAD
  193. n = mt["n"]
  194. parts = [(t["langs"].get(c, c.upper()), mt["langs"][c]) for c in expected if mt["langs"][c]]
  195. other = n - sum(v for _, v in parts)
  196. if other:
  197. parts.append((t["others"], other))
  198. bar_y, bx = cur + 20, x + PAD
  199. out.append(f'<clipPath id="langbar"><rect x="{bx}" y="{bar_y:.0f}" width="{inner}" '
  200. f'height="18" rx="9"/></clipPath><g clip-path="url(#langbar)">')
  201. legend = []
  202. for i, (name, count) in enumerate(parts):
  203. color = "#CBD5E1" if name == t["others"] else BAR_COLORS[min(i, len(BAR_COLORS) - 1)]
  204. seg_w = inner * count / n
  205. out.append(f'<rect x="{bx:.1f}" y="{bar_y:.0f}" width="{seg_w + 0.5:.1f}" height="18" '
  206. f'fill="{color}"/>')
  207. bx += seg_w
  208. legend.append((f"{name} {count} ({round(100 * count / n)} %)", color))
  209. out.append("</g>")
  210. lx, ly = x + PAD, bar_y + 42
  211. for label, color in legend:
  212. lw = text_w(label, 12) + 30
  213. if lx + lw > x + w - PAD:
  214. lx, ly = x + PAD, ly + 20
  215. out.append(f'<circle cx="{lx + 4:.0f}" cy="{ly - 4:.0f}" r="4" fill="{color}"/>')
  216. out.append(txt(lx + 14, ly, label, 12, "#5A6878"))
  217. lx += lw
  218. cur = ly + 14
  219. out.append(hline(x + PAD, cur, x + w - PAD))
  220. pct = lambda k: f"{mt[k]} ({round(100 * mt[k] / n)} %)" # noqa: E731
  221. rows = [
  222. (t["q_length"], fmt_dur(mt["length"])),
  223. (t["q_voiced"], fmt_dur(mt["voiced"])),
  224. (t["q_pause"], f"{mt['pause']} / {n}"),
  225. (t["q_low"], pct("low")),
  226. (t["q_spk"], f"{round(100 * mt['matched'] / n)} % ({mt['matched']} / {n})"),
  227. (t["q_unc"], f"{mt['uncertain']} / {mt['unknown']}"),
  228. (t["q_mis"], " · ".join(mt["misdetected"][:8]) or t["none"]),
  229. (t["q_lat"], f"{mt['latency']:.2f} s"),
  230. ]
  231. for i, (label, value) in enumerate(rows):
  232. base = cur + 26
  233. out.append(txt(x + PAD, base, label, 12, "#5A6878"))
  234. out.append(txt(x + w - PAD, base, value, 14.5, "#0F172A", bold=True, anchor="end"))
  235. cur = base + 10
  236. if i < len(rows) - 1:
  237. out.append(hline(x + PAD, cur, x + w - PAD, "#F3F6FA"))
  238. if corrections:
  239. top, body = cur + 18, []
  240. last = para(body, x + PAD + 16, top + 22, t["corr"], 12, inner - 32, 16, "#334155",
  241. bold=True)
  242. for line in corrections:
  243. last = para(body, x + PAD + 16, last + 18, line, 12, inner - 32, 16, "#5A6878")
  244. bh = last + 16 - top
  245. out.append(box(x + PAD, top, inner, bh, fill="#F8FAFC", stroke="none", rx=8))
  246. out.append(f'<rect x="{x + PAD}" y="{top:.0f}" width="3" height="{bh:.0f}" rx="1.5" '
  247. f'fill="#94A3B8"/>')
  248. out.extend(body)
  249. cur = top + bh
  250. h = cur + 26 - y
  251. return [box(x, y, w, h)] + out, h
  252. def render_card(x, y, w, c, min_h=0):
  253. acc = ACCENTS.get(c.get("accent", "blue"), ACCENTS["blue"])
  254. px, inner, out = x + PAD, w - 2 * PAD, []
  255. cur = para(out, px, y + 32, c["eyebrow"].upper(), 10, inner, 14, acc, bold=True,
  256. spacing=1.7)
  257. cur = para(out, px, cur + 27, c["title"], 19, inner, 24, "#0F172A", bold=True)
  258. if c.get("subtitle"):
  259. cur = para(out, px, cur + 22, c["subtitle"], 12, inner, 16, "#64748B")
  260. cur += 14
  261. out.append(hline(px, cur, px + inner, "#EEF2F6"))
  262. for section in c.get("sections", []):
  263. cur = para(out, px, cur + 22, section["label"].upper(), 9.5, inner, 13, "#94A3B8",
  264. bold=True, spacing=1.3)
  265. dot = RED if section.get("tone") == "limit" else acc
  266. for bullet in section.get("bullets", []):
  267. base = cur + 21
  268. out.append(f'<circle cx="{px + 5}" cy="{base - 5:.0f}" r="2.4" fill="{dot}"/>')
  269. cur = para(out, px + 16, base, bullet, 12.5, inner - 16, 17, "#334155")
  270. cur += 4
  271. if c.get("chips"):
  272. cx, top = px, cur + 12
  273. for chip in c["chips"]:
  274. cw = text_w(chip, 11, bold=True) + 28
  275. if cx + cw > px + inner:
  276. cx, top = px, top + 36
  277. out.append(box(cx, top, cw, 28, fill="#F5F3FF", stroke="#DDD6FE", rx=6))
  278. out.append(txt(cx + cw / 2, top + 18, chip, 11, acc, bold=True, anchor="middle"))
  279. cx += cw + 10
  280. cur = top + 28
  281. for i, note in enumerate(c.get("notes", [])):
  282. cur = para(out, px, cur + (26 if i == 0 else 17), note, 11.5, inner, 16, "#64748B")
  283. h = max(cur + 24 - y, min_h)
  284. head = [box(x, y, w, h, stroke="#E2E8F0"),
  285. f'<rect x="{x}" y="{y:.0f}" width="{w}" height="4" rx="2" fill="{acc}"/>']
  286. return head + out, h
  287. def render_signals(x, y, w, content, lang):
  288. """Opportunities, blockers/risks and (when any) open questions as side-by-side panels."""
  289. t = L[lang]
  290. cols = [("opps", content.get("opportunities") or [], ACCENTS["green"]),
  291. ("risks", content.get("blockers") or [], RED)]
  292. if content.get("open_questions"):
  293. cols.append(("qs", content["open_questions"], ACCENTS["violet"]))
  294. col_w = (w - GAP * (len(cols) - 1)) // len(cols)
  295. panels, bottom = [], 0
  296. for i, (key, items, acc) in enumerate(cols):
  297. cx, out = x + i * (col_w + GAP), []
  298. px, inner = cx + PAD, col_w - 2 * PAD
  299. out.append(txt(px, y + 34, t[key], 13, acc, bold=True, spacing=2.2))
  300. out.append(hline(px, y + 50, px + inner, "#EEF2F6"))
  301. cur = y + 50
  302. if not items:
  303. cur = para(out, px, cur + 26, t["none_id"], 12.5, inner, 17, "#94A3B8", italic=True)
  304. for item in items:
  305. base = cur + (26 if cur == y + 50 else 21)
  306. out.append(f'<circle cx="{px + 5}" cy="{base - 5:.0f}" r="2.4" fill="{acc}"/>')
  307. cur = para(out, px + 16, base, item, 12.5, inner - 16, 17, "#334155")
  308. panels.append((cx, col_w, acc, out))
  309. bottom = max(bottom, cur)
  310. h = bottom + 24 - y
  311. elems = []
  312. for cx, cw, acc, out in panels:
  313. elems += [box(cx, y, cw, h, stroke="#E2E8F0"),
  314. f'<rect x="{cx}" y="{y:.0f}" width="{cw}" height="4" rx="2" fill="{acc}"/>']
  315. elems += out
  316. return elems, h
  317. def render_next(x, y, w, nxt, lang):
  318. t = L[lang]
  319. px, inner, out = x + PAD, w - 2 * PAD, []
  320. out.append(txt(px, y + 34, t["next"], 13, "#5A6878", bold=True, spacing=2.2))
  321. c_owner, c_when = px + inner * 0.60, px + inner * 0.80
  322. for label, cx in ((t["action"], px), (t["owner"], c_owner), (t["when"], c_when)):
  323. out.append(txt(cx, y + 66, label, 11.5, "#64748B", bold=True, spacing=1.6))
  324. cur = y + 76
  325. out.append(hline(px, cur, px + inner, "#DCE2EC"))
  326. for row in nxt.get("rows", []):
  327. base = cur + 26
  328. last = para(out, px, base, row["action"], 13.5, inner * 0.58, 18, "#1E293B")
  329. last = max(last, para(out, c_owner, base, row.get("owner", ""), 13.5, inner * 0.19,
  330. 18, "#1E293B"))
  331. last = max(last, para(out, c_when, base, row.get("when", ""), 12.5, inner * 0.20,
  332. 18, "#2563EB", bold=True))
  333. cur = last + 14
  334. out.append(hline(px, cur, px + inner, "#F3F6FA"))
  335. if nxt.get("note"):
  336. cur = para(out, px, cur + 24, nxt["note"], 12, inner, 16, "#5A6878", italic=True)
  337. h = cur + 24 - y
  338. return [box(x, y, w, h)] + out, h
  339. def render(session_dir, content):
  340. lang = content.get("lang", "en")
  341. t = L[lang]
  342. expected = content.get("expected_langs", ["es", "en"])
  343. mt = metrics(session_dir, expected)
  344. body = []
  345. # Header
  346. t0, t1 = mt["t0"], mt["t1"]
  347. month = t["months"].split()[t0.month - 1]
  348. date_line = (f"{t0.day} {month} {t0.year} · {t0:%H:%M} – {t1:%H:%M} · "
  349. f"{mt['n']} {t['segments']}")
  350. title_size = 32
  351. while text_w(content["title"], title_size, bold=True) > 1030 and title_size > 22:
  352. title_size -= 1
  353. hours, minutes = divmod(int(mt["length"] // 60), 60)
  354. stats = [(str(mt["n"]), t["segments"]),
  355. (f"{hours}h {minutes:02d}m" if hours else f"{minutes}m", t["length"]),
  356. (f"{mt['rt']:.1f}×", t["rt"])]
  357. body += ['<rect x="0" y="0" width="1680" height="160" fill="#0B1220"/>',
  358. '<rect x="0" y="158" width="1680" height="4" fill="#2563EB"/>',
  359. '<rect x="48" y="40" width="6" height="24" rx="3" fill="#3B82F6"/>',
  360. txt(68, 58, content.get("eyebrow", t["eyebrow"]).upper(), 13, "#94A3B8",
  361. bold=True, spacing=2.2),
  362. txt(48, 110, content["title"], title_size, "#F8FAFC", bold=True),
  363. txt(48, 140, date_line, 15, "#A8B6CA"),
  364. hline(1120, 48, 1632, "#22304A")]
  365. for i, (value, label) in enumerate(stats):
  366. body.append(txt(1120 + 180 * i, 94, value, 30, "#F8FAFC", bold=True))
  367. body.append(txt(1120 + 180 * i, 116, label, 12, "#8FA0B6"))
  368. # Main point band
  369. y = 184
  370. mp = content["main_point"]
  371. pill = mp.get("pill", "")
  372. pill_w = text_w(pill, 9.5, True, 1.1) + 32 if pill else 0
  373. status_w = text_w(mp.get("status", ""), 11, True)
  374. head_w = FULL_W - 2 * PAD - 154 - status_w - pill_w - 48
  375. band = []
  376. cur = para(band, M + PAD + 154, y + 38, mp["headline"], 21, head_w, 26, "#1F2937",
  377. bold=True)
  378. right = M + FULL_W - PAD
  379. if pill:
  380. band.append(box(right - pill_w, y + 22, pill_w, 26, stroke="#EFD6A4", rx=13))
  381. band.append(txt(right - pill_w / 2, y + 39, pill, 9.5, "#B45309", bold=True,
  382. anchor="middle", spacing=1.1))
  383. if mp.get("status"):
  384. band.append(txt(right - pill_w - 20, y + 38, mp["status"], 11, "#B45309", bold=True,
  385. anchor="end"))
  386. cur = para(band, M + PAD, cur + 32, mp.get("body", ""), 12.5, FULL_W - 2 * PAD, 20,
  387. "#475569")
  388. band_h = cur + 22 - y
  389. body += [box(M, y, FULL_W, band_h, fill="#FDF6E9", stroke="#EFD6A4"),
  390. f'<rect x="{M}" y="{y}" width="5" height="{band_h:.0f}" rx="2.5" fill="#B45309"/>',
  391. f'<rect x="{M + PAD}" y="{y + 20}" width="140" height="24" rx="12" '
  392. f'fill="#B45309"/>',
  393. txt(M + PAD + 70, y + 36, t["main"], 9.5, "#FFFFFF", bold=True, anchor="middle",
  394. spacing=1.2)] + band
  395. y += band_h + GAP
  396. # Sidebar
  397. side_y = y
  398. blocks = [
  399. lambda yy: render_focus(M, yy, SIDE_W, content.get("focus", []), lang),
  400. lambda yy: render_people(M, yy, SIDE_W, content.get("participants", []),
  401. content.get("participants_note"), lang),
  402. lambda yy: render_quality(M, yy, SIDE_W, mt, expected,
  403. content.get("corrections", []), lang),
  404. ]
  405. for block in blocks:
  406. elems, h = block(side_y)
  407. body += elems
  408. side_y += h + GAP
  409. # Topic cards: main column beside the sidebar, then two-column rows below it
  410. topics = list(content.get("topics", []))
  411. main_y = y
  412. while topics and main_y < side_y - GAP:
  413. elems, h = render_card(MAIN_X, main_y, MAIN_W, topics.pop(0))
  414. body += elems
  415. main_y += h + GAP
  416. y = max(side_y, main_y)
  417. while topics:
  418. if len(topics) == 1:
  419. elems, h = render_card(M, y, FULL_W, topics.pop(0))
  420. body += elems
  421. else:
  422. left, right_c = topics.pop(0), topics.pop(0)
  423. h = max(render_card(M, y, HALF_W, left)[1],
  424. render_card(M + HALF_W + GAP, y, HALF_W, right_c)[1])
  425. body += render_card(M, y, HALF_W, left, h)[0]
  426. body += render_card(M + HALF_W + GAP, y, HALF_W, right_c, h)[0]
  427. y += h + GAP
  428. for key in ("opportunities", "blockers"):
  429. if key not in content:
  430. print(f"warning: content JSON has no '{key}' key; the panel will read "
  431. f"'{t['none_id']}'", file=sys.stderr)
  432. elems, h = render_signals(M, y, FULL_W, content, lang)
  433. body += elems
  434. y += h + GAP
  435. if content.get("next_steps"):
  436. elems, h = render_next(M, y, FULL_W, content["next_steps"], lang)
  437. body += elems
  438. y += h + GAP
  439. # Footer
  440. body.append(hline(M, y, M + FULL_W, "#CBD5E1"))
  441. cur = y + 2
  442. for line in content.get("footer", []):
  443. cur = para(body, M, cur + 18, line, 10.5, FULL_W, 15, "#94A3B8")
  444. height = int(cur + 28)
  445. desc = content.get("description", content["main_point"]["headline"])
  446. head = ['<?xml version="1.0" encoding="UTF-8"?>',
  447. f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{height}" '
  448. f'viewBox="0 0 {W} {height}" font-family="{FONT}" xml:lang="{lang}" role="img" '
  449. f'aria-labelledby="ttl dsc">',
  450. f'<title id="ttl">{escape(content["title"])}</title>',
  451. f'<desc id="dsc">{escape(desc)}</desc>',
  452. f'<rect width="{W}" height="{height}" fill="#EEF2F7"/>']
  453. return "\n".join(head + body + ["</svg>", ""])
  454. def export_png(svg_path):
  455. """Export svg_path to a size-optimized PNG next to it (same stem, .png).
  456. Inkscape rasterizes at full width, then ImageMagick quantizes to 64 colors without
  457. dithering and strips metadata (~1.1 MB -> ~0.27 MB). The raw export lives in a private
  458. temp dir that is always deleted; only the final PNG is kept, beside the SVG.
  459. """
  460. svg = Path(svg_path)
  461. png = svg.with_suffix(".png")
  462. for tool in ("inkscape", "magick"):
  463. if shutil.which(tool) is None:
  464. sys.exit(f"error: '{tool}' not found on PATH; cannot export {png}")
  465. try:
  466. minidom.parse(str(svg))
  467. except Exception as exc:
  468. sys.exit(f"error: {svg} is not well-formed XML: {exc}")
  469. with tempfile.TemporaryDirectory(prefix="session-summary-") as tmp:
  470. raw = Path(tmp) / "full.png"
  471. steps = [
  472. ["inkscape", str(svg), "--export-type=png", f"--export-filename={raw}"],
  473. ["magick", str(raw), "-strip", "-colors", "64", "-dither", "None",
  474. "-define", "png:compression-level=9", "-define", "png:compression-strategy=1",
  475. str(png)],
  476. ]
  477. for cmd in steps:
  478. res = subprocess.run(cmd, capture_output=True, text=True)
  479. if res.returncode != 0:
  480. sys.exit(f"error: {cmd[0]} failed ({res.returncode}): {res.stderr.strip()}")
  481. if not png.is_file():
  482. sys.exit(f"error: export finished but {png} was not created")
  483. print(f"wrote {png} ({png.stat().st_size / 1024:.0f} KB)")
  484. def main():
  485. args = [a for a in sys.argv[1:] if a != "--no-png"]
  486. no_png = len(args) != len(sys.argv) - 1
  487. if len(args) != 3:
  488. sys.exit("usage: render.py <session_dir> <content.json> <out.svg> [--no-png]")
  489. session_dir, content_path, out_path = args
  490. content = json.loads(Path(content_path).read_text(encoding="utf-8"))
  491. Path(out_path).write_text(render(session_dir, content), encoding="utf-8")
  492. print(f"wrote {out_path}")
  493. if not no_png:
  494. export_png(out_path)
  495. if __name__ == "__main__":
  496. main()