"""Render a session-summary SVG from a content JSON and the session's transcripts.jsonl. Usage: uv run python render.py [--no-png] Also exports .png next to the SVG (Inkscape + ImageMagick, 64-color quantized); pass --no-png to skip. A missing tool or failed export exits non-zero. Layout: header, main-point band, a fixed left sidebar (session focus, participants and roles, transcription quality), then any number of topic cards. Topic cards fill the main column next to the sidebar, then continue below it in two-column rows. A full-width signals block follows (opportunities and blockers/risks always, open questions when any), then an optional next-steps table and the footer. Text is wrapped and the canvas height is computed, so nothing is clipped. Transcription-quality metrics are computed from the JSONL, never typed in by hand. """ import json import shutil import statistics import subprocess import sys import tempfile from collections import Counter from datetime import datetime, timedelta from pathlib import Path from xml.dom import minidom from xml.sax.saxutils import escape W = 1680 M = 48 GAP = 24 PAD = 28 SIDE_W = 468 MAIN_X = M + SIDE_W + 32 MAIN_W = W - M - MAIN_X FULL_W = W - 2 * M HALF_W = (FULL_W - GAP) // 2 FONT = "'Inter','Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif" RED = "#B91C1C" ACCENTS = { "amber": "#B45309", "teal": "#0D9488", "red": "#B91C1C", "violet": "#7C3AED", "green": "#15803D", "blue": "#2563EB", "rose": "#BE123C", "slate": "#475569", } PEOPLE_COLORS = ["#7C3AED", "#2563EB", "#B45309", "#0F766E", "#15803D", "#BE123C", "#0369A1"] BAR_COLORS = ["#2563EB", "#60A5FA", "#93C5FD"] L = { "en": { "eyebrow": "SESSION RECONSTRUCTION", "segments": "transcribed segments", "length": "session length", "rt": "faster than real time", "main": "MAIN POINT", "focus": "SESSION FOCUS", "people": "PARTICIPANTS AND ROLES", "quality": "TRANSCRIPTION QUALITY", "next": "NEXT STEPS", "action": "ACTION", "owner": "OWNER", "when": "WHEN", "q_length": "Session length", "q_voiced": "Reconstructed speech", "q_pause": "Pause cuts", "q_low": "Low-confidence segments (p < 0.8)", "q_spk": "Speaker identified by the model", "q_unc": "Uncertain / unknown speaker", "q_mis": "Mis-detected languages", "q_lat": "Median latency per segment", "corr": "Names and terms corrected", "others": "Others", "none": "none", "opps": "OPPORTUNITIES", "risks": "BLOCKERS / RISKS", "qs": "OPEN QUESTIONS", "none_id": "None identified in this session", "langs": {"es": "Spanish", "en": "English"}, "months": "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", }, "es": { "eyebrow": "RECONSTRUCCIÓN DE SESIÓN", "segments": "segmentos transcritos", "length": "de sesión", "rt": "más rápido que tiempo real", "main": "PUNTO PRINCIPAL", "focus": "ENFOQUE DE LA SESIÓN", "people": "PARTICIPANTES Y ROLES", "quality": "CALIDAD DE LA TRANSCRIPCIÓN", "next": "PRÓXIMOS PASOS", "action": "ACCIÓN", "owner": "RESPONSABLE", "when": "CUÁNDO", "q_length": "Duración de la sesión", "q_voiced": "Voz reconstruida", "q_pause": "Cortes por pausa", "q_low": "Segmentos con confianza baja (p < 0.8)", "q_spk": "Voz identificada por el modelo", "q_unc": "Hablante dudoso / desconocido", "q_mis": "Idiomas mal detectados", "q_lat": "Latencia mediana por segmento", "corr": "Nombres y términos corregidos", "others": "Otros", "none": "ninguno", "opps": "OPORTUNIDADES", "risks": "BLOQUEOS / RIESGOS", "qs": "PREGUNTAS ABIERTAS", "none_id": "Ninguno identificado en esta sesión", "langs": {"es": "Español", "en": "Inglés"}, "months": "ene feb mar abr may jun jul ago sep oct nov dic", }, } def char_w(c): if c in "ilI.,:;'|!·": return 0.26 if c in "fjrt()[]-": return 0.34 if c == " ": return 0.28 if c in "mwMW—": return 0.86 if c.isdigit(): return 0.56 if c.isupper(): return 0.68 return 0.53 def text_w(s, size, bold=False, spacing=0.0): w = sum(char_w(c) for c in s) * size * 1.05 return (w * 1.07 if bold else w) + spacing * len(s) def wrap(s, size, width, bold=False, spacing=0.0): lines, cur = [], "" for word in str(s).split(): trial = f"{cur} {word}" if cur else word if not cur or text_w(trial, size, bold, spacing) <= width: cur = trial else: lines.append(cur) cur = word return lines + [cur] if cur else lines or [""] def txt(x, y, s, size, fill, bold=False, anchor=None, spacing=None, italic=False): attrs = f'x="{x:.0f}" y="{y:.0f}" font-size="{size}" fill="{fill}"' if bold: attrs += ' font-weight="700"' if anchor: attrs += f' text-anchor="{anchor}"' if spacing: attrs += f' letter-spacing="{spacing}"' if italic: attrs += ' font-style="italic"' return f"{escape(str(s))}" def para(out, x, y, s, size, width, lh, fill, **style): """Draw wrapped text with the first baseline at y; return the last baseline.""" lines = wrap(s, size, width, style.get("bold", False), style.get("spacing") or 0.0) for i, line in enumerate(lines): out.append(txt(x, y + i * lh, line, size, fill, **style)) return y + (len(lines) - 1) * lh def box(x, y, w, h, fill="#FFFFFF", stroke="#DCE2EC", rx=12): return (f'') def hline(x1, y, x2, color="#EDF1F7"): return f'' def fmt_dur(sec): sec = int(round(sec)) h, rem = divmod(sec, 3600) m, s = divmod(rem, 60) return f"{h} h {m:02d} min {s:02d} s" if h else f"{m} min {s:02d} s" def metrics(session_dir, expected): path = Path(session_dir) / "transcripts.jsonl" segs = [json.loads(line) for line in path.open(encoding="utf-8") if line.strip()] t0 = datetime.fromisoformat(segs[0]["ts"]) t1 = datetime.fromisoformat(segs[-1]["ts"]) + timedelta(seconds=segs[-1].get("duration_s") or 0) voiced = sum(s.get("duration_s") or 0 for s in segs) tx = [s["transcribe_s"] for s in segs if s.get("transcribe_s")] status = Counter(s.get("speaker_status") for s in segs) langs = Counter(s.get("lang") or "?" for s in segs) return { "n": len(segs), "t0": t0, "t1": t1, "length": (t1 - t0).total_seconds(), "voiced": voiced, "pause": sum(1 for s in segs if s.get("end_reason") == "pause"), "low": sum(1 for s in segs if (s.get("lang_prob") or 0) < 0.8), "matched": status["matched"], "uncertain": status["uncertain"], "unknown": status["unknown"], "langs": langs, "misdetected": [code for code, _ in langs.most_common() if code not in expected], "latency": statistics.median(tx) if tx else 0.0, "rt": voiced / sum(tx) if tx else 0.0, } def panel_title(out, x, y, w, title): out.append(txt(x + PAD, y + 34, title, 13, "#5A6878", bold=True, spacing=2.2)) out.append(hline(x + PAD, y + 50, x + w - PAD)) return y + 50 def render_focus(x, y, w, items, lang): out = [] cur = panel_title(out, x, y, w, L[lang]["focus"]) inner = w - 2 * PAD for i, item in enumerate(items): base = cur + (28 if i == 0 else 30) color = ACCENTS["amber"] if item.get("tone") == "warn" else ACCENTS["blue"] out.append(f'') cur = para(out, x + PAD + 20, base, item["label"], 14.5, inner - 20, 19, "#0F172A", bold=True) if item.get("detail"): cur = para(out, x + PAD + 20, cur + 20, item["detail"], 12, inner - 20, 16, "#5A6878", italic=item.get("tone") == "warn") h = cur + 26 - y return [box(x, y, w, h)] + out, h def render_people(x, y, w, people, note, lang): out = [] cur = panel_title(out, x, y, w, L[lang]["people"]) inner = w - 2 * PAD for i, p in enumerate(people): base = cur + (32 if i == 0 else 30) speaks = p.get("status", "speaks") == "speaks" color = PEOPLE_COLORS[i % len(PEOPLE_COLORS)] if speaks else "#94A3B8" initial = p.get("initial") or p["name"][:1].upper() out.append(f'') out.append(txt(x + PAD + 14, base - 1, initial, 12, "#FFFFFF", bold=True, anchor="middle")) cur = para(out, x + PAD + 40, base, p["name"], 14.5, inner - 40, 18, "#0F172A", bold=True) if p.get("role"): cur = para(out, x + PAD + 40, cur + 18, p["role"], 12, inner - 40, 16, "#5A6878") if note: out.append(hline(x + PAD, cur + 16, x + w - PAD, "#F3F6FA")) cur = para(out, x + PAD, cur + 38, note, 12, inner, 16, "#5A6878", italic=True) h = cur + 26 - y return [box(x, y, w, h)] + out, h def render_quality(x, y, w, mt, expected, corrections, lang): t = L[lang] out = [] cur = panel_title(out, x, y, w, t["quality"]) inner = w - 2 * PAD n = mt["n"] parts = [(t["langs"].get(c, c.upper()), mt["langs"][c]) for c in expected if mt["langs"][c]] other = n - sum(v for _, v in parts) if other: parts.append((t["others"], other)) bar_y, bx = cur + 20, x + PAD out.append(f'') legend = [] for i, (name, count) in enumerate(parts): color = "#CBD5E1" if name == t["others"] else BAR_COLORS[min(i, len(BAR_COLORS) - 1)] seg_w = inner * count / n out.append(f'') bx += seg_w legend.append((f"{name} {count} ({round(100 * count / n)} %)", color)) out.append("") lx, ly = x + PAD, bar_y + 42 for label, color in legend: lw = text_w(label, 12) + 30 if lx + lw > x + w - PAD: lx, ly = x + PAD, ly + 20 out.append(f'') out.append(txt(lx + 14, ly, label, 12, "#5A6878")) lx += lw cur = ly + 14 out.append(hline(x + PAD, cur, x + w - PAD)) pct = lambda k: f"{mt[k]} ({round(100 * mt[k] / n)} %)" # noqa: E731 rows = [ (t["q_length"], fmt_dur(mt["length"])), (t["q_voiced"], fmt_dur(mt["voiced"])), (t["q_pause"], f"{mt['pause']} / {n}"), (t["q_low"], pct("low")), (t["q_spk"], f"{round(100 * mt['matched'] / n)} % ({mt['matched']} / {n})"), (t["q_unc"], f"{mt['uncertain']} / {mt['unknown']}"), (t["q_mis"], " · ".join(mt["misdetected"][:8]) or t["none"]), (t["q_lat"], f"{mt['latency']:.2f} s"), ] for i, (label, value) in enumerate(rows): base = cur + 26 out.append(txt(x + PAD, base, label, 12, "#5A6878")) out.append(txt(x + w - PAD, base, value, 14.5, "#0F172A", bold=True, anchor="end")) cur = base + 10 if i < len(rows) - 1: out.append(hline(x + PAD, cur, x + w - PAD, "#F3F6FA")) if corrections: top, body = cur + 18, [] last = para(body, x + PAD + 16, top + 22, t["corr"], 12, inner - 32, 16, "#334155", bold=True) for line in corrections: last = para(body, x + PAD + 16, last + 18, line, 12, inner - 32, 16, "#5A6878") bh = last + 16 - top out.append(box(x + PAD, top, inner, bh, fill="#F8FAFC", stroke="none", rx=8)) out.append(f'') out.extend(body) cur = top + bh h = cur + 26 - y return [box(x, y, w, h)] + out, h def render_card(x, y, w, c, min_h=0): acc = ACCENTS.get(c.get("accent", "blue"), ACCENTS["blue"]) px, inner, out = x + PAD, w - 2 * PAD, [] cur = para(out, px, y + 32, c["eyebrow"].upper(), 10, inner, 14, acc, bold=True, spacing=1.7) cur = para(out, px, cur + 27, c["title"], 19, inner, 24, "#0F172A", bold=True) if c.get("subtitle"): cur = para(out, px, cur + 22, c["subtitle"], 12, inner, 16, "#64748B") cur += 14 out.append(hline(px, cur, px + inner, "#EEF2F6")) for section in c.get("sections", []): cur = para(out, px, cur + 22, section["label"].upper(), 9.5, inner, 13, "#94A3B8", bold=True, spacing=1.3) dot = RED if section.get("tone") == "limit" else acc for bullet in section.get("bullets", []): base = cur + 21 out.append(f'') cur = para(out, px + 16, base, bullet, 12.5, inner - 16, 17, "#334155") cur += 4 if c.get("chips"): cx, top = px, cur + 12 for chip in c["chips"]: cw = text_w(chip, 11, bold=True) + 28 if cx + cw > px + inner: cx, top = px, top + 36 out.append(box(cx, top, cw, 28, fill="#F5F3FF", stroke="#DDD6FE", rx=6)) out.append(txt(cx + cw / 2, top + 18, chip, 11, acc, bold=True, anchor="middle")) cx += cw + 10 cur = top + 28 for i, note in enumerate(c.get("notes", [])): cur = para(out, px, cur + (26 if i == 0 else 17), note, 11.5, inner, 16, "#64748B") h = max(cur + 24 - y, min_h) head = [box(x, y, w, h, stroke="#E2E8F0"), f''] return head + out, h def render_signals(x, y, w, content, lang): """Opportunities, blockers/risks and (when any) open questions as side-by-side panels.""" t = L[lang] cols = [("opps", content.get("opportunities") or [], ACCENTS["green"]), ("risks", content.get("blockers") or [], RED)] if content.get("open_questions"): cols.append(("qs", content["open_questions"], ACCENTS["violet"])) col_w = (w - GAP * (len(cols) - 1)) // len(cols) panels, bottom = [], 0 for i, (key, items, acc) in enumerate(cols): cx, out = x + i * (col_w + GAP), [] px, inner = cx + PAD, col_w - 2 * PAD out.append(txt(px, y + 34, t[key], 13, acc, bold=True, spacing=2.2)) out.append(hline(px, y + 50, px + inner, "#EEF2F6")) cur = y + 50 if not items: cur = para(out, px, cur + 26, t["none_id"], 12.5, inner, 17, "#94A3B8", italic=True) for item in items: base = cur + (26 if cur == y + 50 else 21) out.append(f'') cur = para(out, px + 16, base, item, 12.5, inner - 16, 17, "#334155") panels.append((cx, col_w, acc, out)) bottom = max(bottom, cur) h = bottom + 24 - y elems = [] for cx, cw, acc, out in panels: elems += [box(cx, y, cw, h, stroke="#E2E8F0"), f''] elems += out return elems, h def render_next(x, y, w, nxt, lang): t = L[lang] px, inner, out = x + PAD, w - 2 * PAD, [] out.append(txt(px, y + 34, t["next"], 13, "#5A6878", bold=True, spacing=2.2)) c_owner, c_when = px + inner * 0.60, px + inner * 0.80 for label, cx in ((t["action"], px), (t["owner"], c_owner), (t["when"], c_when)): out.append(txt(cx, y + 66, label, 11.5, "#64748B", bold=True, spacing=1.6)) cur = y + 76 out.append(hline(px, cur, px + inner, "#DCE2EC")) for row in nxt.get("rows", []): base = cur + 26 last = para(out, px, base, row["action"], 13.5, inner * 0.58, 18, "#1E293B") last = max(last, para(out, c_owner, base, row.get("owner", ""), 13.5, inner * 0.19, 18, "#1E293B")) last = max(last, para(out, c_when, base, row.get("when", ""), 12.5, inner * 0.20, 18, "#2563EB", bold=True)) cur = last + 14 out.append(hline(px, cur, px + inner, "#F3F6FA")) if nxt.get("note"): cur = para(out, px, cur + 24, nxt["note"], 12, inner, 16, "#5A6878", italic=True) h = cur + 24 - y return [box(x, y, w, h)] + out, h def render(session_dir, content): lang = content.get("lang", "en") t = L[lang] expected = content.get("expected_langs", ["es", "en"]) mt = metrics(session_dir, expected) body = [] # Header t0, t1 = mt["t0"], mt["t1"] month = t["months"].split()[t0.month - 1] date_line = (f"{t0.day} {month} {t0.year} · {t0:%H:%M} – {t1:%H:%M} · " f"{mt['n']} {t['segments']}") title_size = 32 while text_w(content["title"], title_size, bold=True) > 1030 and title_size > 22: title_size -= 1 hours, minutes = divmod(int(mt["length"] // 60), 60) stats = [(str(mt["n"]), t["segments"]), (f"{hours}h {minutes:02d}m" if hours else f"{minutes}m", t["length"]), (f"{mt['rt']:.1f}×", t["rt"])] body += ['', '', '', txt(68, 58, content.get("eyebrow", t["eyebrow"]).upper(), 13, "#94A3B8", bold=True, spacing=2.2), txt(48, 110, content["title"], title_size, "#F8FAFC", bold=True), txt(48, 140, date_line, 15, "#A8B6CA"), hline(1120, 48, 1632, "#22304A")] for i, (value, label) in enumerate(stats): body.append(txt(1120 + 180 * i, 94, value, 30, "#F8FAFC", bold=True)) body.append(txt(1120 + 180 * i, 116, label, 12, "#8FA0B6")) # Main point band y = 184 mp = content["main_point"] pill = mp.get("pill", "") pill_w = text_w(pill, 9.5, True, 1.1) + 32 if pill else 0 status_w = text_w(mp.get("status", ""), 11, True) head_w = FULL_W - 2 * PAD - 154 - status_w - pill_w - 48 band = [] cur = para(band, M + PAD + 154, y + 38, mp["headline"], 21, head_w, 26, "#1F2937", bold=True) right = M + FULL_W - PAD if pill: band.append(box(right - pill_w, y + 22, pill_w, 26, stroke="#EFD6A4", rx=13)) band.append(txt(right - pill_w / 2, y + 39, pill, 9.5, "#B45309", bold=True, anchor="middle", spacing=1.1)) if mp.get("status"): band.append(txt(right - pill_w - 20, y + 38, mp["status"], 11, "#B45309", bold=True, anchor="end")) cur = para(band, M + PAD, cur + 32, mp.get("body", ""), 12.5, FULL_W - 2 * PAD, 20, "#475569") band_h = cur + 22 - y body += [box(M, y, FULL_W, band_h, fill="#FDF6E9", stroke="#EFD6A4"), f'', f'', txt(M + PAD + 70, y + 36, t["main"], 9.5, "#FFFFFF", bold=True, anchor="middle", spacing=1.2)] + band y += band_h + GAP # Sidebar side_y = y blocks = [ lambda yy: render_focus(M, yy, SIDE_W, content.get("focus", []), lang), lambda yy: render_people(M, yy, SIDE_W, content.get("participants", []), content.get("participants_note"), lang), lambda yy: render_quality(M, yy, SIDE_W, mt, expected, content.get("corrections", []), lang), ] for block in blocks: elems, h = block(side_y) body += elems side_y += h + GAP # Topic cards: main column beside the sidebar, then two-column rows below it topics = list(content.get("topics", [])) main_y = y while topics and main_y < side_y - GAP: elems, h = render_card(MAIN_X, main_y, MAIN_W, topics.pop(0)) body += elems main_y += h + GAP y = max(side_y, main_y) while topics: if len(topics) == 1: elems, h = render_card(M, y, FULL_W, topics.pop(0)) body += elems else: left, right_c = topics.pop(0), topics.pop(0) h = max(render_card(M, y, HALF_W, left)[1], render_card(M + HALF_W + GAP, y, HALF_W, right_c)[1]) body += render_card(M, y, HALF_W, left, h)[0] body += render_card(M + HALF_W + GAP, y, HALF_W, right_c, h)[0] y += h + GAP for key in ("opportunities", "blockers"): if key not in content: print(f"warning: content JSON has no '{key}' key; the panel will read " f"'{t['none_id']}'", file=sys.stderr) elems, h = render_signals(M, y, FULL_W, content, lang) body += elems y += h + GAP if content.get("next_steps"): elems, h = render_next(M, y, FULL_W, content["next_steps"], lang) body += elems y += h + GAP # Footer body.append(hline(M, y, M + FULL_W, "#CBD5E1")) cur = y + 2 for line in content.get("footer", []): cur = para(body, M, cur + 18, line, 10.5, FULL_W, 15, "#94A3B8") height = int(cur + 28) desc = content.get("description", content["main_point"]["headline"]) head = ['', f'', f'{escape(content["title"])}', f'{escape(desc)}', f''] return "\n".join(head + body + ["", ""]) def export_png(svg_path): """Export svg_path to a size-optimized PNG next to it (same stem, .png). Inkscape rasterizes at full width, then ImageMagick quantizes to 64 colors without dithering and strips metadata (~1.1 MB -> ~0.27 MB). The raw export lives in a private temp dir that is always deleted; only the final PNG is kept, beside the SVG. """ svg = Path(svg_path) png = svg.with_suffix(".png") for tool in ("inkscape", "magick"): if shutil.which(tool) is None: sys.exit(f"error: '{tool}' not found on PATH; cannot export {png}") try: minidom.parse(str(svg)) except Exception as exc: sys.exit(f"error: {svg} is not well-formed XML: {exc}") with tempfile.TemporaryDirectory(prefix="session-summary-") as tmp: raw = Path(tmp) / "full.png" steps = [ ["inkscape", str(svg), "--export-type=png", f"--export-filename={raw}"], ["magick", str(raw), "-strip", "-colors", "64", "-dither", "None", "-define", "png:compression-level=9", "-define", "png:compression-strategy=1", str(png)], ] for cmd in steps: res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode != 0: sys.exit(f"error: {cmd[0]} failed ({res.returncode}): {res.stderr.strip()}") if not png.is_file(): sys.exit(f"error: export finished but {png} was not created") print(f"wrote {png} ({png.stat().st_size / 1024:.0f} KB)") def main(): args = [a for a in sys.argv[1:] if a != "--no-png"] no_png = len(args) != len(sys.argv) - 1 if len(args) != 3: sys.exit("usage: render.py [--no-png]") session_dir, content_path, out_path = args content = json.loads(Path(content_path).read_text(encoding="utf-8")) Path(out_path).write_text(render(session_dir, content), encoding="utf-8") print(f"wrote {out_path}") if not no_png: export_png(out_path) if __name__ == "__main__": main()