| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- """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()
|