|
|
@@ -0,0 +1,552 @@
|
|
|
+"""Render a session-summary SVG from a content JSON and the session's transcripts.jsonl.
|
|
|
+
|
|
|
+Usage: uv run python render.py <session_dir> <content.json> <out.svg> [--no-png]
|
|
|
+
|
|
|
+Also exports <out>.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"<text {attrs}>{escape(str(s))}</text>"
|
|
|
+
|
|
|
+
|
|
|
+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'<rect x="{x:.0f}" y="{y:.0f}" width="{w:.0f}" height="{h:.0f}" rx="{rx}" '
|
|
|
+ f'fill="{fill}" stroke="{stroke}"/>')
|
|
|
+
|
|
|
+
|
|
|
+def hline(x1, y, x2, color="#EDF1F7"):
|
|
|
+ return f'<line x1="{x1:.0f}" y1="{y:.0f}" x2="{x2:.0f}" y2="{y:.0f}" stroke="{color}"/>'
|
|
|
+
|
|
|
+
|
|
|
+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'<rect x="{x + PAD}" y="{base - 9:.0f}" width="8" height="8" rx="2" '
|
|
|
+ f'fill="{color}"/>')
|
|
|
+ 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'<circle cx="{x + PAD + 14}" cy="{base - 5:.0f}" r="14" fill="{color}"/>')
|
|
|
+ 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'<clipPath id="langbar"><rect x="{bx}" y="{bar_y:.0f}" width="{inner}" '
|
|
|
+ f'height="18" rx="9"/></clipPath><g clip-path="url(#langbar)">')
|
|
|
+ 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'<rect x="{bx:.1f}" y="{bar_y:.0f}" width="{seg_w + 0.5:.1f}" height="18" '
|
|
|
+ f'fill="{color}"/>')
|
|
|
+ bx += seg_w
|
|
|
+ legend.append((f"{name} {count} ({round(100 * count / n)} %)", color))
|
|
|
+ out.append("</g>")
|
|
|
+ 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'<circle cx="{lx + 4:.0f}" cy="{ly - 4:.0f}" r="4" fill="{color}"/>')
|
|
|
+ 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'<rect x="{x + PAD}" y="{top:.0f}" width="3" height="{bh:.0f}" rx="1.5" '
|
|
|
+ f'fill="#94A3B8"/>')
|
|
|
+ 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'<circle cx="{px + 5}" cy="{base - 5:.0f}" r="2.4" fill="{dot}"/>')
|
|
|
+ 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'<rect x="{x}" y="{y:.0f}" width="{w}" height="4" rx="2" fill="{acc}"/>']
|
|
|
+ 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'<circle cx="{px + 5}" cy="{base - 5:.0f}" r="2.4" fill="{acc}"/>')
|
|
|
+ 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'<rect x="{cx}" y="{y:.0f}" width="{cw}" height="4" rx="2" fill="{acc}"/>']
|
|
|
+ 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 += ['<rect x="0" y="0" width="1680" height="160" fill="#0B1220"/>',
|
|
|
+ '<rect x="0" y="158" width="1680" height="4" fill="#2563EB"/>',
|
|
|
+ '<rect x="48" y="40" width="6" height="24" rx="3" fill="#3B82F6"/>',
|
|
|
+ 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'<rect x="{M}" y="{y}" width="5" height="{band_h:.0f}" rx="2.5" fill="#B45309"/>',
|
|
|
+ f'<rect x="{M + PAD}" y="{y + 20}" width="140" height="24" rx="12" '
|
|
|
+ f'fill="#B45309"/>',
|
|
|
+ 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 = ['<?xml version="1.0" encoding="UTF-8"?>',
|
|
|
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{height}" '
|
|
|
+ f'viewBox="0 0 {W} {height}" font-family="{FONT}" xml:lang="{lang}" role="img" '
|
|
|
+ f'aria-labelledby="ttl dsc">',
|
|
|
+ f'<title id="ttl">{escape(content["title"])}</title>',
|
|
|
+ f'<desc id="dsc">{escape(desc)}</desc>',
|
|
|
+ f'<rect width="{W}" height="{height}" fill="#EEF2F7"/>']
|
|
|
+ return "\n".join(head + body + ["</svg>", ""])
|
|
|
+
|
|
|
+
|
|
|
+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 <session_dir> <content.json> <out.svg> [--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()
|